Building
Webhooks
OBH can notify your server when a payment reaches a terminal state. The SDK does not verify these requests — you do, with your webhook signing secret. This page shows the exact algorithm.
The SDK does not handle webhooks
There is no webhook helper in @obhco/payments. Signatures must be verified by your server with the per-service webhook secret. The secret is separate from your API credential and is shown when created or rotated in OBH Admin.
What you receive
Webhooks are sent to the service’s configured webhook URL as POST requests with a JSON body. Two event types exist today:
| Event | When |
|---|---|
payment.completed | A payment was verified as completed. |
payment.failed | A payment reached a failed terminal state. |
Each delivery has a stable evt_... identifier and includes the OBH payment object.
{ "id": "evt_01J9Z6K3T4QW8M2", "type": "payment.completed", "createdAt": "2026-10-18T08:04:12.000Z", "data": { "id": "018f0000-0000-7000-8000-000000000001", "reference": "OBH-8B30F9C07A52D1E4", "externalOrderId": "IG-8821", "amount": "245000000.00", "currency": "IRR", "environment": "test", "status": "completed", "completedAt": "2026-10-18T08:04:10.000Z", "verifiedAt": "2026-10-18T08:04:10.000Z" }}Cancellation and expiry events
They are reserved for a later lifecycle implementation. Today, a cancelled or expired payment is discovered by retrieving it, not by a webhook.
Signature headers
OBH-Event-Id: evt_01J9Z6K3T4QW8M2OBH-Timestamp: 1760000000OBH-Signature: t=1760000000,v1=<hex hmac>The signature is an HMAC-SHA256 over <timestamp>.<raw request body>, keyed with your webhook signing secret and encoded as lowercase hex. The same timestamp appears both in OBH-Timestamp and in the t= component of OBH-Signature.
- The API credential is not the webhook secret.
- Signatures cover the exact bytes sent. Re-serialized JSON will not match.
- The signing secret is encrypted at rest with a key derived from
AUTH_SECRET. RotatingAUTH_SECRETwithout rotating service webhook secrets makes existing secrets unreadable.
Verify a signature
Read the raw body before parsing it. Then compare the computed digest with the v1 value using a timing-safe comparison, and reject timestamps outside a small tolerance window.
import { createHmac, timingSafeEqual } from "node:crypto"import { NextResponse } from "next/server" import { obh } from "@/lib/obh" const TOLERANCE_SECONDS = 300 export async function POST(request: Request) { // 1. Read the exact bytes that were signed. const rawBody = await request.text() const eventId = request.headers.get("obh-event-id") const timestamp = request.headers.get("obh-timestamp") const signature = request.headers.get("obh-signature") if (!eventId || !timestamp || !signature) { return new NextResponse("Missing signature headers", { status: 400 }) } // 2. Reject stale deliveries (replay protection). const timestampSeconds = Number(timestamp) const age = Math.abs(Date.now() / 1000 - timestampSeconds) if (!Number.isFinite(timestampSeconds) || age > TOLERANCE_SECONDS) { return new NextResponse("Stale timestamp", { status: 400 }) } // 3. Parse t=... and v1=... from the signature header. const parts = Object.fromEntries( signature.split(",").map((part) => { const [key, value] = part.split("=") return [key, value] }) ) const signatureTimestamp = parts.t const provided = parts.v1 if (!signatureTimestamp || !provided || signatureTimestamp !== timestamp) { return new NextResponse("Malformed signature", { status: 400 }) } // 4. HMAC-SHA256 over `<timestamp>.<raw body>`. const expected = createHmac("sha256", process.env.OBH_WEBHOOK_SECRET!) .update(`${timestamp}.${rawBody}`) .digest("hex") const expectedBuffer = Buffer.from(expected, "utf8") const providedBuffer = Buffer.from(provided, "utf8") if ( expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer) ) { return new NextResponse("Invalid signature", { status: 401 }) } // 5. Verify first, parse second. const event = JSON.parse(rawBody) as { id: string type: "payment.completed" | "payment.failed" data: { reference: string } } if (!(await claimEventOnce(event.id))) { return NextResponse.json({ received: true, duplicate: true }) } if (event.type === "payment.completed") { const payment = await obh.getPayment(event.data.reference) if (payment.status === "completed") { await markOrderPaidOnce(payment.externalOrderId!) } } return NextResponse.json({ received: true })}Do not verify a parsed-and-reserialized body
If your framework parses JSON and you stringify it again, whitespace and key order may change and the HMAC will not match. Capture the raw bytes first — request.text() in a Web Request, a raw-body parser in Express, or the raw body option in other frameworks.
Deduplicate events
Delivery is at-least-once. The same event id can arrive more than once, so record it and refuse to process it twice.
async function claimEventOnce(eventId: string) { try { await db.webhookEvent.create({ data: { id: eventId } }) return true } catch (error) { // Unique constraint: already processed. return false }}Combine that with idempotent fulfilment (markOrderPaidOnce) so a duplicate delivery cannot ship two orders even if the event ledger is lost.
Delivery and retries
- A
2xxresponse acknowledges delivery. Anything else, or a timeout, is retried with backoff. - Each delivery has a 5-second timeout and up to 6 attempts. The retry delays are 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours.
- The first attempt is made immediately after the terminal payment update.
- A scheduled worker retries due deliveries. In this repository it runs with
pnpm --filter web webhooks:processand is not yet scheduled automatically. - Each delivery row stores attempts, the next attempt time, and a sanitized error.
Production requires a scheduler
If the retry worker never runs, failed deliveries are never retried. A production deployment must schedule the worker (a cron job, a platform scheduler, or a queue) and monitor it.
Reacting correctly
Acknowledge quickly, then do durable work. The provider’s HTTP client waits for your response; expensive work should happen in a background job where possible.
- Verify the signature and timestamp.
- Deduplicate by event id.
- Retrieve the payment for its authoritative state.
- Perform idempotent fulfilment or failure handling.
- Return
2xx.
Never fulfil based solely on the webhook body. It is a notification, not the state — and an old event can arrive after a newer one.
Operating the endpoint
- Configure both a webhook URL and a signing secret in OBH Admin.
- Store
OBH_WEBHOOK_SECRETin server-side configuration. It is not the same value asOBH_SERVICE_KEY. - Rotate the secret in OBH Admin if it leaks. There is no recovery for a lost secret.
- Log event ids and request ids, never the secret or the raw signature of a valid request.
- Alert on repeated signature failures; they may indicate a misconfiguration or an attack.
See also Security and Next.js integration for a complete working route.