SolidHooksSolidHooks/ Docs

Secret rotation

Rotate source verifier secrets, destination signing secrets and ingest URLs without dropping webhooks.

SolidHooks holds three kinds of secret, and they rotate in different directions.

  • A source verifier secret is what SolidHooks uses to check inbound webhooks. The producer (Stripe, GitHub, Shopify) generates it, you paste it in.
  • A destination signing secret is what SolidHooks uses to sign outbound deliveries. SolidHooks generates it, your receiver verifies with it.
  • A source mask is the unguessable part of the ingest URL or email address. It is a credential too.

Rotation is a hand-off between two systems that cannot change at the same instant. Both of the first two support an overlap window for that reason.

All rotation actions need admin or owner access. A member, or an unscoped API key, gets 403. A scoped key needs read & write on source or destination. The exceptions are called out below.

Source verifier secrets

During the overlap window SolidHooks accepts both the old and the new secret. Without it there is no safe order: paste the new secret here first and traffic still signed with the old one fails, switch at the producer first and traffic signed with the new one fails. It works for every verifier type, because it acts on the stored secret and not the signature format. A source with no verifier (noop) has no secret to rotate, and the call returns 400.

Start a rotation

overlapDays is required and must be 1, 7 or 30.

curl -s "$API/v1/sources/src_.../rotate-secret" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "secret": "<the new secret from the producer>", "overlapDays": 7 }'

The response is the updated source. Now switch the secret at the producer. Rotating again while a window is still open is refused with 409 ROTATION_OVERLAP_ACTIVE and data: { expiresAt }, because it would discard a secret the producer may still be signing with. Wait for that time or cancel the rotation first.

Watch the switch happen

SolidHooks does the verifying here, so it can tell which secret each request matched.

curl -s "$API/v1/sources/src_.../rotation-status" \
  -H "authorization: Bearer $SDHK_KEY"
{
  "rotating": true,
  "expiresAt": "2026-10-12T09:00:00.000Z",
  "countersAvailable": true,
  "current": { "count": 41, "lastSeenAt": "2026-10-05T09:14:02.000Z" },
  "previous": { "count": 3, "lastSeenAt": "2026-10-05T08:51:40.000Z" }
}

current is the new secret and previous is the old one. When previous stops growing and current is counting, the producer has switched.

null is not zero

If the counter store cannot be reached, you get countersAvailable: false and count: null for both slots. Zero means the producer has not switched yet. Null means SolidHooks could not ask. Never read a null as "safe to close".

This endpoint needs only source: read, so it works with a member-tier key.

Test a request against both secrets

POST /v1/sources/{id}/test-signature checks a sample against the live secrets. Send the exact headers and the raw body, byte for byte.

curl -s "$API/v1/sources/src_.../test-signature" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "headers": { "x-hub-signature-256": "sha256=..." }, "body": "{\"action\":\"ping\"}" }'
{ "verified": true, "slot": "previous", "reason": null, "rotating": true }

slot is current, previous or null. previous means that request was signed with the old secret. On failure, reason says why, from the same list as rejectionReason on a rejected event (for example signature_mismatch or timestamp_out_of_tolerance). Testing never records slot usage, so it cannot change the counts above. It needs only source: read and is rate limited per source to about one request a second with a burst of 10, returning 429 RATE_LIMITED beyond that.

Cancel a rotation

If you pasted the wrong secret, POST /v1/sources/{id}/cancel-rotation restores the previous secret and closes the window. It returns 400 if no rotation is in progress.

curl -s "$API/v1/sources/src_.../cancel-rotation" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{}'
ResponseMeaning
409 ROTATION_IN_USEThe new secret has already verified requests, so the producer has switched. Reverting would reject traffic they sign correctly. Body: data: { count, lastSeenAt }.
503 ROTATION_USAGE_UNAVAILABLEThe counters could not be read, so SolidHooks cannot tell whether it is safe. The revert is refused rather than guessed. Retry shortly.

Send { "force": true } to skip the check and revert anyway. It also works during a counter outage. Use it only when you are sure the producer is still on the old secret.

Notifications

When the window is 24 hours from closing, SolidHooks emails the organisation's owners and admins, but only if the old secret is still receiving traffic. If the producer has already switched, the window closes without an email.

Destination signing secrets

Here the roles are reversed. SolidHooks generates the secret, and your receiver has to adopt it. SolidHooks cannot see which secret your receiver verifies with, so there is no usage count and no way to undo a rotation.

Fetch the current secret, and any live previous one, with GET /v1/destinations/{id}/signing-secret. That action and every rotation action below need destination: read & write.

Rotate with an overlap

Pass overlapDays of 1, 7 or 30 on a standard_webhooks destination. Every delivery then carries both signatures in webhook-signature, so your receiver accepts either until the window closes.

curl -s "$API/v1/destinations/dest_.../rotate-signing-secret" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "overlapDays": 7 }'

While the window is open, the signing-secret endpoint returns both values:

{
  "secret": "whsec_...",
  "previousSecret": "whsec_...",
  "previousSecretExpiresAt": "2026-10-12T09:00:00.000Z"
}

Both previous fields are null once the window is over. SolidHooks emails owners and admins 24 hours before it closes. To retire the old secret sooner, call POST /v1/destinations/{id}/end-rotation-overlap. That does not revert anything. Deliveries have been signed with the new secret since the rotation. Closing an already closed window is a no-op.

Rotating again during a live window returns 409 ROTATION_OVERLAP_ACTIVE with data: { expiresAt }. Send "force": true to rotate anyway, accepting that receivers who had not yet adopted the current secret will be stranded.

Rotate with a hard cutover

Omit overlapDays and the old secret stops working the moment the call returns. Update your receiver first, or deliveries fail verification until you do.

curl -s "$API/v1/destinations/dest_.../rotate-signing-secret" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{}'

A solidhooks_v1 destination is always a hard cutover, because its signature header holds one value and cannot carry two. Passing overlapDays there is refused with 400 rather than ignored, so you are never told you have a window you do not have. See Delivery signatures for the two schemes.

Changing the scheme is not a rotation

POST /v1/destinations/{id}/signature-scheme with { "scheme": "standard_webhooks" } or "solidhooks_v1" moves a destination to a different signing scheme and mints a new secret. It is breaking and has no grace period. An overlap signs with two secrets under one scheme, and cannot span two. Make your receiver accept the new scheme first, then switch, then fetch the new secret. Switching to the scheme already in use does nothing. It needs the same permission as rotation.

Rotate the ingest URL or email address

POST /v1/sources/{id}/rotate-mask generates a new mask. The ingest URL (https://sdhk.events/<maskId>) changes, and so does an email source's address (<maskId>@sdhk.events), because it is built from the same mask.

curl -s -X POST "$API/v1/sources/src_.../rotate-mask" \
  -H "authorization: Bearer $SDHK_KEY"

The response is the updated source with the new ingestUrl. There is no overlap window: the mask is replaced, so update the producer straight away. It needs source: read & write.

Permissions at a glance

ActionNeeds
rotate-secret, cancel-rotation, rotate-masksource: read & write
rotation-status, test-signaturesource: read
rotate-signing-secret, end-rotation-overlap, signature-scheme, signing-secretdestination: read & write

A call outside a key's grant returns 403 INSUFFICIENT_API_KEY_PERMISSIONS.

Next steps