Appearance
Webhooks
Receive push notifications as your transactions move through their lifecycle.
Status: target model. This page describes the outbound webhook contract — event delivery, the events query API, and endpoint management. It is not in place today: no webhook delivery service currently exists, and the REST endpoints below follow the same hosted-gateway model (public base URL +
X-API-Key) described in Authentication, which is also a target. Use this page as the forward-looking contract.
Ezys POSTs a signed JSON event to a URL you register each time a transaction changes state, from Intent acceptance through escrow lock and payout to settlement, plus cancellations, expiries, and failures. You run one receiving endpoint and skip the polling loop. Failed deliveries are retried, and the Events API recovers anything you miss.
The event types below are the stable public surface. Internal broker event names seen elsewhere in these docs (for example fx.intent.accepted.v1 in the Intent API) may change independently and are not the contract.
Quick start
- Register your endpoint and store the returned
secret— awhsec_-prefixed string, shown only once. API keys are issued at onboarding (see Authentication):
bash
curl -X POST "https://api.ezys.io/v1/webhook_endpoints" \
-H "X-API-Key: {your_api_key}" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.example.com/webhooks/ezys", "subscribedTypes": ["intent.accepted", "payout.completed", "payout.failed"] }'- Add the signature check to your handler — the code in Signature is copy-paste ready.
- Send yourself a
test.ping:POST /v1/webhook_endpoints/test, and confirm your handler responds 2xx. - In production: process only events whose envelope carries
livemode: true— a top-level field of every event envelope — deduplicate onid, order bysequence.
Delivery
Request
http
POST {your registered URL}
Content-Type: application/json; charset=utf-8
User-Agent: ezys-webhook/1.0
Ezys-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]- The body is a single UTF-8 JSON event (see Event envelope), at most 64 KB.
- Only HTTP POST is used. 3xx redirects are not followed and count as a failed delivery.
- The receiving URL must be HTTPS, TLS 1.2 or later.
Signature
http
Ezys-Signature: t=1783934567,v1=5f2c8a...Verify in four steps:
- Take
tand everyv1from theEzys-Signatureheader. - Compute
HMAC-SHA256(secret, "{t}." + raw_body)over the raw body bytes, not re-serialized JSON. The key is the entire secret string exactly as issued at registration, including itswhsec_prefix. - Compare against each
v1in constant time; one match is enough (two values appear during a secret rotation window). - Reject if
|now − t| > 300seconds.
js
const crypto = require('crypto')
// rawBody: the unparsed request body bytes
function verifyWebhook(headers, rawBody, secret) {
const header = headers['ezys-signature'] || ''
const t = (header.match(/t=(\d+)/) || [])[1]
const sigs = [...header.matchAll(/v1=([0-9a-f]+)/g)].map(m => m[1])
if (!t || sigs.length === 0) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false // replay window
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
return sigs.some(s => {
try {
return crypto.timingSafeEqual(Buffer.from(s, 'hex'), Buffer.from(expected, 'hex'))
} catch {
return false
}
})
}The code needs no change during rotation, since it accepts whichever v1 matches.
If verification fails, check in this order:
| Symptom | Usual cause |
|---|---|
| Never matches | the body was parsed and re-serialized — verify the raw bytes |
| Works in test, fails in production | wrong environment's secret, or the secret was rotated and not updated |
| Intermittent failures | server clock skew beyond the 300-second window — sync with NTP |
t is regenerated on every delivery attempt; the event id in the body never changes.
Retries
| Item | Value |
|---|---|
| Success | any HTTP 2xx (response body is ignored) |
| Request timeout | 15 seconds |
| Retry schedule | immediately → 1m → 5m → 30m → 2h → 6h → 24h → 24h (8 attempts over ~2.5 days, with jitter) |
| After the last attempt | the event stays in the 15-day store; fetch it via the Events API or trigger Resend |
410 Gone response | the endpoint is deactivated immediately |
| Sustained failure | the endpoint is deactivated after every delivery to it has failed for 5 continuous days, counted across all events. Reactivate it with PATCH { "isActive": true }; you are notified out of band before this happens |
Recommended receiver pattern: store the body, respond 2xx immediately, and process asynchronously. Slow handlers cause timeouts and unnecessary retries.
Delivery semantics
- Delivery is at-least-once. The same event can arrive more than once, so deduplicate on
id. - Events are delivered and retried independently. Arrival order is not guaranteed; reconstruct order with
sequence. - Multiple events may be delivered to your endpoint in parallel.
Security notes
| Item | Detail |
|---|---|
| Forgery | HMAC-SHA256 signature — a third party without the secret cannot produce a valid delivery |
| Replay | deliveries older than the 300-second tolerance are rejected by your verification |
| Transport | HTTPS required, TLS 1.2+ |
| Source IPs | a fixed egress IP list is published at go-live for firewall allow-listing |
| Failed verification | respond 401. Any non-2xx response is retried, so coordinate with us if you intend a permanent rejection |
| Secret handling | the secret is shown once at issuance (stored encrypted on our side). Keep it out of code and logs; rotate immediately on suspected exposure |
Event envelope
Every delivery wraps one event in a common envelope. The type says what happened, and data.object holds the Intent's full state at that moment.
One event store, two surfaces. A webhook delivery is the push form of the same event the Status API returns in
events[](pull):occurredAt/observedAt/isTerminalare identical fields, and the leg statuses below correspond to the Status API's per-phasestate. Both surfaces read one store and never disagree — List Events is the recovery path between them.
json
{
"object": "event",
"id": "evt_9f2c1a7e-3b41-4c8a-9d02-b6f0e1a2c3d4",
"type": "payout.completed",
"occurredAt": "2026-07-13T02:02:40.000Z",
"observedAt": "2026-07-13T02:02:47.113Z",
"isTerminal": true,
"livemode": true,
"apiVersion": "2026-08-01",
"intentId": "INT-2F8A3B1C",
"clientReference": "NR-2026-000123",
"sequence": 5,
"data": {
"object": {
"id": "INT-2F8A3B1C",
"status": "payout.completed",
"input": { "asset": "USDC", "amountMinor": "100000000", "exponent": 6, "chain": "ethereum" },
"output": { "currency": "USD", "amountMinor": "9995", "exponent": 2 },
"beneficiary": { "nameMasked": "A*** C***", "accountLast4": "6789", "accountHash": "d1f8a4…", "bank": "Wells Fargo" },
"lock": { "txHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" },
"offramp": { "status": "completed", "toAmountMinor": "9995", "offrampId": "ofr_88231" },
"payout": { "status": "completed", "confirmedAt": "2026-07-13T02:02:40.000Z", "receipt": "0x9c1d8f2e…", "providerReference": "pr_44a1" },
"failure": null,
"fundsState": { "location": "released" }
}
}
}| Field | Type | Description |
|---|---|---|
object | string | Always event |
id | string | Permanent unique ID (evt_ + UUID), identical across retries, resends, and backfill. Your deduplication key. IDs are resource-typed by prefix (evt_ event, ep_ endpoint, whsec_ secret) so they stay unambiguous in logs and support requests |
type | string | One of the event types, <resource>.<action> |
occurredAt | string | ISO 8601 — when the transition happened at the source. The same field the Status API returns in events[].occurredAt |
observedAt | string | ISO 8601 — when Ezys recorded it; occurredAt ≤ observedAt (same as the Status API) |
isTerminal | boolean | true when no further events will follow for this intent — the ✕/✓ rows in event types |
livemode | boolean | false for events produced in the test environment |
apiVersion | string | Payload schema version pinned to your endpoint |
intentId | string | null | The Intent this event belongs to. null only for test.ping |
clientReference | string | null | Your reference key, supplied once in the submission request (Intent API clientReference) and echoed verbatim here — the same join key the Status and Receipts APIs return. Not related to webhook-endpoint registration, which is account-level. null only for test.ping |
sequence | number | Starts at 1 per Intent and increases contiguously. A gap means a missed event (see The Intent object) |
data.object | object | Snapshot of the Intent after this event (see The Intent object) |
All webhook timestamps are ISO 8601 strings — the only Unix value in a delivery is the signature header's t, which is per-attempt and unrelated to event time.
All monetary values follow the Money & Amounts contract — integer strings in the currency's smallest unit with an explicit exponent, never floating-point. (amountMinor is this surface's camelCase spelling of the contract's amount_minor; crypto assets use their token decimals as the exponent, e.g. USDC 6.)
The Intent object
data.object is the same shape on every event: a snapshot of the Intent after the event was applied. Later events fill in more fields (a lock hash, a conversion rate, a payout receipt) and advance the three status fields; the identity fields never change.
json
{
"id": "INT-2F8A3B1C",
"status": "payout.in_progress",
"input": { "asset": "USDC", "amountMinor": "100000000", "exponent": 6, "chain": "ethereum" },
"output": { "currency": "USD", "amountMinor": "9995", "exponent": 2 },
"beneficiary": { "nameMasked": "A*** C***", "accountLast4": "6789", "accountHash": "d1f8a4…", "bank": "Wells Fargo" },
"lock": { "txHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" },
"offramp": { "status": "completed", "fromAsset": "USDC", "fromAmountMinor": "100000000",
"toCurrency": "USD", "toAmountMinor": "9995", "fxRate": "0.9995", "offrampId": "ofr_88231" },
"payout": { "status": "in_progress", "provider": "fiat-partner", "confirmedAt": null, "receipt": null, "providerReference": "pr_44a1" },
"failure": null,
"fundsState": { "location": "locked", "lockTxHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" }
}| Field | Type | Description |
|---|---|---|
id | string | The Intent ID, equal to the envelope intentId |
status | string | Overall state — always the composite <phase>.<state>, equal to the latest lifecycle event type (e.g. intent.accepted, offramp.in_progress, payout.completed). While a leg is active the suffix mirrors that leg's status exactly. See states |
input | object | Funds coming in: asset, amountMinor, exponent, chain |
output | object | Funds going out: currency, amountMinor, exponent |
beneficiary | object | Recipient, masked per the Receipts API schema: nameMasked, accountLast4, accountHash, optional bank |
lock | object | null | HTLC escrow lock: txHash, timelockExpiresAt. null before the lock exists |
offramp | object | null | Off-ramp conversion, USDC→payout currency. Carries its own status plus fromAsset/fromAmountMinor/toCurrency/toAmountMinor/fxRate/offrampId (the conversion's reference). null before it starts |
payout | object | null | Payout to the beneficiary. Carries its own status plus provider/confirmedAt/receipt/providerReference. receipt is the on-chain anchor txHash of the normalized receipt — the same txHash the Receipts API returns from GET /v1/executions/{reference}/receipt; it stays null until the anchoring transaction is confirmed (verify the receipt against the chain with it). providerReference is the provider's payout tracking id. The whole object is null before the leg starts |
failure | object | null | Present when status is failed — { stage, code, message, retryable }, the same block the Status API returns. null otherwise |
fundsState | object | Current funds location. location is exactly one of: none_moved (before the lock — also the final value for cancelled/expired/pre-lock failed intents), locked (in escrow — failures after the lock stay here, refundable once the timelock expires; see Failures), released (payout has left). Carries lockTxHash/timelockExpiresAt while locked |
Sensitive beneficiary fields are always masked (nameMasked, accountLast4, accountHash) — a full account number never appears. Fields a step has not reached yet are null.
Versioning
- Non-breaking changes (new fields, new event types) may appear without an
apiVersionchange. Ignore unknown fields and unknown types. - Breaking changes (removing or renaming fields, changing meanings) ship only under a new
apiVersion, pinned per endpoint, with a migration window during which both versions are supported. intentIdandsequencesemantics are fixed:sequenceis contiguous within itsintentIdchannel. If event families outside the transaction lifecycle are added later, they will use additional identifier fields rather than changing these.sequencecounts the intent's full event stream, not per subscription. If you subscribe to a subset of types, you will see gaps where the types you did not subscribe to would have been — those are expected, so scope gap-detection to the types you subscribe to.
test.ping
A connectivity-test event, sent by Test delivery. It uses the same envelope as every other event, so your handler can verify the signature and parse it with the same code path. It is not a transaction event, so intentId and clientReference are null, sequence is 0, and data.object carries no Intent.
json
{
"object": "event",
"id": "evt_test_9f2c1a7e",
"type": "test.ping",
"occurredAt": "2026-07-13T02:02:47.000Z",
"observedAt": "2026-07-13T02:02:47.000Z",
"isTerminal": true,
"livemode": false,
"apiVersion": "2026-08-01",
"intentId": null,
"clientReference": null,
"sequence": 0,
"data": { "object": { "ping": true } }
}Always verify the signature and return 2xx for test.ping; only apply your business logic to lifecycle events (intent.*, offramp.*, payout.*).
Event types
Events are grouped by the part of the transaction they report on: the Intent overall, the off-ramp conversion, and the payout. Each event delivers a full Intent object snapshot; the type tells you which field just changed.
intent.accepted
│
├─ intent.cancelled / intent.expired / intent.failed ✕ terminal (fails before the legs)
▼
offramp.initiated ──▶ offramp.in_progress ──▶ offramp.completed
│ │ │
└──────────────────────┴─▶ offramp.failed ✕ ▼
payout.initiated ──▶ payout.in_progress ──▶ payout.completed ✓
│ │
└──────────────────────┴─▶ payout.failed ✕In the diagram, ✕ marks a terminal event — the transaction is final and no more events follow it. ✓ is the successful completion. offramp.in_progress / payout.in_progress fire only when the provider processes that leg asynchronously and may be skipped (initiated → completed). A failing leg emits its own terminal event directly (offramp.failed / payout.failed) — no separate intent.failed follows it; intent.failed fires only for failures before the legs.
The table below shows which of the three status fields each event sets. A blank cell means that field is unchanged and keeps its previous snapshot value. A terminal event is marked ✕.
| Type | Fires when | status | offramp.status | payout.status |
|---|---|---|---|---|
intent.accepted | validation passes, solver assigned | intent.accepted | ||
offramp.initiated | off-ramp conversion starts | offramp.initiated | initiated | |
offramp.in_progress | async provider processing the conversion (may be skipped) | offramp.in_progress | in_progress | |
offramp.completed | conversion confirmed (offrampId, final amount) | offramp.completed | completed | |
payout.initiated | payout to the beneficiary starts | payout.initiated | initiated | |
payout.in_progress | async partner disbursement underway (may be skipped) | payout.in_progress | in_progress | |
payout.completed | beneficiary confirmed paid; receipt carries the on-chain receipt anchor (txHash) once confirmed | payout.completed ✓ | completed | |
offramp.failed | conversion fails (failure.stage: "offramp") | offramp.failed ✕ | failed | |
payout.failed | payout fails (failure.stage: "payout") | payout.failed ✕ | failed | |
intent.cancelled | cancelled before completion | intent.cancelled ✕ | ||
intent.expired | the intent deadline passes before execution | intent.expired ✕ | ||
intent.failed | fails before the off-ramp/payout legs (failure.stage: "intent") | intent.failed ✕ |
A blank in offramp.status or payout.status also covers the case where that leg has not started yet (the field is null in the snapshot). Because the overall status is always <phase>.<state>, it simply equals the event type of the latest lifecycle event.
Transaction states
The Intent object carries an overall status plus a per-leg status on offramp and payout. The overall status is always the composite <phase>.<state> — it equals the type of the latest lifecycle event, and while a leg is active the suffix mirrors that leg's own status exactly (leg offramp.status: "in_progress" ⇒ overall status: "offramp.in_progress"), so the two can never disagree.
Intent status — data.object.status, the transaction overall:
| Status | Meaning |
|---|---|
intent.accepted | Intent accepted, settlement plan created — legs not started |
offramp.initiated / offramp.in_progress / offramp.completed | The off-ramp leg is active; the suffix is offramp.status |
payout.initiated / payout.in_progress | The payout leg is active; the suffix is payout.status |
payout.completed | Fully settled, receipt confirmed (terminal ✓) |
intent.failed / offramp.failed / payout.failed | Terminal failure — the prefix is the failing stage (equals failure.stage); the failure block carries code/retryable |
intent.cancelled | Cancelled before completion (terminal ✕) |
intent.expired | The intent deadline passed before execution (terminal ✕) |
Off-ramp status — data.object.offramp.status, the USDC→USD conversion:
| Status | Meaning |
|---|---|
initiated | off-ramp started |
in_progress | conversion underway |
completed | conversion confirmed (offrampId, final amount) |
failed | conversion failed |
Payout status — data.object.payout.status, delivery to the beneficiary account:
| Status | Meaning |
|---|---|
initiated | payout started |
in_progress | partner disbursement underway |
completed | payout confirmed, funds received (receipt) |
failed | payout failed |
in_progress on a leg appears only when the provider processes it asynchronously; a synchronous provider jumps initiated → completed, so in_progress may never be observed for that leg. The overall status is authoritative and self-describing — the composite <phase>.<state> alone tells you where the transaction is without reading the leg objects.
Detect missed events by sequence gaps, since several events can leave every status unchanged:
normal: seq 1 intent.accepted → 2 offramp.initiated → 3 offramp.completed → 4 payout.initiated → 5 payout.completed
missed event: received seq 1 → 2 → 4 ⇒ 3 is missing → recover via GET /v1/eventsBecause delivery is at-least-once and unordered, a gap can also be an event still in flight. Treat a gap as a real miss only after a short grace window (a few seconds), then call the Events API.
Because sequence starts at 1 per Intent, a gap only reveals an event lost mid-stream. An endpoint that was down when the first event fired sees no Intent at all; find those by periodic reconciliation with List Events over a time window.
Event examples
The examples below show data.object at each step of one transaction. The envelope is omitted for brevity; only the object is shown, with the fields that changed highlighted in comments. Identity fields (id, input, output, beneficiary) are present on every event and abbreviated as ... after the first.
Normal flow
json
// intent.accepted → status: intent.accepted; funds not yet moved, no lock
{ "id": "INT-2F8A3B1C", "status": "intent.accepted",
"input": { "asset": "USDC", "amountMinor": "100000000", "exponent": 6, "chain": "ethereum" },
"output": { "currency": "USD", "amountMinor": "9995", "exponent": 2 },
"beneficiary": { "nameMasked": "A*** C***", "accountLast4": "6789", "accountHash": "d1f8a4…", "bank": "Wells Fargo" },
"lock": null, "offramp": null, "payout": null,
"fundsState": { "location": "none_moved" } }
// offramp.initiated → status: offramp.initiated; funds locked in escrow
{ "id": "INT-2F8A3B1C", "status": "offramp.initiated", ...,
"lock": { "txHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" },
"offramp": { "status": "initiated", "fromAsset": "USDC", "fromAmountMinor": "100000000", "toCurrency": "USD" },
"payout": null,
"fundsState": { "location": "locked", "lockTxHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" } }
// offramp.in_progress → async provider only (may be skipped); status mirrors the leg
{ "id": "INT-2F8A3B1C", "status": "offramp.in_progress", ...,
"offramp": { "status": "in_progress", "fromAsset": "USDC", "fromAmountMinor": "100000000", "toCurrency": "USD" } }
// offramp.completed → status: offramp.completed
{ "id": "INT-2F8A3B1C", "status": "offramp.completed", ...,
"offramp": { "status": "completed", "fromAsset": "USDC", "fromAmountMinor": "100000000",
"toCurrency": "USD", "toAmountMinor": "9995", "fxRate": "0.9995", "offrampId": "ofr_88231" } }
// payout.initiated → status: payout.initiated
{ "id": "INT-2F8A3B1C", "status": "payout.initiated", ...,
"payout": { "status": "initiated", "provider": "fiat-partner", "confirmedAt": null, "receipt": null } }
// payout.completed → status: payout.completed; funds released from escrow
{ "id": "INT-2F8A3B1C", "status": "payout.completed", ...,
"payout": { "status": "completed", "provider": "fiat-partner", "confirmedAt": "2026-07-13T02:02:40.000Z", "receipt": "0x9c1d8f2e…", "providerReference": "pr_44a1" },
"fundsState": { "location": "released" } }Failures
A failed leg sets its own status and the Intent status to failed, and the top-level failure block identifies where and why — the same { stage, code, message, retryable } block as the Status API. These examples show only the changed fields; the accreted offramp/payout sub-object keeps its earlier fields too. fundsState reports where the money sits.
json
// offramp.failed → funds still locked, refundable after the timelock
{ "id": "INT-2F8A3B1C", "status": "offramp.failed", ...,
"offramp": { "status": "failed" },
"failure": { "stage": "offramp", "code": "conversion_failed", "message": "FX conversion failed at the provider.", "retryable": false },
"fundsState": { "location": "locked", "lockTxHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" } }
// payout.failed → funds still locked, refundable after the timelock
{ "id": "INT-2F8A3B1C", "status": "payout.failed", ...,
"payout": { "status": "failed" },
"failure": { "stage": "payout", "code": "beneficiary_account_closed", "message": "Beneficiary bank rejected the credit: account closed.", "retryable": false },
"fundsState": { "location": "locked", "lockTxHash": "0xabc...", "timelockExpiresAt": "2026-07-13T02:53:20.000Z" } }
// intent.cancelled / intent.expired → cancelled before the lock; funds untouched
{ "id": "INT-2F8A3B1C", "status": "intent.cancelled", ...,
"fundsState": { "location": "none_moved" } }Failure semantics: failure.stage is the phase that failed (intent, offramp, or payout), failure.code is a stable lower-snake code (provider-original codes are mapped, never passed through raw), and failure.retryable follows the Error Handling & Retries classification. An intent.failed before the legs uses stage: "intent" with a code from the Intent API rejection reasons (beneficiary_required_for_remittance, no_liquidity, below_minimum_output, beneficiary_account_required_for_fiat_output).
Naming conventions, consistent with the rest of the platform API: field names are camelCase (lockTxHash, offrampId), enum values are lower snake_case (in_progress, no_liquidity) — the composite overall status joins phase and state with a dot (offramp.in_progress) — and gateway error codes in the Errors (REST API) section are UPPER_SNAKE.
List Events
http
GET /v1/eventsEach returned event has the same shape as a webhook delivery — the envelope with data.object inside. The only difference is pull versus push: here you fetch from the same store the dispatcher delivers from. The retention and recovery window is 15 days.
Use this to recover missed deliveries, not as a polling loop; under normal operation the webhook stream keeps you current. Requests count against the standard rate limit, and each page returns at most 100 events.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
since | string | No | - | Omit on your first call — listing starts at the oldest event in the 15-day window. On subsequent calls pass the previous response's nextCursor verbatim. It is an opaque cursor, not a timestamp — never construct one or pass an epoch value. Results are ascending |
type | string | No | - | filter by event type, e.g. payout.completed |
intentId | string | No | - | filter by Intent |
limit | number | No | 20 | items per page (max 100) |
Example request
bash
# everything after your last cursor
curl -H "X-API-Key: {your_api_key}" \
"https://api.ezys.io/v1/events?since=184390&limit=100"
# all events for one Intent (gap recovery)
curl -H "X-API-Key: {your_api_key}" \
"https://api.ezys.io/v1/events?intentId=INT-2F8A3B1C"Example response
json
{
"data": [
{
"object": "event",
"id": "evt_9f2c1a7e-3b41-4c8a-9d02-b6f0e1a2c3d4",
"type": "offramp.completed",
"occurredAt": "2026-07-13T01:53:20.000Z",
"observedAt": "2026-07-13T01:53:20.412Z",
"isTerminal": false,
"livemode": true,
"apiVersion": "2026-08-01",
"intentId": "INT-2F8A3B1C",
"clientReference": "NR-2026-000123",
"sequence": 3,
"data": { "object": { "...": "full Intent snapshot, same as a webhook — see The Intent object" } }
}
],
"hasMore": true,
"nextCursor": "184392"
}| Field | Type | Description |
|---|---|---|
data | array | events in the common envelope, ascending by internal cursor |
hasMore | boolean | whether another page exists |
nextCursor | string | pass as since on the next call. Returned even when hasMore is false, so it is your starting point for new events |
since is exclusive (returns events after the cursor) and the cursor is opaque and monotonic, stable across calls. Omitting since starts at the oldest event still inside the 15-day window. To backfill a gap, page forward with nextCursor rather than requesting a wide range in one call.
Get Event
http
GET /v1/events/{eventId}Returns a single event, or 404 if it does not exist or has aged out of the 15-day retention window.
Webhook Endpoints
Get endpoint
http
GET /v1/webhook_endpointsReturns your endpoint configuration. Secret values are never returned.
json
{
"endpointId": "ep_01j9...",
"url": "https://api.example.com/webhooks/ezys",
"subscribedTypes": ["intent.accepted", "payout.completed"],
"isActive": true,
"rotatedAt": null
}Register endpoint
http
POST /v1/webhook_endpoints| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | receiving URL. Must be a public HTTPS host: no http, no private, loopback, or link-local addresses, no userinfo, standard ports only. A rejected URL returns 400 INVALID_URL |
subscribedTypes | string[] | Yes | event types to receive |
json
{
"endpointId": "ep_01j9...",
"secret": "whsec_..."
}One endpoint is registered per API key; a second POST returns 409 ALREADY_EXISTS. The secret is shown only in this response. If lost, rotate to receive a new one.
Update endpoint
http
PATCH /v1/webhook_endpointsChanges url and/or subscribedTypes in place. The secret is unchanged, so this is the routine way to adjust subscriptions or move the receiving URL without a delete-and-re-register cycle.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | No | new receiving URL, same rules as registration |
subscribedTypes | string[] | No | replaces the subscription list |
isActive | boolean | No | set true to reactivate an endpoint that was deactivated (by 410, sustained failure, or DELETE); the secret is unchanged |
Rotate secret
http
POST /v1/webhook_endpoints/rotateIssues a new secret. From this moment until the grace window ends (24 hours by default), deliveries are signed with both secrets (two v1 values in the header), after which the new secret takes over automatically.
json
{
"nextSecret": "whsec_...",
"graceExpiresAt": "2026-07-14T02:02:47.000Z"
}Test delivery
http
POST /v1/webhook_endpoints/testSends one test.ping event to your registered URL and responds 202. Use it to check signature handling before real traffic.
Deactivate endpoint
http
DELETE /v1/webhook_endpoints/{endpointId}Stops deliveries and marks the endpoint inactive. Events keep being recorded while it is deactivated, so once you reactivate it (PATCH { "isActive": true }) you can recover the gap through the Events API.
Resend an event
http
POST /v1/events/{eventId}/resendRe-delivers a single event to your active endpoint, carrying the same id as the original. Use it to recover a delivery you dropped after retries were exhausted, without polling. Responds 202; 404 if the event has aged out of the 15-day window.
Idempotency
Write requests (register, update, rotate, test, resend) accept an Idempotency-Key header: any unique string, a UUID is recommended.
http
POST /v1/webhook_endpoints
Idempotency-Key: 7f2c1a7e-3b41-4c8a-9d02-b6f0e1a2c3d4- Repeating a request with the same key returns the original result (keys are kept for 24 hours), making retries after a lost response safe. Reusing a key with a different body returns
409. - Without the header: re-registering returns
409 ALREADY_EXISTS, re-rotating starts a fresh grace window (invalidating the previousnextSecret), and test sends again.
Webhook deliveries themselves need no such header, since the envelope id is the deduplication key.
Errors (REST API)
REST endpoints on this page use the hosted-gateway envelope and code style from the API Reference overview. Webhook delivery failures are separate; see Retries.
json
{
"success": false,
"error": {
"code": "INVALID_CURSOR",
"message": "The since cursor is malformed"
}
}| HTTP | Codes | Meaning |
|---|---|---|
| 400 | INVALID_CURSOR, INVALID_TYPE, INVALID_URL | malformed request parameters, or a rejected endpoint URL |
| 401 | MISSING_API_KEY, INVALID_API_KEY | authentication failed |
| 403 | API_KEY_DISABLED | key disabled |
| 404 | NOT_FOUND | no such resource, or aged out of retention |
| 409 | ALREADY_EXISTS, IDEMPOTENCY_CONFLICT | duplicate registration, or an Idempotency-Key reused with a different body |
| 429 | RATE_LIMIT_EXCEEDED | too many requests. A Retry-After header gives the seconds to wait; the concrete limit is in the overview |
| 500 / 503 | INTERNAL_ERROR, SERVICE_UNAVAILABLE | transient server error. Reads are safe to retry; retry writes with the same Idempotency-Key |
Integrating your receiver
- Verify the signature. Four steps and copy-paste code in Signature. Always use the raw body bytes.
- Deduplicate on
id. Retries, resends, and backfill all carry the sameid. - Order and detect gaps by
sequence. Arrival order is not guaranteed, and a skipped state can be normal; only asequencegap is a missed event. - Respond fast. Store, return 2xx, process asynchronously.
- Read
data.objectfor the current snapshot. Its three status fields (Transaction states) hold the Intent, off-ramp, and payout states. - Stay forward-compatible. Ignore unknown event types and unknown fields.
- Filter on
livemode. Production handlers should process onlylivemode: true. - Reconcile periodically. List Events over a recent window to catch any Intent your endpoint never saw (a
sequencegap cannot reveal a fully-missed Intent).
