X1ID is live on X1 mainnet
X1ID / Pay webhooks

Pay webhooks

Mainnet

Get an HTTPS POST when a wallet or an @name you control is paid, or when one of its invoices 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).

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, dedupe on the event id, and confirm on chain before releasing anything of value.

Quick start

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

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.

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": "…" }

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.

Register

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 any time.

Who may register what

ScopeThe signing wallet must be…
a wallet addressthat 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.

Dotted names (subnames, name.x1, customer TLDs) are not supported as a scope yet. Subscribe to the paying wallet's address instead.

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 & pathBody / queryReturns
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}/resumere-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, invoice.*, payment.*, invoice, payment, or *. Empty means all. Unknown values are rejected.

Errors

Errors are {code, message}:

CodeMeaning
INVALID_SCOPE, INVALID_WALLET, INVALID_URL, INVALID_EVENT_TYPESA field failed validation
WALLET_MISMATCHFor a wallet scope, wallet must be that same wallet
CHALLENGE_INVALIDNonce unknown, already used, or expired
SIGNATURE_INVALIDThe signature doesn't verify over message
NOT_CONTROLLER, CONTROL_UNPROVENThe wallet doesn't (provably) control the scope
NOT_FOUNDThe @name isn't registered
URL_REJECTEDThe URL resolves to a non-public address
LIMIT_REACHEDThe account already has the maximum number of webhooks
WEBHOOK_NOT_FOUNDNo such webhook on this account
NOT_RESUMABLEThe webhook isn't auto-paused
RATE_LIMITED, API_KEY_REQUIREDKey / rate-limit errors, as on the rest of /v1
PAY_WEBHOOKS_DISABLEDThe feature is switched off on this deployment
RPC_ERRORChain read failed; retry

Event types

TypeWhen
payment.receivedA direct transfer (System or SPL, Solana Pay style) arrived at a watched X1ID payment address
invoice.paidAn X1ID invoice was paid through the registry program (pay_invoice)
invoice.createdAn invoice was created with this payee
invoice.cancelledAn invoice was cancelled
invoice.closedAn invoice account was closed (invoice.closedStatus: 0 open/expired, 1 paid, 2 cancelled)
payment.binding_verifiedA 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:

{
  "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:

StatusMeaning
verifiedProven from chain state at the payment's slot. basis is owner, holder, verified-record, or invoice (the program itself checked it)
noneNo @name pays to that wallet
unboundAn invoice created without a name
unverifiableCould not be proven (yet); may follow as payment.binding_verified

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.

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.

  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.
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)
});
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

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 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

LimitValue
Webhooks per account25
Deliveries per webhook60 per minute (excess deliveries wait; they are not failures)
Response timeout10 s
Attempts before dead-letter6
Consecutive dead letters before auto-pause50
Body size64 KiB; if trimmed, memo and references are dropped and truncated: true is set
Challenge lifetime10 minutes, single use

Next

Browse the docs

Pay webhooks