Pay webhooks
MainnetGet 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
| 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.
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 & 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, 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:
{
"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 |
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.
- Read the raw request bytes. Never re-serialize parsed JSON.
- Parse
tandv1fromX1id-Signature. - Reject if
tis outside your tolerance window (300 s recommended) to stop replays. - Recompute the HMAC and compare in constant time.
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)
});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 "", 200During 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): fetchchain.signaturewithgetTransaction(signature, { commitment: "finalized" }), check it succeeded (meta.err === null), and check that the recipient's balance change (native lamports, or the token balance ofrecipientTokenAccountformint) matchesnet. - Invoice (
invoice.paid): read the Invoice account itself and check statusPaid,paid_by, amount and mint. The SDK'sverifyInvoicePaiddoes 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 your2xxwas lost. - Success is any
2xxwithin 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 andPOST /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, thenixIndex, theninnerIndex, and read current state from chain when order matters. - Latency: events come from finalized transactions (roughly 15–20 s after
confirmedon X1), plus a short polling interval. - Delivery log:
GET /v1/pay/webhooks/{id}/deliveriesshows, 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 |
