The Leash API can push every event it writes to a URL you control, so you don’t have to poll. Subscriptions are scoped to your API key (and therefore network), HMAC-signed end-to-end, and retried with exponential backoff if your endpoint is down.

What you receive

Every delivery is a POST with a JSON body and three Leash headers:
POST https://your-app.example.com/leash-hook
Content-Type: application/json
X-Leash-Signature: t=1745446272,v1=8f3c…
X-Leash-Event: event
X-Leash-Delivery: 01HVTQX4GZTH8XK1F2JZ7N5WJ4

{
  "type": "event",
  "event": {
    "id": "01HVTQX4GZTH8XK1F2JZ7N5WJ4",
    "ts": "2026-04-23T12:01:02.000Z",
    "kind": "agent.delegation.set",
    "phase": "confirmed",
    "network": "solana-devnet",
    "client_reference": "order-42",
    "agent_asset": "9pK9…",
    "signature": "5xY7…",
    "mint": "EPjF…",
    "amount_atomic": "100000000",
    "metadata": { "stable_symbol": "USDC", "allowance_atomic": "100000000" },
    "error_code": null,
    "error_message": null
  }
}
The same event id may be delivered up to four times — once per phase transition (preparedsubmittedconfirmed | 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).
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret: string, header: string, rawBody: string): boolean {
  const map = Object.fromEntries(header.split(',').map((s) => s.split('=') as [string, string]));
  if (!map.t || !map.v1) return false;
  const expected = createHmac('sha256', secret).update(`${map.t}.${rawBody}`).digest('hex');
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(map.v1, 'hex'));
}
First-party 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

curl -X POST https://api.leash.market/v1/webhooks \
  -H "Authorization: Bearer $LEASH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/leash-hook",
    "events": ["receipt.published", "agent.treasury.withdraw"]
  }'
{
  "id": "01HVTQX4GZTH8XK1F2JZ7N5WJ4",
  "network": "solana-devnet",
  "url": "https://your-app.example.com/leash-hook",
  "events": ["receipt.published", "agent.treasury.withdraw"],
  "secret": "whsec_5b1a3e8c…",
  "disabled_at": null,
  "created_at": "2026-04-23T12:01:02Z"
}
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:
KindWhen it fires
agent.identity.registerA new agent identity is registered on chain.
agent.executive.registerAn executive wallet is bound to the cluster.
agent.executive.delegateAn agent delegates execution to an executive.
agent.delegation.setOwner sets an SPL spend delegation for the executive.
agent.delegation.revokeOwner revokes the spend delegation.
agent.treasury.provisionTreasury ATAs are pre-created.
agent.treasury.withdrawOwner withdraws SPL from the treasury.
agent.treasury.withdraw_solOwner withdraws lamports from the treasury.
agent.treasury.fundIndexer observed an incoming SPL transfer to the treasury PDA.
agent.treasury.fund_solIndexer observed an incoming SOL transfer to the treasury PDA.
agent.token.setIdentity’s agent_token field is updated.
submit.rawAny signed transaction broadcast through /v1/submit.
receipt.publishedA new receipt was pushed to the API (or forwarded by the runner).
receipt.pulledThe receipt-pull worker ingested a receipt from a registered URL.

Listing, fetching, deleting

# All subscriptions on the current key
curl https://api.leash.market/v1/webhooks \
  -H "Authorization: Bearer $LEASH_API_KEY"

# One subscription
curl https://api.leash.market/v1/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $LEASH_API_KEY"

# Delete and purge delivery history
curl -X DELETE https://api.leash.market/v1/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $LEASH_API_KEY"
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

curl https://api.leash.market/v1/webhooks/$WEBHOOK_ID/deliveries?limit=50 \
  -H "Authorization: Bearer $LEASH_API_KEY"
{
  "items": [
    {
      "id": "01HVTQX5…",
      "webhook_id": "01HVTQX4…",
      "event_id": "01HVTQX4GZTH8XK1F2JZ7N5WJ4",
      "attempts": 1,
      "delivered": true,
      "next_attempt_at": "2026-04-23 12:01:02",
      "last_status": 200,
      "last_error": null,
      "last_attempt_at": "2026-04-23 12:01:02",
      "created_at": "2026-04-23 12:01:01"
    }
  ]
}
Failed deliveries stay in this list for 7 days with the last status code and error message so you can debug receiver-side outages.

Retries

The worker schedules retries with exponential backoff:
AttemptDelay before next try
15 seconds
225 seconds
3~2 minutes
4~10 minutes
5+capped at 1 hour
After 8 failed attempts (~30 minutes total), the delivery is flagged as 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 on event.id and event.phase.
  • Out-of-order possible — phase submitted may land before phase prepared on a slow receiver. Always reconcile against the latest state via GET /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 an ngrok 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.