Agent Spend Webhooks

Receive push notifications when spend requests are approved, declined, or cancelled, instead of polling for status.

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 typeWhen it fires
spend_request.createdSpend request first recorded
spend_request.approvedAuto-approved or operator approved
spend_request.declinedDeclined by policy or operator
spend_request.cancelledCancelled 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

HeaderValue
Dino-Event-IdUnique event ID — use this as your idempotency key
Dino-Event-TypeEvent type string
Dino-Delivery-Attempt1 on first try, increments on retries
Dino-Request-IdDino trace ID when available
x-dino-signature-sha256hex(HMAC_SHA256(secret, body))
Dino-Signaturet={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.

AttemptDelay before retry
1immediate
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:

  1. Use Dino-Event-Id to deduplicate (idempotent handler).
  2. Use occurred_at to order events you have already received.
  3. 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:

  1. In the dashboard under Developer → Webhooks, find the endpoint.
  2. Click Rotate secret.
  3. Update your environment with the new whsec_... value.
  4. Deliveries signed with the old secret will start failing verification — update your verifier before rotating in production.