Dino sends signed HTTP POST requests to your registered endpoints when a spend request transitions state. Use webhooks to wake your agent runtime after a human approval decision instead of polling GET /v1/spend/:id.
#Events
| Event type | When it fires |
|---|---|
spend_request.created | Spend request first recorded |
spend_request.approved | Auto-approved or operator approved |
spend_request.declined | Declined by policy or operator |
spend_request.cancelled | Cancelled by operator or expiry |
#Register an endpoint
In the Dino dashboard under Developer → Webhooks, click Add endpoint and provide:
- URL — publicly reachable HTTPS endpoint that accepts
POST - Event types — leave empty to receive all spend events, or select specific ones
- Description — optional label for your own reference
After saving, Dino shows the signing secret (whsec_...) once. Copy it and store it in your secret manager. It is not recoverable after you close the dialog.
#Payload shape
{
"id": "evt_01jwx4k2p0000000000000000",
"type": "spend_request.approved",
"occurred_at": "2026-05-07T12:34:56.000Z",
"request_id": "req_trace_01jwx4k2p0000000000000000",
"data": {
"spend": {
"id": "req_01jwx4k2p0000000000000000",
"team_id": "team_01jwx4k2p0000000000000000",
"agent_account_id": "agent_01jwx4k2p0000000000000000",
"status": "approved",
"decision_reason": "Approved by operator",
"amount_cents": 12000,
"currency": "usd",
"merchant_name": "AWS",
"reason": "GPU runtime for evaluation job",
"decided_at": "2026-05-07T12:34:56.000Z",
"created_at": "2026-05-07T12:30:00.000Z",
"updated_at": "2026-05-07T12:34:56.000Z"
}
}
}
#Request headers
| Header | Value |
|---|---|
Dino-Event-Id | Unique event ID — use this as your idempotency key |
Dino-Event-Type | Event type string |
Dino-Delivery-Attempt | 1 on first try, increments on retries |
Dino-Request-Id | Dino trace ID when available |
x-dino-signature-sha256 | hex(HMAC_SHA256(secret, body)) |
Dino-Signature | t={unix_ts},v1={hex(HMAC_SHA256(secret, "{ts}.{body}"))} |
#Verify signatures
Always verify both signing headers against the raw request body before parsing JSON. Use the raw bytes exactly as received — do not re-serialize or pretty-print.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyDinoWebhookSignature(params: {
rawBody: string;
secret: string;
signedHeader: string | null; // Dino-Signature
sha256Header: string | null; // x-dino-signature-sha256
now?: Date;
maxAgeSeconds?: number;
}): boolean {
if (!params.signedHeader || !params.sha256Header) return false;
// Parse the Stripe-style header: t=<timestamp>,v1=<hex>
const parts = Object.fromEntries(
params.signedHeader.split(",").map((p) => p.split("=") as [string, string])
);
const timestamp = Number(parts.t);
const v1 = parts.v1;
if (!Number.isInteger(timestamp) || timestamp <= 0 || !v1) return false;
// Reject stale messages (default: 5 minutes)
const maxAge = params.maxAgeSeconds ?? 300;
const ageSeconds = Math.abs(((params.now ?? new Date()).getTime()) - timestamp * 1000) / 1000;
if (ageSeconds > maxAge) return false;
const expectedSha256 = createHmac("sha256", params.secret)
.update(params.rawBody)
.digest("hex");
const expectedV1 = createHmac("sha256", params.secret)
.update(`${timestamp}.${params.rawBody}`)
.digest("hex");
const toBuffer = (hex: string) => Buffer.from(hex, "hex");
const sha256Match =
toBuffer(expectedSha256).length === toBuffer(params.sha256Header).length &&
timingSafeEqual(toBuffer(expectedSha256), toBuffer(params.sha256Header));
const v1Match =
toBuffer(expectedV1).length === toBuffer(v1).length &&
timingSafeEqual(toBuffer(expectedV1), toBuffer(v1));
return sha256Match && v1Match;
}
Return HTTP 200 (any 2xx) as fast as possible. Do any slow work asynchronously after acknowledging the delivery.
#Minimal receiver
export async function handleDinoWebhook(
request: Request,
secret: string,
): Promise<Response> {
const rawBody = await request.text();
const ok = verifyDinoWebhookSignature({
rawBody,
secret,
signedHeader: request.headers.get("Dino-Signature"),
sha256Header: request.headers.get("x-dino-signature-sha256"),
});
if (!ok) {
return new Response(JSON.stringify({ ok: false }), { status: 401 });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case "spend_request.approved":
// wake the waiting agent or mark the spend as cleared
break;
case "spend_request.declined":
case "spend_request.cancelled":
// notify the agent the spend will not proceed
break;
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
A complete runnable example is in packages/agent-spend-wrapper-example/src/receiver-example.ts.
#Retry schedule
Dino retries failed deliveries with exponential backoff. A delivery is considered failed if your endpoint returns a non-2xx status, times out (10 seconds), or is unreachable.
| Attempt | Delay before retry |
|---|---|
| 1 | immediate |
| 2 | ~1 minute |
| 3 | ~4 minutes |
| 4 | ~16 minutes |
| 5 | ~64 minutes (~1 hour) |
| 6 | ~256 minutes (~4 hours) |
| 7 | ~17 hours |
| 8 | ~3 days |
After 8 failed attempts the delivery is marked failed_permanently and will not retry automatically. You can trigger a manual redeliver from the dashboard under Developer → Webhooks → Deliveries.
The Dino-Delivery-Attempt header tells you which attempt number is currently being delivered. Use Dino-Event-Id to deduplicate retried deliveries — the same event ID is used across all retry attempts for the same event.
#Event ordering
Dino does not guarantee that events arrive in the order they occurred. A delivery failure on one event may cause it to arrive after a later event for the same spend request.
To reconstruct the canonical sequence:
- Use
Dino-Event-Idto deduplicate (idempotent handler). - Use
occurred_atto order events you have already received. - For authoritative status, call
GET /v1/spend/:id— it always reflects the current state of the spend request.
#Polling as fallback
If your endpoint is unreachable or during initial setup before you have a registered endpoint, fall back to polling:
curl -sS "https://api.dino.id/v1/spend/req_01jwx4k2p0000000000000000" \
-H "Authorization: Bearer YOUR_DINO_SPEND_KEY"
Recommended polling strategy for needs_approval:
- Poll every 30–60 seconds.
- Stop after the status becomes terminal (
approved,declined,cancelled,failed,expired). - Cap total polling time to your agent's session timeout.
Webhooks are the preferred mechanism once configured. Polling is the correct fallback when webhooks are not yet set up or when you need to recover from a missed delivery.
#Rotate or revoke a signing secret
If your signing secret is compromised:
- In the dashboard under Developer → Webhooks, find the endpoint.
- Click Rotate secret.
- Update your environment with the new
whsec_...value. - Deliveries signed with the old secret will start failing verification — update your verifier before rotating in production.