Webhooks API
Register endpoints to receive events instead of polling.
| Method | Path | Scope |
|---|---|---|
POST | /v1/webhook-endpoints | webhook-endpoints:write |
GET | /v1/webhook-endpoints | webhook-endpoints:read |
GET | /v1/webhook-endpoints/{id} | webhook-endpoints:read |
PATCH | /v1/webhook-endpoints/{id} | webhook-endpoints:write |
POST | /v1/webhook-endpoints/{id}/rotate-secret | webhook-endpoints:write |
DELETE | /v1/webhook-endpoints/{id} | webhook-endpoints:write |
Register an endpoint
POST /v1/webhook-endpoints
{ "url": "https://app.example.com/webhooks/zenvault", "eventTypes": ["transfer.*", "address.received"] }
eventTypes filters what's delivered: empty/omitted means all events;
entries match exactly or as a prefix wildcard (transfer.* matches
transfer.completed, transfer.failed, …).
The response includes oneTimeSecret — the HMAC signing key, returned exactly
once. Store it; you verify every delivery with it. rotate-secret issues a new
one and invalidates the old.
:::warning HTTPS required
Webhook URLs must use HTTPS — plaintext http:// is rejected with 400 on
create/update for production workspaces (which today is every workspace).
:::
Event types
| Type | Fires when |
|---|---|
transfer.created | Transfer recorded |
transfer.signing | Being signed |
transfer.broadcasting | Submitted to the network |
transfer.confirming | Seen on-chain, awaiting confirmations |
transfer.completed | Confirmed / final |
transfer.failed | Failed |
transfer.cancelled | Cancelled |
transfer.timed_out | Timed out |
address.received | An inbound deposit to one of your addresses confirmed |
gas.low | Gas wallet dropped below the refill threshold (Gas) |
gas.depleted | Gas wallet can't fund top-ups — web3 withdrawals will stall |
Delivery payload
Every delivery is a POST with this envelope:
{
"id": "evt_3f2a...uuid",
"type": "transfer.completed",
"occurred_at": "2026-05-27T10:12:00.000Z",
"workspace_id": "...",
"data": { ... }
}
data for transfer.*:
{
"id": "d91a...uuid",
"kind": "external",
"state": "completed",
"asset_canonical": "USDT_ETHEREUM",
"amount": "250.00",
"src_account_id": "8f1c...",
"dest_account_id": null,
"dest_external_address": "0xfeed...beef"
}
data for address.received (emitted once, on confirmation):
{
"transfer_id": "a17c...uuid",
"destination_account_id": "8f1c...",
"destination_address": "0x9a8b...c3d4",
"asset_canonical": "USDT_ETHEREUM",
"amount": "125.50",
"tx_hash": "0x6f4a..."
}
Headers & retries
| Header | Value |
|---|---|
zenvault-event-id | evt_<uuid> — stable across retries; dedupe on it |
zenvault-event-type | e.g. transfer.completed |
zenvault-delivery-attempt | 1, 2, … |
zenvault-signature | t=<unix-seconds>,v1=<hex-hmac> |
Respond 2xx to acknowledge. Any non-2xx (or timeout — we wait 15s) is retried
with jittered exponential backoff (capped at 6h) up to 18 attempts over
~3 days, after which the delivery is marked dead.
:::info At-least-once, unordered
Deliveries are at-least-once and unordered. Make handlers idempotent (dedupe
on zenvault-event-id) and treat the transfer state machine as the source of
truth rather than assuming events arrive in order.
:::
Always verify the signature — see Verify webhooks.