Events & webhooks
MainnetEvery on-chain identity change — a handle registered, transferred, a primary set, an attestation added, a sale — becomes a stable, typed event. Register a webhook and X1ID delivers each matching event to your endpoint as a signed POST, with retries and a dead-letter record. One stream powers identity-change alerts and Gate eligibility hooks, and a sibling stream, Pay webhooks, covers payments and invoices, so you don't poll.
Payments and invoices: self-serve today. To get a webhook when a wallet or an @name you control is paid, or when one of its invoices is created, paid, cancelled or closed, register it yourself with your API key and a wallet signature. See Pay webhooks. Those deliveries use the x1id.pay-events/v1 payload and the same signature scheme as below.
Identity events: provisioned on request. Self-serve registration for the identity stream on this page (registrations, transfers, records, market, …) isn't public yet. Email [email protected] with your endpoint URL and the event types you want, and you'll get back a signing secret. The envelope and verification recipe below are the stable delivery contract, so you can build your receiver against them today.
The event envelope
Every event is a small, stable JSON object (x1id.events/v1). The on-chain instruction name and raw indexer shape are implementation details that churn; you get a stable { category, type }, a typed data projection of the fields you most want, and — always — the untouched row in raw as an escape hatch.
{
"schema": "x1id.events/v1",
"id": 148123, // monotonic; dedupe on this
"type": "market.sold", // stable, namespaced
"category": "market", // coarse filtering axis
"rawEventType": "buy_handle", // the on-chain instruction
"handle": { "name": "alice", "account": "<Handle PDA base58>" }, // null for config/sentinel events
"owner": "<pubkey|null>",
"chain": { "signature": "<sig>", "ixIndex": 0, "slot": 183500000, "blockTime": "2026-10-02T00:00:00.000Z" },
"data": { "priceLamports": "10000000000", "feeLamports": "250000000", "buyer": "…", "seller": "…" },
"raw": { /* the indexer detail, verbatim */ },
"emittedAt": "2026-10-02T12:00:00.000Z"
}Lamport amounts are decimal strings. A value whose shape isn't certain stays only in raw — nothing is reshaped or guessed.
Event types
Events are grouped into categories; each category has a set of normalized types.
| Category | Example types |
|---|---|
registration | identity.registered |
ownership | identity.transferred, identity.released, identity.recovered, identity.nft_minted |
records | record.created/updated/closed/verified/cleared, text_record.*, record.delegate_set/revoked |
primary | primary.set, primary.cleared |
attestation | attestation.created, attestation.revoked |
market | market.listed, market.sold, market.offer_placed, market.auction_started/settled, market.bid_placed |
lock | lock.locked, lock.unlock_initiated, lock.unlocked |
subname | subname.created/revoked, subname.record_* |
namespace | namespace.created, namespace.status_changed, namespace.override_* |
voucher | voucher.created, voucher.refunded |
commitment | commitment.created, commitment.cancelled |
lease | lease.renewed |
admin | admin.config_changed (the specific op is in data.action) |
other | unknown — any instruction a build predates is still delivered, never dropped |
Filtering
A subscription's filter is an array of tokens; an event matches if the array is empty (all events) or any token matches:
| Token | Matches |
|---|---|
* | everything |
market | every event in that category |
market.* | normalized types under that prefix |
market.sold | that exact type |
buy_handle | events whose rawEventType matches (the raw instruction) |
Verifying a delivery
Each delivery is a POST of the JSON body with these headers:
Content-Type: application/json
X1id-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA256>
X1id-Event-Id: <event id>
X1id-Event-Type: <normalized type>
X1id-Delivery-Id: <delivery id>
X1id-Subscription-Id: <whsub_...>The signature is HMAC-SHA256 over `${t}.${rawBody}` using your subscription secret (whsec_…, shown once at creation). This is the Stripe scheme — the timestamp t foils replay.
To verify: (1) read the raw request body bytes (don't re-serialize the parsed JSON); (2) parse t and v1 from X1id-Signature; (3) reject if |now − t| exceeds your tolerance (300 s recommended); (4) recompute the HMAC and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header, toleranceSecs = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!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);
}Delivery, retries & ordering
- A delivery succeeds only on a
2xx. A3xxredirect is a failure — a webhook endpoint must not redirect. - On failure it retries with full-jitter exponential backoff, up to a configured number of attempts (6 by default), then dead-letters the delivery while keeping the full signed payload for inspection or replay.
- Dedupe on the event
id(and theX1id-Event-Idheader): delivery is at-least-once, so the same event can arrive more than once, butidis monotonic and stable. - Respond fast (
2xx) and process asynchronously — a slow handler just invites a retry.
