# Webhooks (/docs/concepts/webhooks)



A **webhook endpoint** is an HTTPS URL of yours that GridRouter posts events to. Each endpoint
subscribes to the event types it wants, and every delivery is signed per the
[Standard Webhooks](https://www.standardwebhooks.com) spec, retried with backoff for about a day,
logged, and replayable.

Webhooks are for events. To stream every call to your own storage in batches, use a
[log drain](/docs/concepts/logs-and-drains#drains) instead.

## Endpoints [#endpoints]

Create an endpoint in Settings → **Alerts & integrations**, or with a key that has
`credentials:write`:

```bash
curl https://api.gridrouter.io/v1/webhooks/endpoints \
  -H "Authorization: Bearer $GRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/gridrouter",
    "description": "Production",
    "event_types": ["job.completed", "job.failed", "alert.triggered", "alert.resolved"]
  }'
```

The response includes the endpoint's signing `secret` (`whsec_…`). It is shown **once**; store it
in your secret manager. Later reads show only its first characters.

* **Subscriptions.** `event_types` lists the types delivered to the endpoint (up to 40).
  `["*"]` means every type, including types added later.
* **Status.** `active`; `failing` after 3 failed attempts in a row; `disabled` when you turn it
  off or GridRouter [disables it](#auto-disable).
* **Health.** Each endpoint reports deliveries, failures, consecutive failures, the last success
  and failure, and the last error (a status code or error class, never a response body).

### Alert events [#alert-events]

An endpoint subscribed to `alert.triggered` and `alert.resolved` (or `*`) receives your alerts
as they open and resolve. To send only some rules' alerts to an endpoint, connect it as a
**webhook destination** (`{ "type": "webhook", "endpoint_id": "whe_…" }`) and route those rules
to it; see [Integrations](/docs/concepts/integrations#webhooks). Alerts from muted rules send no
webhook events.

## The envelope [#the-envelope]

Every delivery is one JSON object:

```json
{
  "id": "evt_job_completed_01j9x7k2m9q4",
  "type": "job.completed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": { "job": { "object": "job", "id": "job_01j9x7k2m9q4w8x1", "status": "succeeded" } }
}
```

| Field         | Meaning                                                                         |
| ------------- | ------------------------------------------------------------------------------- |
| `id`          | The event id (`evt_…`), the same for every endpoint that receives it            |
| `type`        | One of the [event types](#event-reference)                                      |
| `api_version` | The payload version, currently `2026-10-01`                                     |
| `created_at`  | When the event happened (ISO 8601, UTC)                                         |
| `test`        | `true` on events sent with **Send test event**; absent otherwise                |
| `data`        | The event's payload; its shape per type is in the [reference](#event-reference) |

Payloads are versioned by `api_version`, and `GET /v1/webhooks/event-types` returns the current
version with every type and an example. Write receivers to ignore fields and event types they
don't recognize.

## Headers and signing [#headers-and-signing]

| Header              | Value                                                                          |
| ------------------- | ------------------------------------------------------------------------------ |
| `webhook-id`        | The delivery id (`msg_…`). The same on every retry and replay of this delivery |
| `webhook-timestamp` | Unix seconds when this attempt was signed                                      |
| `webhook-signature` | One or more space-separated `v1,<signature>`                                   |
| `content-type`      | `application/json`                                                             |
| `user-agent`        | `GridRouter-Webhooks/2.0`                                                      |

Each signature is the base64 HMAC-SHA256 of `` `${webhook-id}.${webhook-timestamp}.${body}` ``,
keyed by the base64-decoded bytes after `whsec_` in your secret. To verify:

1. Read the **raw** body exactly as received, before any JSON parsing.
2. Reject the request if `webhook-timestamp` is more than 5 minutes from your clock.
3. Compute the signature and compare it, in constant time, with each `v1,` entry in
   `webhook-signature`. Accept if any one matches.
4. Answer `2xx` within 15 seconds. Do slow work after you respond.

## Verify a delivery [#verify-a-delivery]

<Tabs items="[&#x22;TypeScript (SDK)&#x22;, &#x22;TypeScript (WebCrypto)&#x22;, &#x22;Node.js (crypto)&#x22;, &#x22;Python&#x22;, &#x22;openssl&#x22;]">
  <Tab value="TypeScript (SDK)">
    `verifyWebhook` from `@relaygrid/sdk` checks the timestamp (5-minute tolerance by default,
    `toleranceSec` to change it) and every signature in the header.

    ```ts
    import { verifyWebhook } from "@relaygrid/sdk";

    export async function POST(req: Request) {
      const body = await req.text();
      const ok = await verifyWebhook(body, req.headers, process.env.GRID_WEBHOOK_SECRET ?? "");
      if (!ok) return new Response("invalid signature", { status: 400 });

      const event = JSON.parse(body);
      const deliveryId = req.headers.get("webhook-id");
      // Skip deliveryId if you've already processed it, then handle event.type.
      return new Response(null, { status: 204 });
    }
    ```
  </Tab>

  <Tab value="TypeScript (WebCrypto)">
    For Workers, Deno, Bun or any runtime with WebCrypto. `crypto.subtle.verify` compares in
    constant time.

    ```ts
    const b64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));

    export async function verifyGridWebhook(
      body: string,
      headers: Headers,
      secret: string,
      toleranceSec = 300,
    ): Promise<boolean> {
      const id = headers.get("webhook-id");
      const ts = headers.get("webhook-timestamp");
      const sigs = headers.get("webhook-signature");
      if (!id || !ts || !sigs || !/^\d+$/.test(ts)) return false;
      if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;

      const key = await crypto.subtle.importKey(
        "raw",
        b64(secret.replace(/^whsec_/, "")),
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["verify"],
      );
      const signed = new TextEncoder().encode(`${id}.${ts}.${body}`);
      for (const sig of sigs.split(" ")) {
        if (!sig.startsWith("v1,")) continue;
        try {
          if (await crypto.subtle.verify("HMAC", key, b64(sig.slice(3)), signed)) return true;
        } catch {
          // Not valid base64: not a match.
        }
      }
      return false;
    }
    ```
  </Tab>

  <Tab value="Node.js (crypto)">
    With Express, mount the route with `express.raw({ type: "application/json" })` so `req.body` is
    the raw bytes.

    ```ts
    import { createHmac, timingSafeEqual } from "node:crypto";

    export function verifyGridWebhook(
      body: Buffer,
      headers: Record<string, string | string[] | undefined>,
      secret: string,
      toleranceSec = 300,
    ): boolean {
      const id = String(headers["webhook-id"] ?? "");
      const ts = String(headers["webhook-timestamp"] ?? "");
      const sigs = String(headers["webhook-signature"] ?? "");
      if (!id || !/^\d+$/.test(ts) || !sigs) return false;
      if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;

      const expected = createHmac("sha256", Buffer.from(secret.replace(/^whsec_/, ""), "base64"))
        .update(`${id}.${ts}.`)
        .update(body)
        .digest();
      return sigs.split(" ").some((sig) => {
        if (!sig.startsWith("v1,")) return false;
        const got = Buffer.from(sig.slice(3), "base64");
        return got.length === expected.length && timingSafeEqual(got, expected);
      });
    }
    ```
  </Tab>

  <Tab value="Python">
    Pass the raw body bytes (`request.get_data()` in Flask, `await request.body()` in FastAPI).

    ```python
    import base64
    import hashlib
    import hmac
    import time


    def verify_grid_webhook(body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
        msg_id = headers.get("webhook-id")
        ts = headers.get("webhook-timestamp")
        sigs = headers.get("webhook-signature")
        if not msg_id or not ts or not sigs or not ts.isdigit():
            return False
        if abs(time.time() - int(ts)) > tolerance:
            return False

        key = base64.b64decode(secret.removeprefix("whsec_"))
        signed = f"{msg_id}.{ts}.".encode() + body
        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
        return any(
            hmac.compare_digest(sig[3:], expected)
            for sig in sigs.split(" ")
            if sig.startswith("v1,")
        )
    ```
  </Tab>

  <Tab value="openssl">
    Compute a signature by hand, for example to send a signed request to your own receiver while you
    build it:

    ```bash
    SECRET='whsec_…'            # your endpoint's secret
    MSG_ID='msg_test0000000001'
    TS=$(date +%s)
    BODY='{"id":"evt_test_1","type":"job.completed","api_version":"2026-10-01","created_at":"2026-10-01T12:00:00.000Z","test":true,"data":{}}'

    KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
    SIG=$(printf '%s' "$MSG_ID.$TS.$BODY" \
      | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | base64)

    curl -X POST https://example.com/webhooks/gridrouter \
      -H "content-type: application/json" \
      -H "webhook-id: $MSG_ID" \
      -H "webhook-timestamp: $TS" \
      -H "webhook-signature: v1,$SIG" \
      --data-binary "$BODY"
    ```
  </Tab>
</Tabs>

## Rotate the secret [#rotate-the-secret]

`POST /v1/webhooks/endpoints/{id}/rotate-secret` issues a new secret and returns it once. The old
one keeps signing alongside it for `overlap_s` (default 24 hours, up to 7 days; `0` retires it at
once). During the overlap, `webhook-signature` carries two `v1,` signatures, the new secret's
first, so a receiver that still has the old secret keeps verifying while you deploy the new one.
The endpoint's `secrets` list shows both, with the old one's `expires_at`.

## Retries [#retries]

An attempt succeeds when your endpoint answers `2xx` within 15 seconds. Anything else is a failed
attempt and is retried: timeouts, network errors, `5xx`, `4xx` (receivers often return them while
deploying) and redirects, which are never followed. The only failure that isn't retried is a URL
that the [URL rules](#url-rules) now block.

| Attempt | Sent | Time since the first attempt |
| --- | --- | --- |
| 1 | When the event happens | 0 s |
| 2 | 5 seconds after attempt 1 failed | About 5 s |
| 3 | 5 minutes after attempt 2 failed | About 5 min 5 s |
| 4 | 30 minutes after attempt 3 failed | About 35 min 5 s |
| 5 | 2 hours after attempt 4 failed | About 2 h 35 min 5 s |
| 6 | 5 hours after attempt 5 failed | About 7 h 35 min 5 s |
| 7 | 8 hours after attempt 6 failed | About 15 h 35 min 5 s |
| 8 | 8 hours after attempt 7 failed | About 23 h 35 min 5 s |

After the eighth attempt the delivery is marked `failed`. It stays in the delivery log, and you can
[replay](#delivery-log-and-replay) it.

### Auto-disable [#auto-disable]

An endpoint that has failed at least 10 attempts in a row and delivered nothing for 3 days is
disabled automatically, with the reason in `disabled_reason`. GridRouter opens a warning alert,
"Webhook endpoint disabled", and sends it to every destination. Fix the receiver, re-enable the
endpoint (`PUT /v1/webhooks/endpoints/{id}` with `{ "enabled": true }`, which clears its failure
count), then replay the failed deliveries.

## Idempotency and ordering [#idempotency-and-ordering]

* **Dedupe on `webhook-id`.** A retry or replay carries the same `webhook-id` and body; only the
  timestamp and signature are new. Record the ids you've processed and skip repeats.
* **Once per endpoint.** The same event is created for an endpoint once, even if GridRouter's
  producer retries.
* **No ordering guarantee.** Retries and parallel deliveries can arrive out of order: a
  `job.completed` can land after a later event. Order by `created_at`, and treat the object's own
  status in `data` as the truth.

## Payload size [#payload-size]

A delivery body is at most 256 KB. A larger event is sent as a stub: `data.truncated` is `true`,
nested objects keep only their `id`, and top-level scalar fields are kept. Fetch the full object
from the API by its id.

## Delivery log and replay [#delivery-log-and-replay]

Every delivery is logged with each attempt's status code, latency, error class (`timeout`,
`network`, `redirect`, `blocked_url` or `HTTP <code>`) and the first 500 characters of your
response, with secrets redacted. The log also keeps the first 2,000 characters of the signed
body. Deliveries are kept for 30 days (the newest 5,000 per workspace).

* `GET /v1/webhooks/deliveries?endpoint_id=…&status=failed` lists deliveries, newest first.
* `POST /v1/webhooks/deliveries/{id}/replay` sends a delivery again with the same `webhook-id` and
  a fresh retry schedule. The endpoint must be enabled.
* **Send test event** (`POST /v1/webhooks/endpoints/{id}/test` with `{ "event_type": "job.completed" }`,
  default `alert.triggered`) sends that type's example payload from the
  [reference](#event-reference), marked `test: true`, right away. It's one attempt, not retried,
  and the result is returned and logged.

## URL rules [#url-rules]

Endpoint URLs are checked when you save them and again before every attempt:

* `https` only, on the default port (443).
* No IP addresses, `localhost`, single-label or internal hostnames.
* No username or password in the URL.
* Redirects are never followed; a `3xx` is a failed attempt.

## API and MCP [#api-and-mcp]

Every operation is a REST route and an [MCP tool](/docs/mcp) of the same name. Reads need
`logs:read`; endpoints hold signing secrets, so changes need `credentials:write`.

Reads, with `logs:read`:

* `webhooks_event_types`: `GET /v1/webhooks/event-types`
* `webhooks_endpoints_list` and `webhooks_endpoints_get`: `GET /v1/webhooks/endpoints` and
  `GET /v1/webhooks/endpoints/{id}`
* `webhooks_deliveries_list` and `webhooks_deliveries_get`: `GET /v1/webhooks/deliveries` and
  `GET /v1/webhooks/deliveries/{id}`

Changes, with `credentials:write`:

* `webhooks_endpoints_create`: `POST /v1/webhooks/endpoints`
* `webhooks_endpoints_update` and `webhooks_endpoints_delete`: `PUT` and
  `DELETE /v1/webhooks/endpoints/{id}`
* `webhooks_endpoints_rotate_secret`: `POST /v1/webhooks/endpoints/{id}/rotate-secret`
* `webhooks_endpoints_test`: `POST /v1/webhooks/endpoints/{id}/test`
* `webhooks_deliveries_replay`: `POST /v1/webhooks/deliveries/{id}/replay`

A workspace can have up to 10 endpoints. Deleting one deletes its secrets and its delivery log.

## Event reference [#event-reference]

Generated from the event schemas. Every example below is validated against its type's schema
when the docs are built, and its `data` is what **Send test event** sends for that type.

| Event | Group | Summary |
| --- | --- | --- |
| `call.completed` | Calls | A call finished with a hit or a miss. |
| `call.failed` | Calls | A call failed. |
| `job.completed` | Jobs and lists | An async job succeeded. |
| `job.failed` | Jobs and lists | An async job failed or expired. |
| `list.completed` | Jobs and lists | A list finished processing every row. |
| `waterfall.run.completed` | Waterfalls | A waterfall run finished. |
| `alert.triggered` | Alerts | An alert rule fired. |
| `alert.resolved` | Alerts | An alert resolved. |
| `vendor_key.failed` | Vendors and billing | A vendor rejected one of your own vendor keys. |
| `billing.mismatch_detected` | Vendors and billing | A vendor billed a call against its own published rule. |
| `budget.threshold_reached` | Vendors and billing | A budget crossed a threshold. |
| `key.created` | Access | An API key was created. |
| `key.revoked` | Access | An API key was revoked. |
| `member.added` | Access | Someone joined the workspace. |
| `member.removed` | Access | Someone left or was removed from the workspace. |

### Calls

#### `call.completed`

A call finished with a hit or a miss. After every successful call (hit or miss). High volume: subscribe only if you need every call; log drains batch the same data.

```json
{
  "id": "evt_call_completed_01j9x7k2m9q4",
  "type": "call.completed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "call": {
      "call_id": "call_01j9x7k2m9q4w8x1c5v3b6",
      "ts": "2026-10-01T12:00:00.000Z",
      "capability": "people.email.find",
      "endpoint_id": "example/email-finder",
      "provider": "example",
      "mode": "sync",
      "outcome": "hit",
      "http_status": 200,
      "error_code": null,
      "total_ms": 412,
      "cost_micro": 4000,
      "key_id": "key_2m9q4w8x1c5v",
      "app_id": null,
      "run_id": null,
      "credential": "managed",
      "cache": "miss",
      "billing_verdict": "managed"
    }
  }
}
```

#### `call.failed`

A call failed. After every call whose outcome is failed, with the error code and HTTP status.

```json
{
  "id": "evt_call_failed_01j9x7k2m9q4",
  "type": "call.failed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "call": {
      "call_id": "call_01j9x7k2m9q4w8x1c5v3b6",
      "ts": "2026-10-01T12:00:00.000Z",
      "capability": "people.email.find",
      "endpoint_id": "example/email-finder",
      "provider": "example",
      "mode": "sync",
      "outcome": "failed",
      "http_status": 502,
      "error_code": "upstream_error",
      "total_ms": 412,
      "cost_micro": 0,
      "key_id": "key_2m9q4w8x1c5v",
      "app_id": null,
      "run_id": null,
      "credential": "managed",
      "cache": "miss",
      "billing_verdict": "managed"
    }
  }
}
```

### Jobs and lists

#### `job.completed`

An async job succeeded. When a job created with POST /v1/jobs reaches succeeded.

```json
{
  "id": "evt_job_completed_01j9x7k2m9q4",
  "type": "job.completed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "job": {
      "object": "job",
      "id": "job_01j9x7k2m9q4w8x1",
      "endpoint_id": "example/company-enrich",
      "mode": "async",
      "status": "succeeded",
      "created_at": "2026-10-01T12:00:00.000Z",
      "updated_at": "2026-10-01T12:00:00.000Z",
      "polls": 3
    }
  }
}
```

#### `job.failed`

An async job failed or expired. When a job reaches failed or expired.

```json
{
  "id": "evt_job_failed_01j9x7k2m9q4",
  "type": "job.failed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "job": {
      "object": "job",
      "id": "job_01j9x7k2m9q4w8x2",
      "endpoint_id": "example/company-enrich",
      "mode": "async",
      "status": "failed",
      "created_at": "2026-10-01T12:00:00.000Z",
      "updated_at": "2026-10-01T12:00:00.000Z",
      "polls": 5,
      "error": {
        "code": "upstream_error",
        "message": "The vendor returned HTTP 502"
      }
    }
  }
}
```

#### `list.completed`

A list finished processing every row. When the last row of a list is done, with its totals.

```json
{
  "id": "evt_list_completed_01j9x7k2m9q4",
  "type": "list.completed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "list": {
      "object": "list",
      "id": "list_01j9x7k2m9q4",
      "status": "completed",
      "total": 500,
      "done": 500,
      "hits": 431,
      "failed": 3,
      "cost_micro": 1724000,
      "created_at": "2026-10-01T12:00:00.000Z"
    }
  }
}
```

### Waterfalls

#### `waterfall.run.completed`

A waterfall run finished. When a waterfall run completes or fails, with the merged record and what it cost.

```json
{
  "id": "evt_waterfall_run_completed_01j9x7k2m9q4",
  "type": "waterfall.run.completed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "run": {
      "object": "waterfall_run",
      "id": "wfr_01j9x7k2m9q4w8x1",
      "waterfall_id": "work-email",
      "version": "3",
      "mode": "sync",
      "status": "completed",
      "outcome": "hit",
      "stop_reason": "first_hit",
      "fields_filled": [
        "email"
      ],
      "cost_micro": 8000,
      "latency_ms": 1840,
      "batch_id": null,
      "created_at": "2026-10-01T12:00:00.000Z",
      "completed_at": "2026-10-01T12:00:00.000Z",
      "attempts": 2,
      "data": {
        "email": "jane@example.com"
      }
    }
  }
}
```

### Alerts

#### `alert.triggered`

An alert rule fired. When an alert opens (after dedupe, cooldown and mute), for rules that notify this endpoint.

```json
{
  "id": "evt_alert_triggered_01j9x7k2m9q4",
  "type": "alert.triggered",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "alert": {
      "object": "alert",
      "id": "alt_7k2m9q4w8x1c5v3b6n0z",
      "rule_id": "alr_4f8k2m9q4w8x1c5v",
      "rule_name": "Error-rate spike",
      "metric": "error_rate",
      "severity": "warning",
      "status": "triggered",
      "subject": null,
      "title": "Error-rate spike",
      "message": "Error rate is 62% over the last 5 minutes (alert at or above 50%).",
      "value": 0.62,
      "threshold": 0.5,
      "count": 1,
      "started_at": "2026-10-01T12:00:00.000Z",
      "last_seen_at": "2026-10-01T12:00:00.000Z",
      "acknowledged_at": null,
      "acknowledged_by": null,
      "resolved_at": null,
      "resolved_reason": null,
      "muted": false
    }
  }
}
```

#### `alert.resolved`

An alert resolved. When an alert resolves on its own or someone resolves it.

```json
{
  "id": "evt_alert_resolved_01j9x7k2m9q4",
  "type": "alert.resolved",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "alert": {
      "object": "alert",
      "id": "alt_7k2m9q4w8x1c5v3b6n0z",
      "rule_id": "alr_4f8k2m9q4w8x1c5v",
      "rule_name": "Error-rate spike",
      "metric": "error_rate",
      "severity": "warning",
      "status": "resolved",
      "subject": null,
      "title": "Error-rate spike",
      "message": "Error rate is 62% over the last 5 minutes (alert at or above 50%).",
      "value": 0.62,
      "threshold": 0.5,
      "count": 1,
      "started_at": "2026-10-01T12:00:00.000Z",
      "last_seen_at": "2026-10-01T12:00:00.000Z",
      "acknowledged_at": null,
      "acknowledged_by": null,
      "resolved_at": "2026-10-01T12:00:00.000Z",
      "resolved_reason": "auto",
      "muted": false
    }
  }
}
```

### Vendors and billing

#### `vendor_key.failed`

A vendor rejected one of your own vendor keys. When a call made with your vendor key gets HTTP 401 or 403 from the vendor. At most once per vendor per 10 minutes.

```json
{
  "id": "evt_vendor_key_failed_01j9x7k2m9q4",
  "type": "vendor_key.failed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "provider": "example",
    "capability": "people.email.find",
    "endpoint_id": "example/email-finder",
    "call_id": "call_01j9x7k2m9q4w8x1c5v3b7",
    "upstream_status": 401,
    "key_id": "key_2m9q4w8x1c5v"
  }
}
```

#### `billing.mismatch_detected`

A vendor billed a call against its own published rule. When vendor billing verification finds a charge for a miss, a double-charged retry or a price mismatch on a call made with your key.

```json
{
  "id": "evt_billing_mismatch_detected_01j9x7k2m9q4",
  "type": "billing.mismatch_detected",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "call_id": "call_01j9x7k2m9q4w8x1c5v3b8",
    "provider": "example",
    "endpoint_id": "example/email-finder",
    "verdict": "charged_for_miss",
    "expected_credits": 0,
    "reported_credits": 1
  }
}
```

#### `budget.threshold_reached`

A budget crossed a threshold. When a workspace or key budget reaches a budget alert rule's threshold (80% and 100% by default).

```json
{
  "id": "evt_budget_threshold_reached_01j9x7k2m9q4",
  "type": "budget.threshold_reached",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "scope": "key",
    "scope_id": "key_2m9q4w8x1c5v",
    "period": "month",
    "limit_micro": 50000000,
    "spent_micro": 40250000,
    "percent": 80.5,
    "threshold_percent": 80
  }
}
```

### Access

#### `key.created`

An API key was created. When anyone in the workspace creates an API key. The secret is never included.

```json
{
  "id": "evt_key_created_01j9x7k2m9q4",
  "type": "key.created",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "key": {
      "object": "api_key",
      "id": "key_2m9q4w8x1c5v",
      "name": "Production",
      "prefix": "grid_live_2m9q",
      "scopes": [
        "call",
        "logs:read"
      ],
      "status": "active",
      "created_at": "2026-10-01T12:00:00.000Z"
    },
    "actor": "user:usr_2m9q4w8x"
  }
}
```

#### `key.revoked`

An API key was revoked. When a key is revoked from the dashboard, the API or automatically (refresh-token reuse).

```json
{
  "id": "evt_key_revoked_01j9x7k2m9q4",
  "type": "key.revoked",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "key": {
      "object": "api_key",
      "id": "key_2m9q4w8x1c5v",
      "name": "Production",
      "prefix": "grid_live_2m9q",
      "scopes": [
        "call",
        "logs:read"
      ],
      "status": "revoked",
      "created_at": "2026-10-01T12:00:00.000Z"
    },
    "actor": "user:usr_2m9q4w8x"
  }
}
```

#### `member.added`

Someone joined the workspace. When an invitation is accepted or a member is added.

```json
{
  "id": "evt_member_added_01j9x7k2m9q4",
  "type": "member.added",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "member": {
      "user_id": "usr_4w8x1c5v",
      "email": "sam@example.com",
      "role": "developer"
    },
    "actor": "user:usr_2m9q4w8x"
  }
}
```

#### `member.removed`

Someone left or was removed from the workspace. When a member leaves or is removed.

```json
{
  "id": "evt_member_removed_01j9x7k2m9q4",
  "type": "member.removed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-01T12:00:00.000Z",
  "data": {
    "member": {
      "user_id": "usr_4w8x1c5v",
      "email": "sam@example.com",
      "role": "developer"
    },
    "actor": "user:usr_2m9q4w8x"
  }
}
```

