Webhooks

Receive and verify

Verify raw requests, persist an inbox entry, and process events idempotently.

Authenticate the notification and durably accept work before acknowledging it. Do not perform fulfilment before protecting against duplicate delivery.

Node.js receiver

Install the published SDK. This factory returns a handler using Request/Response. Connect acceptEvent to your application's durable inbox; it must resolve only after commit, including when a duplicate already exists. A background worker processes accepted entries independently.

import {
  verifySignature,
  WebhookSignatureVerificationError,
} from "@revkeen/sdk/webhooks";

export function createWebhookReceiver({ secret, acceptEvent }) {
  if (!secret) throw new Error("Set REVKEEN_WEBHOOK_SECRET");

  return async function POST(request) {
    const payload = await request.text();
    const signature = request.headers.get("x-revkeen-signature");
    if (!signature) return new Response("Missing signature", { status: 400 });

    try {
      verifySignature({ payload, signature, secret, tolerance: 300 });
    } catch (error) {
      if (error instanceof WebhookSignatureVerificationError) {
        return new Response("Invalid signature", { status: 400 });
      }
      throw error;
    }

    let event;
    try {
      event = JSON.parse(payload);
    } catch (error) {
      if (error instanceof SyntaxError) {
        return new Response("Invalid JSON", { status: 400 });
      }
      throw error;
    }
    if (!event || typeof event !== "object") {
      return new Response("Invalid event", { status: 400 });
    }

    let eventId = event.id;
    if (event._truncated === true) {
      if (typeof event.fetchUrl !== "string" || !URL.canParse(event.fetchUrl)) {
        return new Response("Invalid event reference", { status: 400 });
      }
      const path = new URL(event.fetchUrl).pathname.split("/");
      if (path.length !== 4 || path[1] !== "v2" || path[2] !== "events") {
        return new Response("Invalid event reference", { status: 400 });
      }
      eventId = path[3];
    } else if (typeof event.type !== "string") {
      return new Response("Missing event type", { status: 400 });
    }
    if (typeof eventId !== "string" || eventId.length === 0) {
      return new Response("Missing event ID", { status: 400 });
    }

    try {
      await acceptEvent({ eventId, event });
    } catch {
      // No durable acknowledgement: ask for a retry. Alert through your own monitoring.
      return new Response("Temporarily unavailable", { status: 503 });
    }
    return new Response(null, { status: 202 });
  };
}

Your framework should export the returned handler as its POST route. Pass the endpoint secret from REVKEEN_WEBHOOK_SECRET and your storage adapter. The worker must validate the event-specific schema before business changes. Configure an appropriate body size limit in your HTTP server. This sample requires a maintained Node.js runtime.

Connect a durable inbox

acceptEvent is an application integration point, not an SDK function. Associate each endpoint secret with a trusted merchant ID in your configuration; do not select a tenant from unverified headers. A relational inbox can use (merchant_id, event_id) as a unique key:

INSERT INTO webhook_inbox (merchant_id, event_id, payload, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (merchant_id, event_id) DO NOTHING;

Pass bound parameters and wait for transaction commit. This is a schema pattern for your application, not a migration to run against RevKeen. For a separate queue, use a transactional outbox or poll the inbox: do not acknowledge a database insert followed by a failed queue publish.

Process once per business operation

Two deliveries can both pass a separate alreadyProcessed check. A crash can occur after an external action but before markProcessed. A unique inbox alone does not make external effects exactly-once.

Use a business idempotency key, such as one fulfilment per invoice. Commit local state changes and completion together. For external services, use their idempotency mechanism and reconcile uncertain outcomes. Retry failed processing jobs independently of delivery. A duplicate can return 2xx only when the original work is durably accepted.

Resolve truncated events

For _truncated entries, the example extracts the event ID from the signed reference without fetching its host. Build /v2/events/{eventId} against your configured RevKeen origin, URL-encode the identifier, and use the corresponding merchant credential. Validate the fetched event before processing. Do not assume data.object is present on a truncated notification or blindly fetch a URL from an incoming body.

Continue with signature details and delivery behaviour.

On this page