Webhooks
Verify exact request bytes, deduplicate events durably, and replay failed deliveries.
Webhooks confirm checkout, order, invoice, and subscription changes. A browser return URL only resumes navigation.
Create an endpoint
Add the destination
Select events
CIPAY_WEBHOOK_SECRET in the receiver’s secret manager.Send a test event
202 for a new event.Verify, save, then enqueue
The shared receiver reads at most 1 MB, verifies the raw bytes, saves the event ID, and enqueues work in one application transaction.
import {
CipayWebhookVerificationError,
verifyWebhook,
} from "@cipay/client-sdk/api";
export async function handleCipayWebhook(request: Request): Promise<Response> {
try {
// Verify before JSON parsing. The signature covers these exact bytes.
const rawBody = new Uint8Array(await request.arrayBuffer());
if (rawBody.byteLength > 1_000_000) {
return new Response("Webhook too large", { status: 413 });
}
const event = verifyWebhook({
rawBody,
headers: request.headers,
secret: process.env.CIPAY_WEBHOOK_SECRET!,
});
// Your transaction inserts event.id once and creates one outbox job.
const accepted = await saveAndEnqueueOnce(event);
return new Response(null, { status: accepted ? 202 : 204 });
} catch (error) {
if (error instanceof CipayWebhookVerificationError) {
return new Response("Invalid webhook", { status: 400 });
}
throw error;
}
}
saveAndEnqueueOnce is your database function. Add a unique constraint on event_id, and insert the event plus an outbox job in one transaction.
Framework routes
Each framework passes the same raw request to handleCipayWebhook. Express needs express.raw so its JSON middleware does not consume the body first.
import { handleCipayWebhook } from "@/lib/cipay-webhook";
// Route Handlers already provide a Web Request.
export const POST = handleCipayWebhook;import { Hono } from "hono";
import { handleCipayWebhook } from "./cipay-webhook";
const app = new Hono();
// c.req.raw preserves the original Web Request body.
app.post("/api/cipay/webhooks", (c) => handleCipayWebhook(c.req.raw));import type { APIRoute } from "astro";
import { handleCipayWebhook } from "../../../lib/cipay-webhook";
// Astro exposes the untouched Web Request to the endpoint.
export const POST: APIRoute = ({ request }) => handleCipayWebhook(request);import express from "express";
import { handleCipayWebhook } from "./cipay-webhook";
const app = express();
app.post(
"/api/cipay/webhooks",
express.raw({ type: "application/json", limit: "1mb" }),
async (request, response) => {
// Forward only the signed headers and the untouched Buffer.
const headers = new Headers({
"x-cipay-signature": request.get("x-cipay-signature") ?? "",
"x-cipay-timestamp": request.get("x-cipay-timestamp") ?? "",
"x-cipay-event-id": request.get("x-cipay-event-id") ?? "",
});
const webRequest = new Request("https://receiver.local/webhooks", {
method: "POST",
headers,
body: request.body,
});
const result = await handleCipayWebhook(webRequest);
response.status(result.status).end();
},
);Handle retries and replay
Inspect
Fix
Replay
204 without another business effect.Test failure cases
The snippets below assume signedRequest() creates a valid sandbox fixture and POST() calls your public receiver route.
Changed body → 400
const fixture = signedFixture();
// Reuse the valid signature headers, then change one body byte.
const changed = new Request(fixture.url, {
method: "POST",
headers: fixture.headers,
body: `${fixture.rawBody} `,
});
expect((await POST(changed)).status).toBe(400);
expect(await countStoredEvents()).toBe(0);The receiver rejects the request before persistence because the HMAC comparison fails.
Invalid signature → 400
const request = signedRequest({
// A random digest was not created with your endpoint secret.
signature: `v1=${"0".repeat(64)}`,
});
expect((await POST(request)).status).toBe(400);
expect(await countStoredEvents()).toBe(0);This catches the wrong secret, damaged headers, and forged requests.
Expired timestamp → 400
const request = signedRequest({
// Sign the old timestamp correctly; freshness should still reject it.
signedAt: new Date(Date.now() - 6 * 60 * 1_000),
});
expect((await POST(request)).status).toBe(400);The default five-minute tolerance limits delayed replay of a captured request.
Event ID mismatch → 400
const request = signedRequest({
bodyEventId: "event_body",
// The signed body and x-cipay-event-id header must identify one event.
headerEventId: "event_header",
});
expect((await POST(request)).status).toBe(400);Verification fails before the event reaches your database.
Duplicate delivery → 202, then 204
// Build two valid requests with the same signed event ID.
const first = await POST(signedRequest({ eventId: "event_123" }));
const duplicate = await POST(signedRequest({ eventId: "event_123" }));
expect(first.status).toBe(202);
expect(duplicate.status).toBe(204);
expect(await countBusinessEffects("event_123")).toBe(1);The unique event ID claim acknowledges retries without repeating side effects.
Platform replay → no repeated work
await POST(signedRequest({ eventId: "event_123" }));
// Replay the same delivery after fixing the receiver dependency.
await replayFromPlatform("event_123");
expect(await countStoredEvents("event_123")).toBe(1);
expect(await countBusinessEffects("event_123")).toBe(1);Platform replay sends the original business event, so the same deduplication rule applies.
Test through the public route so framework body handling is included. Parsing JSON before verifyWebhook can change the bytes and reject every valid event.