Skip to content

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

  1. Register your endpoint and store the returned secret — a whsec_-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"] }'
  1. Add the signature check to your handler — the code in Signature is copy-paste ready.
  2. Send yourself a test.ping: POST /v1/webhook_endpoints/test, and confirm your handler responds 2xx.
  3. In production: process only events whose envelope carries livemode: true — a top-level field of every event envelope — deduplicate on id, order by sequence.

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:

  1. Take t and every v1 from the Ezys-Signature header.
  2. 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 its whsec_ prefix.
  3. Compare against each v1 in constant time; one match is enough (two values appear during a secret rotation window).
  4. Reject if |now − t| > 300 seconds.
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:

SymptomUsual cause
Never matchesthe body was parsed and re-serialized — verify the raw bytes
Works in test, fails in productionwrong environment's secret, or the secret was rotated and not updated
Intermittent failuresserver 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

ItemValue
Successany HTTP 2xx (response body is ignored)
Request timeout15 seconds
Retry scheduleimmediately → 1m → 5m → 30m → 2h → 6h → 24h → 24h (8 attempts over ~2.5 days, with jitter)
After the last attemptthe event stays in the 15-day store; fetch it via the Events API or trigger Resend
410 Gone responsethe endpoint is deactivated immediately
Sustained failurethe 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

ItemDetail
ForgeryHMAC-SHA256 signature — a third party without the secret cannot produce a valid delivery
Replaydeliveries older than the 300-second tolerance are rejected by your verification
TransportHTTPS required, TLS 1.2+
Source IPsa fixed egress IP list is published at go-live for firewall allow-listing
Failed verificationrespond 401. Any non-2xx response is retried, so coordinate with us if you intend a permanent rejection
Secret handlingthe 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/isTerminal are identical fields, and the leg statuses below correspond to the Status API's per-phase state. 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" }
    }
  }
}
FieldTypeDescription
objectstringAlways event
idstringPermanent 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
typestringOne of the event types, <resource>.<action>
occurredAtstringISO 8601 — when the transition happened at the source. The same field the Status API returns in events[].occurredAt
observedAtstringISO 8601 — when Ezys recorded it; occurredAtobservedAt (same as the Status API)
isTerminalbooleantrue when no further events will follow for this intent — the ✕/✓ rows in event types
livemodebooleanfalse for events produced in the test environment
apiVersionstringPayload schema version pinned to your endpoint
intentIdstring | nullThe Intent this event belongs to. null only for test.ping
clientReferencestring | nullYour 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
sequencenumberStarts at 1 per Intent and increases contiguously. A gap means a missed event (see The Intent object)
data.objectobjectSnapshot 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" }
}
FieldTypeDescription
idstringThe Intent ID, equal to the envelope intentId
statusstringOverall 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
inputobjectFunds coming in: asset, amountMinor, exponent, chain
outputobjectFunds going out: currency, amountMinor, exponent
beneficiaryobjectRecipient, masked per the Receipts API schema: nameMasked, accountLast4, accountHash, optional bank
lockobject | nullHTLC escrow lock: txHash, timelockExpiresAt. null before the lock exists
offrampobject | nullOff-ramp conversion, USDC→payout currency. Carries its own status plus fromAsset/fromAmountMinor/toCurrency/toAmountMinor/fxRate/offrampId (the conversion's reference). null before it starts
payoutobject | nullPayout 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
failureobject | nullPresent when status is failed{ stage, code, message, retryable }, the same block the Status API returns. null otherwise
fundsStateobjectCurrent 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 apiVersion change. 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.
  • intentId and sequence semantics are fixed: sequence is contiguous within its intentId channel. If event families outside the transaction lifecycle are added later, they will use additional identifier fields rather than changing these.
  • sequence counts 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 (initiatedcompleted). 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 .

TypeFires whenstatusofframp.statuspayout.status
intent.acceptedvalidation passes, solver assignedintent.accepted
offramp.initiatedoff-ramp conversion startsofframp.initiatedinitiated
offramp.in_progressasync provider processing the conversion (may be skipped)offramp.in_progressin_progress
offramp.completedconversion confirmed (offrampId, final amount)offramp.completedcompleted
payout.initiatedpayout to the beneficiary startspayout.initiatedinitiated
payout.in_progressasync partner disbursement underway (may be skipped)payout.in_progressin_progress
payout.completedbeneficiary confirmed paid; receipt carries the on-chain receipt anchor (txHash) once confirmedpayout.completedcompleted
offramp.failedconversion fails (failure.stage: "offramp")offramp.failedfailed
payout.failedpayout fails (failure.stage: "payout")payout.failedfailed
intent.cancelledcancelled before completionintent.cancelled
intent.expiredthe intent deadline passes before executionintent.expired
intent.failedfails 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 statusdata.object.status, the transaction overall:

StatusMeaning
intent.acceptedIntent accepted, settlement plan created — legs not started
offramp.initiated / offramp.in_progress / offramp.completedThe off-ramp leg is active; the suffix is offramp.status
payout.initiated / payout.in_progressThe payout leg is active; the suffix is payout.status
payout.completedFully settled, receipt confirmed (terminal ✓)
intent.failed / offramp.failed / payout.failedTerminal failure — the prefix is the failing stage (equals failure.stage); the failure block carries code/retryable
intent.cancelledCancelled before completion (terminal ✕)
intent.expiredThe intent deadline passed before execution (terminal ✕)

Off-ramp statusdata.object.offramp.status, the USDC→USD conversion:

StatusMeaning
initiatedoff-ramp started
in_progressconversion underway
completedconversion confirmed (offrampId, final amount)
failedconversion failed

Payout statusdata.object.payout.status, delivery to the beneficiary account:

StatusMeaning
initiatedpayout started
in_progresspartner disbursement underway
completedpayout confirmed, funds received (receipt)
failedpayout failed

in_progress on a leg appears only when the provider processes it asynchronously; a synchronous provider jumps initiatedcompleted, 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/events

Because 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/events

Each 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

ParameterTypeRequiredDefaultDescription
sincestringNo-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
typestringNo-filter by event type, e.g. payout.completed
intentIdstringNo-filter by Intent
limitnumberNo20items 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"
}
FieldTypeDescription
dataarrayevents in the common envelope, ascending by internal cursor
hasMorebooleanwhether another page exists
nextCursorstringpass 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_endpoints

