Webhooks
HMAC-signed event delivery for peg breaks, flow anomalies, and system health.
Pellet pushes events to your URL when something material happens — a peg breaks, a stable's flow spikes, or our own system drifts. Each delivery is HMAC-signed so you can verify it came from us.
Event types
| Event | When |
|---|---|
peg_break.started | A stable just crossed a peg threshold (mild >10bps for 5min, severe >50bps for 1min) |
peg_break.ended | A previously-ongoing peg break has resolved |
flow_anomaly.detected | Cross-stable flow exceeded 7-day baseline by ≥3σ in a 15-min window |
system.health_drift | Pellet's own ingestion fell behind chain head or peg sampling stalled |
Subscribing
Currently admin-only via API (self-serve UI coming with Pellet Pro). Endpoint:
curl -X POST https://pelletfi.com/api/admin/webhooks \
-H "Authorization: Bearer $YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Slack incident channel",
"url": "https://hooks.slack.com/services/T0.../B0.../...",
"event_types": ["peg_break.started", "peg_break.ended"],
"stable_filter": ["0x20c000000000000000000000b9537d11c60e8b50"]
}'Returns:
{
"id": "wh_a1b2c3d4e5f6...",
"secret": "whsec_a1b2c3...",
"url": "https://hooks.slack.com/...",
"event_types": ["peg_break.started", "peg_break.ended"]
}Save the secret — it's only returned once. You'll need it to verify signatures.
stable_filter is optional. Omit to subscribe to events across all stables.
Delivery
Pellet POSTs JSON to your URL with these headers:
Content-Type: application/json
X-Pellet-Signature: t=1712345678,v1=ab12cd34...
X-Pellet-Event: peg_break.started
X-Pellet-Delivery: 12345
User-Agent: Pellet-Webhook/1 (+https://pelletfi.com)Body:
{
"event": "peg_break.started",
"data": {
"stable": "0x20c000000000000000000000b9537d11c60e8b50",
"severity": "mild",
"started_at": "2026-04-14T11:20:00Z",
"max_deviation_bps": 14.3,
"started_block": 14710001
},
"delivery_id": 12345
}Verifying signatures
The signature is HMAC-SHA256 of {timestamp}.{body} with your subscription secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req: Request, body: string, secret: string): boolean {
const header = req.headers.get("x-pellet-signature");
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
);
const ts = parts.t;
const sig = parts.v1;
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${ts}.${body}`)
.digest("hex");
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Retries
A delivery is considered successful on any 2xx response. On any other response or timeout (10s), Pellet retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 2 | 30s |
| 3 | 2m |
| 4 | 10m |
| 5 | 1h |
| 6 | 6h |
| 7 | 24h |
After the 6th retry (7 total attempts), the delivery is marked failed and dropped.
Best practices
- Verify the signature on every request before processing.
- Respond fast — return 2xx quickly and process async if needed. Long handlers risk timing out.
- Handle replays —
delivery_idis unique per delivery; track it if your handler isn't idempotent. - Use HTTPS — webhooks to plain HTTP endpoints are accepted but discouraged.