> ## Documentation Index
> Fetch the complete documentation index at: https://requestnetwork-08-31-chore-streamline-webhook-reconciliatio.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook reconciliation

> Verify signed Request Network webhook deliveries and safely update your application without polling.

## What you'll build

A webhook handler that receives signed Request Network events, verifies the signature, and triggers your downstream systems — order fulfillment, invoice closeout, accounting entries, and customer email. It is idempotent and safe to retry.

**Audience:** any backend integrating Request Network where payment events drive state changes downstream.

## Choose the events to handle

See the [Webhooks reference](/api-reference/webhooks) for the current event catalog, recipient routing, payload examples, and [legacy integrations](/api-reference/webhooks#legacy-integrations). Use it to choose which events your handler needs; this guide focuses on processing each delivery safely.

## Setup

<Steps>
  <Step title="Choose the endpoint owner">
    To receive events as a platform, note its `clientId`. To receive events as an orchestrator, use the key assigned to that orchestrator.
  </Step>

  <Step title="Register an endpoint">
    Follow [platform Client ID webhook setup](/api-reference/webhooks#register-a-platform-client-id-webhook) or [orchestrator webhook setup](/api-reference/webhooks#orchestrator-webhooks). Save the signing secret immediately; Request Network returns it only once.
  </Step>

  <Step title="Test delivery">
    Send a test delivery with the relevant platform or orchestrator endpoint in the [Webhooks reference](/api-reference/webhooks). Test deliveries include `x-request-network-test: true` and placeholder data.
  </Step>
</Steps>

Most integrations register one endpoint, either for a platform's Client ID or for an orchestrator. If you register the same callback URL twice, once for a platform's Client ID and once for an orchestrator, Request Network returns a separate signing secret for each registration. Your receiver must accept a valid signature made with either secret.

## Handler — reference implementation

A signature-verifying Express handler. It verifies against the **raw** body, uses constant-time comparison, passes the delivery ID to business handlers as their idempotency key, and lets Request Network retry a failed handler.

```typescript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) {
  throw new Error("WEBHOOK_SECRET is required");
}

function signatureMatches(rawBody: Buffer, signature: string, secret: string) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const signatureBuffer = Buffer.from(signature, "hex");
  const expectedBuffer = Buffer.from(expected, "hex");
  const hasExpectedLength = signatureBuffer.length === expectedBuffer.length;
  const comparableSignature = hasExpectedLength
    ? signatureBuffer
    : Buffer.alloc(expectedBuffer.length);

  const isMatch = timingSafeEqual(comparableSignature, expectedBuffer);
  return hasExpectedLength && isMatch;
}

app.post(
  "/webhooks/request-network",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["x-request-network-signature"] as string;
    const deliveryId = req.headers["x-request-network-delivery"] as string;

    if (!signature || !deliveryId) {
      return res.status(400).send("missing headers");
    }

    // 1. Verify against the RAW body — never re-stringify.
    const hasValidSignature = signatureMatches(req.body, signature, WEBHOOK_SECRET);

    if (!hasValidSignature) {
      return res.status(401).send("invalid signature");
    }

    // 2. A verified test delivery uses placeholder data. Acknowledge it
    // before it can change application state.
    if (req.headers["x-request-network-test"] === "true") {
      console.info("received verified Request Network test webhook");
      return res.status(200).send("ok");
    }

    try {
      // 3. Parse and route. Each business operation uses deliveryId as an
      // idempotency key in its own durable store.
      const event = JSON.parse(req.body.toString("utf8"));
      console.info({ deliveryId, event: event.event });
      await handleEvent(event, deliveryId);
      return res.status(200).send("ok");
    } catch (err) {
      console.error("handler failed", err);
      return res.status(500).send("handler error");
    }
  },
);

async function handleEvent(event: any, deliveryId: string) {
  switch (event.event) {
    // Hosted onboarding, delivered to an orchestrator endpoint.
    case "client_id.linked":
      await recordLinkedPlatform(
        event.clientId,
        event.linkId,
        event.intentId,
        deliveryId,
      );
      break;

    case "payment.confirmed":
      await markOrderPaid(event.requestId, event.txHash, deliveryId);
      break;

    case "payment.failed":
      await flagFailedPayment(
        event.requestId,
        event.subStatus,
        deliveryId,
      );
      break;

    case "kyt.screening.completed":
      await recordKytResult(
        event.paymentToken,
        event.status,
        event.provider,
        deliveryId,
      );
      break;

    // Payer activity from the Secure Payment Page. Do not use it as a
    // settlement signal; use payment.confirmed instead.
    case "secure_payment.user_event":
      await recordPayerActivity(
        event.securePaymentToken,
        event.userEvent,
        deliveryId,
      );
      break;

    // A payer-wallet allowlist rejection. This is useful for monitoring and
    // audit history, but it does not change the payment's settlement state.
    case "secure_payment.access_rejected":
      await recordPayerWalletRejection(
        event.requestId,
        event.attemptedPayerWalletAddress,
        deliveryId,
      );
      break;

    default:
      // Acknowledge events this handler does not use so they do not retry.
      console.info({ deliveryId, event: event.event }, "ignoring event");
  }
}
```

Webhook delivery is at least once, not exactly once. Each business operation must atomically record the delivery ID with the state it changes, then make a repeat delivery a successful no-op. If an operation calls another service, pass the delivery ID as that service's idempotency key too. A process can fail after a side effect but before it returns `200`.

## Headers reference

| Header                          | Description                                   |
| ------------------------------- | --------------------------------------------- |
| `x-request-network-signature`   | HMAC-SHA256 of the raw JSON body, hex-encoded |
| `x-request-network-delivery`    | ULID — use as idempotency key                 |
| `x-request-network-retry-count` | `0`–`3`, current retry attempt                |
| `x-request-network-test`        | `true` only for test deliveries               |

## Retry policy

| Attempt     | Delay | Cumulative time |
| ----------- | ----- | --------------- |
| 0 (initial) | —     | t=0             |
| 1           | 1s    | t+1s            |
| 2           | 5s    | t+6s            |
| 3           | 15s   | t+21s           |

After 4 total attempts (initial + 3 retries) the delivery is dropped. Triggers: any non-2xx response, timeout, connection error. Default request timeout is 5s.

## Common patterns

### Idempotency

The same `payment.confirmed` event might arrive twice (network blip, retry overlap). Use `x-request-network-delivery` as the idempotency key. Record it atomically with the business update in your durable store; do not use a check-then-act cache lookup, because overlapping deliveries can both pass the check.

For a local database update, add a `webhook_deliveries` table with a unique `delivery_id` column, then insert that ID in the same transaction as the business update:

```typescript theme={null}
async function markOrderPaid(
  requestId: string,
  txHash: string,
  deliveryId: string,
) {
  await db.transaction(async (tx) => {
    const order = await tx.orders.findOne({ where: { requestId } });
    if (!order) return; // not ours

    const claim = await tx.execute(
      `INSERT INTO webhook_deliveries (delivery_id)
       VALUES ($1)
       ON CONFLICT (delivery_id) DO NOTHING`,
      [deliveryId],
    );
    if (claim.rowCount === 0) return; // already applied

    await tx.orders.update({
      where: { id: order.id },
      data: { paidAt: new Date(), txHash },
    });
  });
}
```

### Route events by Client ID

If you are an orchestrator working with several linked platforms, use `clientId` to identify the platform for an event. Store that Client ID with your own platform record when you link it.

### Slack alerts on failure

```typescript theme={null}
case "payment.failed":
  await fetch(SLACK_WEBHOOK, {
    method: "POST",
    body: JSON.stringify({
      text: `:warning: Payment failed for request ${event.requestId}`,
    }),
  });
  break;
```

## Local development

Use [ngrok](https://ngrok.com) to expose localhost during development:

```bash theme={null}
ngrok http 3000
# Pass the https://xxxxx.ngrok-free.app URL when you register your endpoint
```

Local URLs (`localhost`, `127.0.0.1`) are accepted by the auth API for testing. HTTPS is required in production.

## Related

<CardGroup cols={2}>
  <Card title="Webhooks reference" href="/api-reference/webhooks" icon="webhook">
    Full payload schemas for every event type.
  </Card>

  <Card title="Webhooks & Events" href="/api-features/webhooks-events" icon="bell">
    High-level concepts and event categories.
  </Card>
</CardGroup>
