> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stableyard.fi/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying webhook signatures

> Check each delivery's HMAC-SHA256 signature against the raw body before you trust or parse it.

Stableyard signs every delivery with the endpoint's signing secret. Verify the signature on the raw request bytes before you parse the JSON or act on it, and reject anything that fails.

## How the signature is built

`x-stableyard-signature` has the form `t=<timestamp>,v1=<hmac_sha256>`. The digest is HMAC-SHA256, hex-encoded, over:

```text theme={null}
<timestamp>.<deliveryId>.<eventId>.<rawBody>
```

| Part | Where it comes from |
| - | - |
| `timestamp` | The `t=` value. It must equal `x-stableyard-timestamp` |
| `deliveryId` | `x-stableyard-delivery` |
| `eventId` | `x-stableyard-event-id` |
| `rawBody` | The request body bytes exactly as received |
| Key | The endpoint's signing secret, `whsec_…`, returned once at creation or rotation |

`GET /v2/partners/config` describes the same scheme under `capabilities.webhooks.signatures`, so you can check your implementation against the deployment you call:

```json theme={null}
"signatures": {
  "header": "x-stableyard-signature",
  "timestampHeader": "x-stableyard-timestamp",
  "deliveryHeader": "x-stableyard-delivery",
  "eventHeader": "x-stableyard-event",
  "scheme": "hmac-sha256",
  "signedPayload": "<timestamp>.<deliveryId>.<eventId>.<rawBody>"
}
```

## Verify a delivery

Verify the raw request bytes before JSON parsing, require the signed timestamp to match `x-stableyard-timestamp`, reject timestamps outside a short tolerance, and compare digests in constant time.

```js theme={null}
import crypto from "node:crypto";

const MAX_TIMESTAMP_SKEW_SECONDS = 300;

function requiredHeader(headers, name) {
  const value = headers[name];
  if (typeof value !== "string" || value.length === 0) {
    throw new Error(`Missing ${name}`);
  }
  return value;
}

export function verifyStableyardWebhook(rawBody, headers, signingSecret) {
  if (!Buffer.isBuffer(rawBody)) throw new Error("rawBody must be a Buffer");

  const signature = requiredHeader(headers, "x-stableyard-signature");
  const timestampHeader = requiredHeader(headers, "x-stableyard-timestamp");
  const deliveryId = requiredHeader(headers, "x-stableyard-delivery");
  const eventId = requiredHeader(headers, "x-stableyard-event-id");
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/i.exec(signature);
  if (!match) throw new Error("Malformed webhook signature");

  const [, signedTimestamp, providedHex] = match;
  if (signedTimestamp !== timestampHeader) {
    throw new Error("Webhook timestamp mismatch");
  }

  const timestamp = Number(signedTimestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > MAX_TIMESTAMP_SKEW_SECONDS) {
    throw new Error("Webhook timestamp outside tolerance");
  }

  const prefix = Buffer.from(`${signedTimestamp}.${deliveryId}.${eventId}.`, "utf8");
  const payload = Buffer.concat([prefix, rawBody]);
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(payload)
    .digest();
  const provided = Buffer.from(providedHex, "hex");

  if (provided.length !== expected.length || !crypto.timingSafeEqual(expected, provided)) {
    throw new Error("Invalid webhook signature");
  }

  return { deliveryId, eventId, timestamp };
}
```

<Warning>
  Verify against the raw body, not a re-serialized one. Parsing the JSON and encoding it again can reorder keys or change whitespace, which changes the bytes and breaks the signature. Capture the raw body before any JSON middleware runs.
</Warning>

Each attempt is signed when it is sent. A retry carries the same delivery ID, event ID and body, with a fresh `x-stableyard-timestamp` and signature, so the timestamp check passes for a legitimate retry.

## After verifying

Parse the JSON and apply the event in the same database transaction that records `x-stableyard-event-id` under a unique constraint. If that insert conflicts, return `2xx` without applying the business effect again.

Do not mark an event processed before its business update commits: a crash in between permanently loses the event. Timestamp validation limits replay of a captured request; durable event-ID deduplication handles legitimate redelivery.

## Rotating the signing secret

```bash theme={null}
curl -X POST https://staging-api-v2.stableyard.fi/v2/console/webhooks/webhook_123/rotate-secret \
  -u "$APP_ID:$APP_SECRET"
```

```json theme={null}
{
  "webhookEndpoint": { "id": "webhook_123", "status": "active" },
  "signingSecret": "whsec_…"
}
```

The new secret is returned once, and the previous one stops working at once: there is no overlap window. Deliveries and retries sent after the rotation are signed with the new secret, so deploy it to your handler before or immediately after you rotate. An archived endpoint cannot rotate its secret.

## When verification fails

| Symptom | Likely cause |
| - | - |
| Every signature is invalid | The body was parsed and re-serialized, or the wrong endpoint's secret is configured |
| Signatures fail after a rotation | The handler still holds the previous secret |
| Timestamp mismatch | The code compares `t=` with the wrong header, or the request was altered before it reached the handler |
| Timestamp outside tolerance | The receiving server's clock is wrong |

Respond to a failed verification with a non-`2xx` status. A `4xx` other than `408`, `425` or `429` is not retried. See [Delivery and retries](/webhooks/delivery-and-retries).

## Related

<CardGroup cols={2}>
  <Card title="Webhooks overview" icon="bolt" href="/webhooks">
    Endpoints, headers and a minimal handler.
  </Card>

  <Card title="Delivery and retries" icon="rotate" href="/webhooks/delivery-and-retries">
    What happens when your handler fails.
  </Card>

  <Card title="Event catalog" icon="list" href="/webhooks/event-catalog">
    Every partner event and its payload.
  </Card>

  <Card title="Reconciliation" icon="scale-balanced" href="/payments/reconciliation">
    Survive duplicates and reordering.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.