# Pay webhooks
URL: /docs/x1id/pay-webhooks

> Get an HMAC-signed HTTPS POST when a wallet or an @name you control is paid, or when one of its X1ID invoices is created, paid, cancelled or closed. Self-serve registration with a wallet signature, at-least-once delivery, and the on-chain verification recipe.

# Pay webhooks

<Pill tone="mainnet">Mainnet</Pill>

<LlmActions />

Get an HTTPS `POST` when a wallet or an `@name` you control is paid, or when one of its [invoices](/docs/x1id/checkout) is created, paid, cancelled or closed. Registration is **self-serve**: you prove control of the wallet or name with a wallet signature, and the API hands you a signing secret.

Base URL: `https://api.x1id.io/v1/pay/webhooks`. Every call needs an X1ID developer API key in `X-API-Key` (see [Developer platform](/docs/x1id/platform)).

<Callout type="warn">
  **A webhook is a notification, never authority. The chain is the source of truth.** Every event restates a fact the X1 chain already proves, and events are produced only from transactions at **`finalized`** commitment, so nothing reported can be rolled back by a fork. But a webhook can be delayed, retried, duplicated, or (if your secret leaks) forged. Always [verify the signature](#verifying-the-signature), dedupe on the event `id`, and [confirm on chain](#verify-on-chain-before-releasing-value) before releasing anything of value.
</Callout>

## Quick start

You need an X1ID developer API key and the wallet that controls what you subscribe to.

<Steps>
  <Step>
    ### Ask for a challenge

    Say what you want to watch, where to send it, and which wallet will sign. `scope` is an `@name` or a wallet address.

    ```bash
    curl -s https://api.x1id.io/v1/pay/webhooks/challenge \
      -H "X-API-Key: $X1ID_KEY" -H "content-type: application/json" \
      -d '{
        "scope": "@acme",
        "wallet": "<the wallet that will sign>",
        "url": "https://shop.example/x1id/webhook",
        "event_types": ["invoice.paid", "payment.received"],
        "description": "shop checkout"
      }'
    # → { "nonce": "…", "message": "x1id-pay-webhook:v1\n…", "expires_at": "…" }
    ```
  </Step>

  <Step>
    ### Sign `message` with that wallet

    Use the wallet's `signMessage`. Raw bytes and the Solana off-chain-message envelope are both accepted. Signing moves no funds: the message names your API key id, the scope, the URL, the event types, a single-use nonce and a 10-minute expiry.
  </Step>

  <Step>
    ### Register

    ```bash
    curl -s https://api.x1id.io/v1/pay/webhooks \
      -H "X-API-Key: $X1ID_KEY" -H "content-type: application/json" \
      -d '{ "nonce": "<nonce>", "signature": "<base58 signature>" }'
    # → 201 { "id": "whsub_…", "secret": "whsec_…", "scope": {…}, "status": "active", … }
    ```

    Store `secret` now. It is **shown once**; you can [rotate it](#endpoints) any time.
  </Step>
</Steps>

### Who may register what

| Scope               | The signing wallet must be…                                                                                                                                                    |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| a wallet address    | that wallet                                                                                                                                                                    |
| `@name` (top-level) | the name's **current on-chain owner** (or NFT holder), **or** its **payment address** (a live, on-chain-verified X1 address record, the same rule every X1ID pay surface uses) |

Control is checked from chain state at registration time. For an `@name`, both wallets are recorded as the subscription's **proven wallets**, and you only receive the name's events that were paid to one of them. If the name changes hands or its payment address moves, events paid to the new wallet are **not** delivered to you: the subscription is paused with `paused_reason: "scope_changed: …"`. Register again with a fresh proof.

<Callout type="info">
  Dotted names (subnames, `name.x1`, customer TLDs) are not supported as a scope yet. Subscribe to the paying wallet's address instead.
</Callout>

## Endpoints

All endpoints require `X-API-Key` and count against that key's normal rate limit. A webhook belongs to the account that registered it.

| Method & path                              | Body / query                                        | Returns                                                              |
| ------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------- |
| `POST /v1/pay/webhooks/challenge`          | `{scope, wallet?, url, event_types?, description?}` | `{nonce, message, expires_at}`                                       |
| `POST /v1/pay/webhooks`                    | `{nonce, signature}`                                | `201` webhook + `secret` (once)                                      |
| `GET /v1/pay/webhooks`                     |                                                     | `{webhooks: [...]}`                                                  |
| `GET /v1/pay/webhooks/{id}`                |                                                     | the webhook                                                          |
| `GET /v1/pay/webhooks/{id}/deliveries`     | `?limit=1..200` (default 50)                        | `{deliveries: [...]}`, the delivery log                              |
| `POST /v1/pay/webhooks/{id}/rotate-secret` |                                                     | `{id, secret}` (once); used from the next attempt, including retries |
| `POST /v1/pay/webhooks/{id}/resume`        |                                                     | re-activates a webhook after a dead-letter auto-pause                |
| `DELETE /v1/pay/webhooks/{id}`             |                                                     | `204`                                                                |

**`url`** must be `https`, on port 443, with no credentials, and its host must resolve **only** to public addresses. Private, loopback, link-local, cloud-metadata, CGNAT and other reserved ranges are refused at registration and again on every delivery (after DNS resolution, with the connection pinned to the vetted address). Redirects are never followed: a `3xx` is a failed delivery.

**`event_types`** accepts any of the [types below](#event-types), `invoice.*`, `payment.*`, `invoice`, `payment`, or `*`. Empty means all. Unknown values are rejected.

### Errors

Errors are `{code, message}`:

| Code                                                                    | Meaning                                                |
| ----------------------------------------------------------------------- | ------------------------------------------------------ |
| `INVALID_SCOPE`, `INVALID_WALLET`, `INVALID_URL`, `INVALID_EVENT_TYPES` | A field failed validation                              |
| `WALLET_MISMATCH`                                                       | For a wallet scope, `wallet` must be that same wallet  |
| `CHALLENGE_INVALID`                                                     | Nonce unknown, already used, or expired                |
| `SIGNATURE_INVALID`                                                     | The signature doesn't verify over `message`            |
| `NOT_CONTROLLER`, `CONTROL_UNPROVEN`                                    | The wallet doesn't (provably) control the scope        |
| `NOT_FOUND`                                                             | The `@name` isn't registered                           |
| `URL_REJECTED`                                                          | The URL resolves to a non-public address               |
| `LIMIT_REACHED`                                                         | The account already has the maximum number of webhooks |
| `WEBHOOK_NOT_FOUND`                                                     | No such webhook on this account                        |
| `NOT_RESUMABLE`                                                         | The webhook isn't auto-paused                          |
| `RATE_LIMITED`, `API_KEY_REQUIRED`                                      | Key / rate-limit errors, as on the rest of `/v1`       |
| `PAY_WEBHOOKS_DISABLED`                                                 | The feature is switched off on this deployment         |
| `RPC_ERROR`                                                             | Chain read failed; retry                               |

## Event types

| Type                       | When                                                                                                                 |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `payment.received`         | A direct transfer (System or SPL, Solana Pay style) arrived at a watched X1ID payment address                        |
| `invoice.paid`             | An X1ID invoice was paid through the registry program (`pay_invoice`)                                                |
| `invoice.created`          | An invoice was created with this payee                                                                               |
| `invoice.cancelled`        | An invoice was cancelled                                                                                             |
| `invoice.closed`           | An invoice account was closed (`invoice.closedStatus`: `0` open/expired, `1` paid, `2` cancelled)                    |
| `payment.binding_verified` | A payment whose `@name` binding couldn't be proven when first seen is now proven on chain (carries the full payment) |

Each payment produces **exactly one** of `payment.received` / `invoice.paid`, so subscribing to both never double-counts. An `@name` subscription only receives events whose binding to that name is **verified**; a payment first seen as `unverifiable` reaches it later as `payment.binding_verified`, so subscribe to that type too if you watch a name.

## Payload

Every delivery body uses the `x1id.pay-events/v1` schema:

```jsonc
{
  "schema": "x1id.pay-events/v1",
  "id": "pev_4b2f…",                 // stable: dedupe on this (= X1id-Event-Id)
  "type": "invoice.paid",
  "notice": "Notification only. The chain is the source of truth: …",
  "chain": {
    "signature": "5Kt…",              // the transaction — verify it on chain
    "ixIndex": 2,                     // top-level instruction index
    "innerIndex": -1,                 // -1 = top level, else the CPI position
    "slot": 183500000,
    "blockTime": "2026-10-17T12:00:00.000Z",   // null if the RPC had none
    "commitment": "finalized",
    "finalized": true
  },
  "payment": {                        // null for invoice.created / cancelled / closed
    "kind": "invoice",                // "invoice" | "transfer"
    "payer": "7xKX…",
    "recipient": "E2cE…",             // the wallet paid (SPL: the token account's owner)
    "recipientTokenAccount": "9aBc…", // SPL only, else null
    "asset": "spl",                   // "native" (XNT) | "spl"
    "mint": "B69c…",                  // null = native XNT
    "amount": "40000000",             // what the payer paid, base units, decimal STRING
    "fee": "100000",                  // protocol fee (0 for a transfer); null if unknown
    "net": "39900000",                // what the recipient received; null if unknown
    "invoice": "4jCP…",               // invoice address, or null
    "reference": "4jCP…",             // first Solana Pay reference, or null
    "references": ["4jCP…"],
    "memo": "order #1042",            // PUBLIC and unauthenticated — display only
    "binding": {
      "status": "verified",           // verified | none | unbound | unverifiable
      "handles": [
        { "name": "acme", "tld": null, "account": "H8Fs…", "basis": "invoice", "checkedSlot": 183500000 }
      ]
    }
  },
  "invoice": {                        // invoice.* events; for invoice.paid: {address, payee}
    "address": "4jCP…",
    "payee": "E2cE…",
    "actor": "7xKX…",                 // who signed the instruction
    "mint": null,
    "amount": "40000000",
    "expiresAt": "1830000000",        // unix seconds, string
    "bound": true,                    // created bound to an @name
    "handleAccount": "H8Fs…",
    "createIndexed": true             // false: created before the indexed range — read the chain
  },
  "truncated": true                   // only present if the body was trimmed to fit 64 KiB
}
```

Amounts are base-unit integers as **strings**, never floats.

`binding.status` says how the payment ties to an `@name`:

| Status         | Meaning                                                                                                                                      |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `verified`     | Proven from chain state at the payment's slot. `basis` is `owner`, `holder`, `verified-record`, or `invoice` (the program itself checked it) |
| `none`         | No `@name` pays to that wallet                                                                                                               |
| `unbound`      | An invoice created without a name                                                                                                            |
| `unverifiable` | Could not be proven (yet); may follow as `payment.binding_verified`                                                                          |

<Callout type="warn">
  `memo` is public and unauthenticated: anyone can write anything in it. Display it, but never use it to decide anything. Match payments to orders by **invoice address** (or reference), not memo.
</Callout>

### Headers

```
Content-Type: application/json
X1id-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
X1id-Event-Id: pev_…           (same as body.id)
X1id-Event-Type: invoice.paid
X1id-Delivery-Id: <delivery id>
X1id-Subscription-Id: whsub_…
```

## Verifying the signature

`v1 = HMAC_SHA256(secret, "<t>.<raw body>")`, hex-encoded. This is the same scheme as the [identity event stream](/docs/x1id/events#verifying-a-delivery).

1. Read the **raw** request bytes. Never re-serialize parsed JSON.
2. Parse `t` and `v1` from `X1id-Signature`.
3. Reject if `t` is outside your tolerance window (300 s recommended) to stop replays.
4. Recompute the HMAC and compare in **constant time**.

<Tabs items={['Node', 'Python']}>
  <Tab value="Node">
    ```js title="webhook.js"
    import express from "express";
    import { createHmac, timingSafeEqual } from "node:crypto";

    export function verifyX1idWebhook(secret, rawBody, header, toleranceSecs = 300) {
      const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
      const t = Number(parts.t);
      if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSecs) return false;
      const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
      const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
      return a.length === b.length && timingSafeEqual(a, b);
    }

    const app = express();

    // Keep the raw body: don't let a JSON parser run first.
    app.post("/x1id/webhook", express.raw({ type: "application/json" }), (req, res) => {
      const raw = req.body.toString("utf8");
      if (!verifyX1idWebhook(process.env.X1ID_WEBHOOK_SECRET, raw, req.get("X1id-Signature") ?? "")) {
        return res.sendStatus(400);
      }
      const ev = JSON.parse(raw);
      if (alreadyProcessed(ev.id)) return res.sendStatus(200);   // at-least-once: dedupe
      queue(ev);                                                   // do the work async
      res.sendStatus(200);                                         // answer fast (< 10 s)
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python title="webhook.py"
    import hashlib, hmac, json, os, time
    from flask import Flask, abort, request

    SECRET = os.environ["X1ID_WEBHOOK_SECRET"]
    app = Flask(__name__)

    def verify_x1id_webhook(secret: str, raw_body: bytes, header: str, tolerance_secs: int = 300) -> bool:
        parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
        try:
            t = int(parts["t"])
        except (KeyError, ValueError):
            return False
        if abs(time.time() - t) > tolerance_secs:
            return False
        signed = f"{t}.".encode() + raw_body
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, parts.get("v1", ""))

    @app.post("/x1id/webhook")
    def x1id_webhook():
        raw = request.get_data()          # raw bytes, before any JSON parsing
        if not verify_x1id_webhook(SECRET, raw, request.headers.get("X1id-Signature", "")):
            abort(400)
        ev = json.loads(raw)
        # dedupe on ev["id"], then queue the work
        return "", 200
    ```
  </Tab>
</Tabs>

During a secret rotation, deliveries switch to the new secret on their next attempt. If you can't update atomically, accept either secret for a short overlap.

## Verify on chain before releasing value

For low-value, automated flows (a receipt email, a dashboard tick) the signed webhook alone is fine. For goods, credit or withdrawals, confirm the fact on chain first:

* **Direct transfer** (`payment.received`): fetch `chain.signature` with `getTransaction(signature, { commitment: "finalized" })`, check it succeeded (`meta.err === null`), and check that the recipient's balance change (native lamports, or the token balance of `recipientTokenAccount` for `mint`) matches `net`.
* **Invoice** (`invoice.paid`): read the **Invoice account** itself and check status `Paid`, `paid_by`, amount and mint. The SDK's [`verifyInvoicePaid`](/docs/x1id/checkout#4-verify-the-payment-on-your-server) does exactly this in one call.

The X1ID SDK and `query.x1id.io/pay/invoice/<address>` are conveniences built on the same data; the account and the transaction are the authority.

## Delivery semantics

* **At least once.** Dedupe on `id` (`X1id-Event-Id`). The same event may arrive more than once, for example a retry after your `2xx` was lost.
* **Success is any `2xx` within 10 s.** Anything else (`3xx`, `4xx`, `5xx`, timeout, connection or TLS failure) is retried. Respond quickly and do slow work asynchronously.
* **Retries:** exponential backoff with full jitter. The n-th retry waits a random time up to `min(6 h, 10 s × 2^(n-1))`. After **6 attempts** the delivery is **dead-lettered**: kept in the delivery log with its last status code or error class.
* **Auto-pause:** after 50 consecutive dead-lettered deliveries the webhook is paused (`paused_reason: "auto-paused: …"`). Fix your endpoint and `POST /v1/pay/webhooks/{id}/resume`. Events that occur while a webhook is paused are not queued for it.
* **Ordering is not guaranteed** (retries reorder). Order by `chain.slot`, then `ixIndex`, then `innerIndex`, and read current state from chain when order matters.
* **Latency:** events come from finalized transactions (roughly 15–20 s after `confirmed` on X1), plus a short polling interval.
* **Delivery log:** `GET /v1/pay/webhooks/{id}/deliveries` shows, per event, the status (`pending` / `delivered` / `dead`), attempts, last HTTP status or a fixed error class (`timeout`, `connect_failed`, `dns_failed`, `blocked_destination`, …) and response time. Response bodies are never read or stored.

## Limits

| Limit                                      | Value                                                                                |
| ------------------------------------------ | ------------------------------------------------------------------------------------ |
| Webhooks per account                       | 25                                                                                   |
| Deliveries per webhook                     | 60 per minute (excess deliveries wait; they are not failures)                        |
| Response timeout                           | 10 s                                                                                 |
| Attempts before dead-letter                | 6                                                                                    |
| Consecutive dead letters before auto-pause | 50                                                                                   |
| Body size                                  | 64 KiB; if trimmed, `memo` and `references` are dropped and `truncated: true` is set |
| Challenge lifetime                         | 10 minutes, single use                                                               |

## Next

<Cards>
  <Card href="/docs/x1id/checkout" icon="ShoppingCart" title="Checkout widget" description="Take payment on your site with an on-chain invoice, then verify it on your server." />

  <Card href="/docs/x1id/events" icon="Webhook" title="Identity events" description="The identity-change event stream: registrations, transfers, records, market." />

  <Card href="/docs/x1id/platform" icon="Server" title="Developer platform" description="API keys, tiers, and the rest of the /v1 surface." />
</Cards>
