> ## Documentation Index
> Fetch the complete documentation index at: https://developers.fd.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Never miss a payment — instant event delivery so your system reacts the moment funds arrive.

Webhooks tell your server when a payment is confirmed. Prism sends a signed JSON `POST` to one
HTTPS URL per project. Signing follows the [Standard Webhooks](https://www.standardwebhooks.com/)
specification, so any Standard Webhooks library can verify the requests.

## Set up

<Steps>
  <Step title="Open the Webhooks settings">
    In the Prism Console, go to **Settings** → **Webhooks**.
  </Step>

  <Step title="Enter your endpoint URL">
    The URL must use `https` on port 443. Local, private, and link-local addresses are not allowed.
  </Step>

  <Step title="Pass the reachability test">
    Prism sends a signed `webhook.test` event to your URL. Your endpoint must answer with a 2xx status
    within 10 seconds, or the save is rejected. The error message names the reason, for example
    `timeout` or `http_status`.
  </Step>

  <Step title="Copy the signing secret">
    Prism shows the signing secret (`whsec_...`) once. Store it in your secret manager. You need it to
    verify every request.
  </Step>
</Steps>

## Events

| Event | When it is sent |
| - | - |
| `payment.confirmed` | A payment is confirmed on-chain and settled. |
| `webhook.test` | You save a URL, change it, or turn a disabled endpoint back on. |

## Payload

```json theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
{
  "id": "5c2b1f0e-3a8d-4f6b-9a61-2f7d0c9e4b11",
  "type": "payment.confirmed",
  "apiVersion": "2026-09-25",
  "createdAt": "2026-09-25T03:12:44Z",
  "data": {
    "paymentId": "0b6f2a3c-7d41-4c0e-8e5b-1a9d3f6c2e70",
    "projectId": "9e1d4c7a-2b58-4f3e-a6d0-7c8b5e1f3a29",
    "status": "confirmed",
    "grossAmount": { "value": "100.00", "asset": "USDC" },
    "feeTotal": { "value": "1.50", "asset": "USDC" },
    "netAmount": { "value": "98.50", "asset": "USDC" },
    "chainId": 8453,
    "network": "Base",
    "txHash": "0x…",
    "payerAddress": "0x…",
    "resource": "https://shop.example/checkout/ORDER-8812",
    "description": "Order 8812",
    "createdAt": "2026-09-25T03:12:40Z",
    "settledAt": "2026-09-25T03:12:43Z"
  }
}
```

| Field | Description |
| - | - |
| `id` | Event id. Same value as the `webhook-id` header. Stable across retries and resends. |
| `type` | Event type. |
| `apiVersion` | Payload version. |
| `createdAt` | When Prism built this event (UTC). |
| `data.paymentId` | Prism payment id. |
| `data.projectId` | Your project id. |
| `data.status` | Always `confirmed` for this event. |
| `data.grossAmount` | Amount the payer paid. |
| `data.feeTotal` | All deductions: platform fee, network costs, affiliate share, and any cashback you fund. |
| `data.netAmount` | Amount that reaches your settlement wallet. `feeTotal + netAmount = grossAmount`. |
| `data.chainId`, `data.network` | Chain the payment settled on. |
| `data.txHash` | On-chain transaction hash. |
| `data.payerAddress` | Wallet that paid. |
| `data.resource` | The resource URL the payment was for. |
| `data.description` | Payment description. May be `null`. |
| `data.createdAt` | When the payment was created (UTC). |
| `data.settledAt` | Block time of the settlement (UTC). May be `null`. |

Amounts are strings in token units, not in the smallest unit. `asset` is the token symbol. New
fields may be added at any time. Fields are never removed without a new `apiVersion`.

## Verify signatures

Every request has three headers:

| Header | Value |
| - | - |
| `webhook-id` | Event id. Use it to de-duplicate. |
| `webhook-timestamp` | Unix time in seconds when the request was signed. |
| `webhook-signature` | One or more space-separated `v1,<base64 signature>` values. |

To verify a request:

1. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`. Use the raw body bytes,
   before any JSON parsing.
2. Take the part of your secret after `whsec_` and base64-decode it. That is the HMAC key.
3. Compute HMAC-SHA256 of the signed content and base64-encode it.
4. Accept the request if any `v1` value in `webhook-signature` matches. Use a constant-time compare.
5. Reject timestamps more than 5 minutes from your clock.

While a rotated secret is still valid, the header holds two `v1` values, one per secret.

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    import crypto from "node:crypto";

    export function verifyPrismWebhook(secret: string, headers: Record<string, string>, rawBody: string): boolean {
      const id = headers["webhook-id"];
      const timestamp = headers["webhook-timestamp"];
      const signatures = headers["webhook-signature"];
      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
      const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
      const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
      return signatures.split(" ").some((part) => {
        const [version, signature] = part.split(",");
        return version === "v1" && signature.length === expected.length
          && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
      });
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    import base64, hashlib, hmac, time

    def verify_prism_webhook(secret: str, headers: dict, raw_body: str) -> bool:
        msg_id = headers["webhook-id"]
        timestamp = headers["webhook-timestamp"]
        if abs(time.time() - int(timestamp)) > 300:
            return False
        key = base64.b64decode(secret.removeprefix("whsec_"))
        expected = base64.b64encode(
            hmac.new(key, f"{msg_id}.{timestamp}.{raw_body}".encode(), hashlib.sha256).digest()
        ).decode()
        return any(
            part.split(",", 1)[0] == "v1" and hmac.compare_digest(part.split(",", 1)[1], expected)
            for part in headers["webhook-signature"].split(" ")
        )
    ```
  </Tab>
</Tabs>

## Retries and auto-disable

* Prism tries each event up to 3 times: at once, after 1 minute, and after 5 minutes.
* Only a 2xx response counts as success. Redirects count as failures and are not followed.
* Each call times out after 10 seconds.
* After 3 failed calls in a row, or any `410 Gone`, Prism disables the endpoint and shows a banner in
  **Settings** → **Webhooks**. Fix your endpoint, then turn it back on. The reachability test runs again.
* When you turn an endpoint back on, payments confirmed in the last 24 hours that were not sent are
  delivered once. Older ones are not. Use the payments API to reconcile a longer outage.
* The delivery log shows every event with its status, attempts, and last result. You can resend a
  `Failed` or `Delivered` event, one at a time. A resend keeps the same `webhook-id`.

Delivery is at-least-once while the endpoint is enabled. The same event can arrive more than once, so
de-duplicate on `webhook-id`. A strict de-duplicator also ignores a deliberate resend of an event you
already processed. That is expected.

## Delete an endpoint

Prism stops sending at once. The delivery log is kept and stays visible. Events that were still
waiting are not sent to the deleted URL. After you add a new endpoint, you can resend any event from
the old endpoint to the new one. It keeps the same `webhook-id`.

## Rotate the secret

Rotating creates a new signing secret, shown once. The old secret keeps signing for 24 hours, so you
can deploy the new one without dropping events.

## Best practices

* Return 2xx quickly, then process the event in the background.
* Use `webhook-id` as an idempotency key.
* After an outage, reconcile with the payments API instead of relying only on webhooks.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.