Returns 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
FieldTypeRequiredDescription
urlstringYesreceiving 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
subscribedTypesstring[]Yesevent 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_endpoints

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

FieldTypeRequiredDescription
urlstringNonew receiving URL, same rules as registration
subscribedTypesstring[]Noreplaces the subscription list
isActivebooleanNoset 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/rotate

Issues 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/test

Sends 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}/resend

Re-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 previous nextSecret), 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"
  }
}
HTTPCodesMeaning
400INVALID_CURSOR, INVALID_TYPE, INVALID_URLmalformed request parameters, or a rejected endpoint URL
401MISSING_API_KEY, INVALID_API_KEYauthentication failed
403API_KEY_DISABLEDkey disabled
404NOT_FOUNDno such resource, or aged out of retention
409ALREADY_EXISTS, IDEMPOTENCY_CONFLICTduplicate registration, or an Idempotency-Key reused with a different body
429RATE_LIMIT_EXCEEDEDtoo many requests. A Retry-After header gives the seconds to wait; the concrete limit is in the overview
500 / 503INTERNAL_ERROR, SERVICE_UNAVAILABLEtransient server error. Reads are safe to retry; retry writes with the same Idempotency-Key

Integrating your receiver

  1. Verify the signature. Four steps and copy-paste code in Signature. Always use the raw body bytes.
  2. Deduplicate on id. Retries, resends, and backfill all carry the same id.
  3. Order and detect gaps by sequence. Arrival order is not guaranteed, and a skipped state can be normal; only a sequence gap is a missed event.
  4. Respond fast. Store, return 2xx, process asynchronously.
  5. Read data.object for the current snapshot. Its three status fields (Transaction states) hold the Intent, off-ramp, and payout states.
  6. Stay forward-compatible. Ignore unknown event types and unknown fields.
  7. Filter on livemode. Production handlers should process only livemode: true.
  8. Reconcile periodically. List Events over a recent window to catch any Intent your endpoint never saw (a sequence gap cannot reveal a fully-missed Intent).