> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prismgateway.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Usage webhooks

> Receive every billed call in your own systems, and verify it is really from Prism

# Usage webhooks

Prism can POST a signed event to your endpoint for every billed call your
organization makes. Use it to reconcile spend, drive internal chargeback, or feed
your own dashboards without polling the usage API.

Set endpoints up yourself in the console under **Team → Usage webhooks**. Prism
generates the signing secret and shows it once at creation.

## What arrives

One `POST` per usage event, `content-type: application/json`:

```json theme={null}
{
  "type": "usage.event.created",
  "data": {
    "event_id": "0f7c…",
    "occurred_at": "2026-08-12T09:14:22.104Z",
    "org_id": "9b2e…",
    "api_key_id": "3ad1…",
    "public_model_id": "openai/gpt-5.5",
    "channel": "chat",
    "billed_units": { "input_tokens": 812, "output_tokens": 241 },
    "cost_credits_minor": 143,
    "upstream_cost_micro_usd": 21400,
    "request_id": "req_…",
    "cache_hit": false,
    "status": "ok"
  }
}
```

`cost_credits_minor` is in minor units — `143` is ฿1.43.

Headers:

```text theme={null}
webhook-id:        <unique per delivery>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64 hmac-sha256>
x-prism-event-type: usage.event.created
```

This is the [Standard Webhooks](https://www.standardwebhooks.com/) envelope, so
an existing Standard Webhooks verifier works unchanged.

## Verifying

The signed message is `{webhook-id}.{webhook-timestamp}.{raw body}`.

<Warning>
  Sign the **raw request body**, before any JSON parsing. Re-serialising a parsed
  object changes key order and whitespace, and the signature will not match.
</Warning>

<CodeGroup>
  ```ts Node / Bun theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyPrismWebhook(headers, rawBody, secret) {
    const id = headers["webhook-id"];
    const ts = headers["webhook-timestamp"];
    const sigHeader = headers["webhook-signature"] ?? "";

    // Bound replay first — a valid signature on a six-month-old body is still a replay.
    if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = createHmac("sha256", key)
      .update(`${id}.${ts}.${rawBody}`)
      .digest("base64");

    // The header may carry several space-separated signatures during secret rotation.
    return sigHeader.split(" ").some((part) => {
      if (!part.startsWith("v1,")) return false;
      const got = Buffer.from(part.slice(3), "utf8");
      const want = Buffer.from(expected, "utf8");
      return got.length === want.length && timingSafeEqual(got, want);
    });
  }
  ```

  ```python Python theme={null}
  import base64, hmac, hashlib, time

  def verify_prism_webhook(headers, raw_body: bytes, secret: str) -> bool:
      wid = headers.get("webhook-id", "")
      ts = headers.get("webhook-timestamp", "")
      sig_header = headers.get("webhook-signature", "")

      try:
          if abs(time.time() - int(ts)) > 300:
              return False
      except ValueError:
          return False

      key = base64.b64decode(secret.removeprefix("whsec_"))
      message = f"{wid}.{ts}.".encode() + raw_body
      expected = base64.b64encode(hmac.new(key, message, hashlib.sha256).digest()).decode()

      for part in sig_header.split(" "):
          if part.startswith("v1,") and hmac.compare_digest(part[3:], expected):
              return True
      return False
  ```

  ```go Go theme={null}
  package prism

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  func Verify(h http.Header, rawBody []byte, secret string) bool {
  	id := h.Get("webhook-id")
  	ts := h.Get("webhook-timestamp")

  	sec, err := strconv.ParseInt(ts, 10, 64)
  	if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
  		return false
  	}

  	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
  	if err != nil {
  		return false
  	}
  	mac := hmac.New(sha256.New, key)
  	mac.Write([]byte(id + "." + ts + "."))
  	mac.Write(rawBody)
  	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

  	for _, part := range strings.Split(h.Get("webhook-signature"), " ") {
  		if strings.HasPrefix(part, "v1,") && hmac.Equal([]byte(part[3:]), []byte(expected)) {
  			return true
  		}
  	}
  	return false
  }
  ```
</CodeGroup>

## Responding

Return any `2xx` as soon as you have stored the event. Do the work afterwards —
a slow handler burns your retry budget.

Anything else is treated as a failure and retried with exponential backoff
(1 min, 2, 4, 8 … capped at 1 hour) until the attempt limit, after which the
delivery is marked `dead` and shown in the console with the last error.

## Idempotency

Retries mean the same `event_id` can arrive more than once. Treat `event_id` as
the primary key on your side and ignore duplicates — do not add to a running
total on every delivery.

## Ordering

Deliveries are not ordered. A retried event can arrive after a newer one. Use
`occurred_at` rather than arrival order when sequence matters.

## Rotating the secret

The secret is shown once and stored encrypted; Prism cannot show it to you again.
To rotate, add a second endpoint with the same URL, deploy a receiver that
accepts either secret, then remove the old endpoint.
