What you receive
Every delivery is aPOST with a JSON body and three Leash headers:
prepared → submitted → confirmed | failed). The
delivery row is unique on (webhook_id, event_id) so duplicates from
internal retries never reach you.
Verifying signatures
X-Leash-Signature follows the Stripe-compatible t=<unix>,v1=<hex>
format. The signed payload is ${t}.${rawBody} (concatenate the
timestamp, a literal dot, and the request body before HMAC-ing).
verifySignature helpers in the generated polyglot
SDKs are on the roadmap and not yet
shipped — for now, copy the snippet above into your service.
Reject deliveries with a timestamp older than ~5 minutes — that’s the
default tolerance the API uses on the verify path and protects against
replay if your webhook URL ever leaks alongside a captured request.
Subscribing
The secret is returned exactly once. Store it the moment the
response lands; subsequent GET calls omit it on purpose. Rotate by
deleting and re-creating the subscription.
events: [] (or omitting the field) subscribes to every event
kind for the network the API key is bound to. For the full list of
kinds with the API or on-chain trigger that produces each one, see
Explorer tracking. The summary version:
| Kind | When it fires |
|---|---|
agent.identity.register | A new agent identity is registered on chain. |
agent.executive.register | An executive wallet is bound to the cluster. |
agent.executive.delegate | An agent delegates execution to an executive. |
agent.delegation.set | Owner sets an SPL spend delegation for the executive. |
agent.delegation.revoke | Owner revokes the spend delegation. |
agent.treasury.provision | Treasury ATAs are pre-created. |
agent.treasury.withdraw | Owner withdraws SPL from the treasury. |
agent.treasury.withdraw_sol | Owner withdraws lamports from the treasury. |
agent.treasury.fund | Indexer observed an incoming SPL transfer to the treasury PDA. |
agent.treasury.fund_sol | Indexer observed an incoming SOL transfer to the treasury PDA. |
agent.token.set | Identity’s agent_token field is updated. |
submit.raw | Any signed transaction broadcast through /v1/submit. |
receipt.published | A new receipt was pushed to the API (or forwarded by the runner). |
receipt.pulled | The receipt-pull worker ingested a receipt from a registered URL. |
Listing, fetching, deleting
GET and LIST never include the secret — only the original POST
response does. A 404 on cross-key lookup confirms subscriptions
are scoped per API key.
Delivery history
Retries
The worker schedules retries with exponential backoff:| Attempt | Delay before next try |
|---|---|
| 1 | 5 seconds |
| 2 | 25 seconds |
| 3 | ~2 minutes |
| 4 | ~10 minutes |
| 5+ | capped at 1 hour |
delivered=true with last_status=-1 so it stops being
retried — but it stays in the deliveries history so you can see the
failure. Re-create the subscription to drain backlogs.
Any 2xx status counts as success. 4xx and 5xx are both retried
the same way; we don’t distinguish “permanent” client errors because
intermittent gateway misbehaviour is too common to special-case. If
you want to drop a delivery on the floor, return 200 and discard.
Best-effort guarantees
- At-least-once — your endpoint may receive the same event twice
(different delivery ids, same
event.id). Idempotency is your responsibility; key onevent.idandevent.phase. - Out-of-order possible — phase
submittedmay land before phasepreparedon a slow receiver. Always reconcile against the latest state viaGET /v1/events/{id}if order matters. - Network-isolated — a
lsh_test_*key only ever delivers devnet events, even if you accidentally point it at a mainnet URL.
Testing your receiver
Spin up anngrok http <port> (or use webhook.site)
and POST /v1/webhooks with the public URL. Run a prepare +
submit against any agent and you’ll see deliveries land within a
few seconds. Inspect GET /v1/webhooks/{id}/deliveries to confirm
attempts, status codes, and signing headers — re-create the
subscription with a different secret if you suspect leakage.
