Webhooks
Subscribe to agent_events and get HMAC-signed POSTs the moment matching activity hits Tempo. Recipient-address filter in v1, retry queue with exponential backoff, and Stripe-style signatures.
POST /api/webhooks registers a callback. Every agent_events row that matches your filter produces an HMAC-signed POST. v1 filters on agent_id (required) plus optional recipient_address, routed_to_address, min_amount_wei, and token_address. Memo-prefix filtering lands in v1.1.
Endpoints
| Method | Path | Notes |
|---|---|---|
| POST | /api/webhooks | Create. Returns signing_secret once. |
| GET | /api/webhooks | List. No secrets. |
| GET | /api/webhooks/[id] | Detail with delivery counts. |
| POST | /api/webhooks/[id]/verify | Body { verify_token }. Flips status to active. |
| POST | /api/webhooks/[id]/rotate-secret | Returns new secret once. Hard cutover. |
| POST | /api/webhooks/[id]/pause | Owner-initiated pause. |
| POST | /api/webhooks/[id]/resume | Re-arms; resets consecutive_failures = 0. |
| DELETE | /api/webhooks/[id] | Soft-delete (status='deleted'). |
| GET | /api/webhooks/[id]/deliveries | Paginated delivery log. |
| POST | /api/webhooks/[id]/deliveries/[deliveryId]/redeliver | Force a retry attempt. |
Authentication
Same as the rest of the wallet API. Either:
Authorization: Bearer <token>from a paired CLI / agent session, OR- the browser passkey cookie set by the wallet UI sign-in flow.
The same wallet user can own up to 25 active subscriptions (status != 'deleted').
Filter shape
type WebhookFilter = {
agent_id: string; // REQUIRED, must reference an active agent
recipient_address?: string; // lowercase 0x + 40 hex; matches counterparty_address
routed_to_address?: string; // lowercase 0x + 40 hex
min_amount_wei?: string; // bigint as string
token_address?: string; // lowercase 0x + 40 hex
};ANDed. No regex, no OR. recipient_address indexes via a Postgres expression index, so adding it doesn't slow your dispatch.
Create
curl -sS https://pellet.network/api/webhooks \
-H "Authorization: Bearer $PELLET_BEARER" \
-H "content-type: application/json" \
-d '{
"callback_url": "https://example.com/webhooks/pellet",
"label": "alerts → ops",
"filters": {
"agent_id": "tempo-gateway-mpp",
"min_amount_wei": "1000000"
}
}'Response (201 Created):
{
"ok": true,
"id": "9b2c…",
"status": "pending_verify",
"callback_url": "https://example.com/webhooks/pellet",
"filters": { "agent_id": "tempo-gateway-mpp", "min_amount_wei": "1000000" },
"label": "alerts → ops",
"signing_secret": "f4e8b3…",
"verify_token": "8a91…",
"verify_token_expires_at": "2026-05-01T15:09:00Z",
"created_at": "2026-04-30T15:09:00Z"
}signing_secret is returned once. Store it before you close the response. The verification ping fires immediately after this response goes out.
callback_url must use https:// in production. http://localhost:* is allowed only when NODE_ENV !== 'production'.
Verify
The verification ping POSTs:
{
"type": "pellet.webhook.verify",
"verify_token": "8a91…",
"subscription_id": "9b2c…"
}Echo it back to flip the subscription to active:
curl -sS https://pellet.network/api/webhooks/9b2c…/verify \
-H "Authorization: Bearer $PELLET_BEARER" \
-H "content-type: application/json" \
-d '{"verify_token": "8a91…"}'Until the echo lands, dispatch skips this subscription. The token expires after 24h.
Event payload
{
"type": "pellet.event.v1",
"id": "<delivery_id>",
"delivered_at": "2026-04-30T15:09:00Z",
"subscription_id": "9b2c…",
"data": {
"id": 4821,
"ts": "2026-04-30T15:08:59Z",
"agent_id": "tempo-gateway-mpp",
"agent_label": "Tempo MPP Gateway",
"agent_category": "gateway",
"counterparty_address": "0xabc…",
"counterparty_label": "Stargate",
"counterparty_category": null,
"kind": "transfer",
"amount_wei": "1000000",
"token_address": "0x20c0…3726",
"tx_hash": "0x0af5…",
"source_block": 123456,
"methodology_version": "v0.2",
"routed_to_address": "0x789…",
"routed_to_label": "Stargate Bridge",
"routed_fingerprint": null,
"explorer_url": "https://explore.testnet.tempo.xyz/tx/0x0af5…"
}
}Mirrors RecentEventRow field-for-field, snake_case, no memo field — that's v1.1.
Headers
| Header | Notes |
|---|---|
Pellet-Delivery | UUID stable across retries — use it to dedupe. |
Pellet-Subscription | The id of the subscription receiving this event. |
Pellet-Event-Id | The agent_events.id — also in data.id. |
Pellet-Signature | t=<unix_seconds>,v1=<hex_hmac_sha256>. |
User-Agent | Pellet-Webhooks/1.0. |
Verifying the signature
import { createHmac, timingSafeEqual } from "crypto";
function verify(secret: string, header: string, rawBody: string): boolean {
// Stripe-style: "t=…,v1=…". The signed string is `${t}.${rawBody}`.
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return false; // tolerance window: 5 min
}
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest();
let received: Buffer;
try {
received = Buffer.from(parts.v1, "hex");
} catch {
return false;
}
return expected.length === received.length && timingSafeEqual(expected, received);
}Always verify against the raw request body — JSON re-serialization will scramble whitespace and break the HMAC.
Retry schedule
Non-2xx responses (other than 410 Gone) and network errors retry on this curve, with ±20% jitter on each step:
| Attempt | Delay before next |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | (dead) |
A retry cron runs every minute and walks the queue. After 5 consecutive failures on the subscription, status flips to disabled_by_failures and dispatch stops. POST /resume clears the counter.
Status code rules
| Code | Outcome |
|---|---|
2xx | success; consecutive_failures = 0. |
410 | subscription paused, delivery dead. |
429, 5xx | retry with backoff. |
| Network/timeout | retry with backoff. |
Other 4xx | delivery dead. Does not bump consecutive_failures (the payload is the problem, not your server). |
Idempotency contract
A given (subscription_id, agent_events.id) pair produces at most one delivery row. The bus listener and the inline match-runner hook both attempt to insert; the unique index makes the double-call safe.
Pellet-Delivery is stable across retries — dedupe on it. If you see the same delivery_id twice, the prior run didn't 2xx and we're retrying the same event.
End-to-end example
# 1. Subscribe.
curl -sS https://pellet.network/api/webhooks \
-H "Authorization: Bearer $PELLET_BEARER" \
-H "content-type: application/json" \
-d '{
"callback_url": "https://my-tunnel.example.com/pellet",
"filters": { "agent_id": "tempo-gateway-mpp" },
"label": "all gateway activity"
}'
# → { id: "9b2c…", signing_secret: "f4e8…", verify_token: "8a91…" }
# 2. Receiver gets the verify ping. Echo the token.
curl -sS https://pellet.network/api/webhooks/9b2c…/verify \
-H "Authorization: Bearer $PELLET_BEARER" \
-H "content-type: application/json" \
-d '{"verify_token": "8a91…"}'
# → { ok: true, status: "active" }
# 3. Sit back. Every matching event POSTs to your callback within seconds.
# 4. Need to roll the secret?
curl -sS -X POST https://pellet.network/api/webhooks/9b2c…/rotate-secret \
-H "Authorization: Bearer $PELLET_BEARER"
# → { signing_secret: "<new>" } // hard cutover, no overlap window