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.
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.client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
created, err := client.Webhooks.Endpoints.Create(ctx, shadway.WebhookEndpointCreateParams{
URL: "https://example.com/shadway/webhooks",
SubscribedEvents: []shadway.EventType{
shadway.EventTypeMandateStatusChanged,
shadway.EventTypeMandateApprovalRequested,
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(created.Endpoint.ID)
fmt.Println(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.
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;
}
}var secret = os.Getenv("SHADWAY_WEBHOOK_SECRET")
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
delivery, err := shadway.VerifyWebhook(body, r.Header, secret, nil)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
processOnce(delivery.ID, delivery.Event) // Dedupe by delivery ID.
w.WriteHeader(http.StatusNoContent)
}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.
const rotated = await shadway.webhooks.endpoints.rotateSecret(endpointId);
console.log(rotated.secret); // Deploy this; the old secret expires shortly.rotated, err := client.Webhooks.Endpoints.RotateSecret(ctx, endpointID)
if err != nil {
log.Fatal(err)
}
fmt.Println(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.