Shadway
Guides

Guides / Verify webhooks

Verify webhooks

Webhooks push the same events to your servers that you'd otherwise stream. Anyone can POST JSON to a public URL, so before you act on one, check that it really came from Shadway, arrived recently, and wasn't changed on the way. The SDKs do the whole check in one call.

Create an endpoint

Endpoints must be HTTPS. Subscribe to specific event types, or "*" for all of them.

create-endpoint.ts
import { Shadway } from "shadway";

const shadway = new Shadway();

const created = await shadway.webhooks.endpoints.create({
  url: "https://example.com/shadway/webhooks",
  subscribedEvents: ["mandate.status_changed", "mandate.approval_requested"],
});

console.log(created.endpoint.id);
console.log(created.secret); // Shown only in this response. Store it now.

The secret (whsec_...) appears once, in the create response. Store it in your secrets manager. You can't read it back later, only rotate it.

Verify every delivery

Verify against the raw request body: the exact bytes, before any JSON parsing. A body your framework has parsed and re-serialized will not match the signature.

handler.ts
import { Shadway, WebhookVerificationError } from "shadway";

const shadway = new Shadway();
const secret = process.env.SHADWAY_WEBHOOK_SECRET!;

// Works in any runtime with Request/Response (Node 20+, workers, edge).
export async function handleWebhook(request: Request): Promise<Response> {
  const body = new Uint8Array(await request.arrayBuffer());

  try {
    const delivery = await shadway.webhooks.verify(body, request.headers, secret);
    await processOnce(delivery.id, delivery.event); // Dedupe by delivery ID.
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

Return a 2xx quickly. Do the real work after responding, or on a queue. A non-2xx response, including a timeout, means Shadway retries the delivery with backoff for up to an hour per attempt interval.

If Express or a similar framework handles your routes, register the raw-body middleware for the webhook path (express.raw({ type: "application/json" })) so parsing doesn't consume the exact bytes first.

What verification checks

Each delivery carries two headers:

  • Shadway-Webhook-Id: a stable delivery ID, the same across retries of one delivery. This is your deduplication key.
  • Shadway-Signature: t=<unix seconds>,v1=<hex>, an HMAC-SHA256 of the delivery ID, the timestamp, and the raw body, keyed by your endpoint secret.

The SDK recomputes the signature in constant time, accepts any matching v1 value (there can be more than one during secret rotation), and rejects timestamps older than five minutes to stop replays. You get either a verified event object or a typed verification error.

Rotate the secret

Rotation issues a new secret while deliveries signed with the previous one keep verifying for a short overlap window. Deploy the new secret within it.

rotate.ts
const rotated = await shadway.webhooks.endpoints.rotateSecret(endpointId);
console.log(rotated.secret); // Deploy this; the old secret expires shortly.

During the overlap, the signature header carries a v1 value for each active secret, and verification with either succeeds. Rotation never drops deliveries.

Continue building

On this page