# Secret rotation

Source: https://solidhooks.com/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`.

```bash
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.

```bash
curl -s "$API/v1/sources/src_.../rotation-status" \
  -H "authorization: Bearer $SDHK_KEY"
```

```json
{
  "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.

```bash
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\"}" }'
```

```json
{ "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.

```bash
curl -s "$API/v1/sources/src_.../cancel-rotation" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{}'
```

| Response                         | Meaning                                                                                                                                                              |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `409 ROTATION_IN_USE`            | The 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_UNAVAILABLE` | The 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.

```bash
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:

```json
{
  "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.

```bash
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](https://solidhooks.com/docs/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.

```bash
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

| Action                                                                                | Needs                       |
| ------------------------------------------------------------------------------------- | --------------------------- |
| `rotate-secret`, `cancel-rotation`, `rotate-mask`                                     | `source: read & write`      |
| `rotation-status`, `test-signature`                                                   | `source: read`              |
| `rotate-signing-secret`, `end-rotation-overlap`, `signature-scheme`, `signing-secret` | `destination: read & write` |

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

## Next steps

* [Verification](https://solidhooks.com/docs/verification) — how sources check inbound requests
* [Delivery signatures](https://solidhooks.com/docs/delivery-signatures) — verify what SolidHooks sends you
* [Sources](https://solidhooks.com/docs/sources) — ingest URLs and masks
* [API keys](https://solidhooks.com/docs/api-keys) — scope a key for rotation work