Pellet Docs

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

MethodPathNotes
POST/api/webhooksCreate. Returns signing_secret once.
GET/api/webhooksList. No secrets.
GET/api/webhooks/[id]Detail with delivery counts.
POST/api/webhooks/[id]/verifyBody { verify_token }. Flips status to active.
POST/api/webhooks/[id]/rotate-secretReturns new secret once. Hard cutover.
POST/api/webhooks/[id]/pauseOwner-initiated pause.
POST/api/webhooks/[id]/resumeRe-arms; resets consecutive_failures = 0.
DELETE/api/webhooks/[id]Soft-delete (status='deleted').
GET/api/webhooks/[id]/deliveriesPaginated delivery log.
POST/api/webhooks/[id]/deliveries/[deliveryId]/redeliverForce 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

HeaderNotes
Pellet-DeliveryUUID stable across retries — use it to dedupe.
Pellet-SubscriptionThe id of the subscription receiving this event.
Pellet-Event-IdThe agent_events.id — also in data.id.
Pellet-Signaturet=<unix_seconds>,v1=<hex_hmac_sha256>.
User-AgentPellet-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:

AttemptDelay before next
130 seconds
22 minutes
310 minutes
430 minutes
52 hours
66 hours
712 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

CodeOutcome
2xxsuccess; consecutive_failures = 0.
410subscription paused, delivery dead.
429, 5xxretry with backoff.
Network/timeoutretry with backoff.
Other 4xxdelivery 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

Source