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

# Webhooks

> Receive signed webhook events from Foil when sessions and Gate signups change state, verify HMAC signatures, and handle retries idempotently.

Foil posts signed JSON events to your server when interesting things happen on a session or a Gate signup. Use webhooks to react asynchronously: persist a verdict to your warehouse, fan out to a workflow, or provision an account when Gate approves a developer.

## Event types

Three event types are available today. One endpoint can subscribe to any combination of them.

| Event                            | Sent when                                                                                                                                                            |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session.fingerprint.calculated` | The browser SDK has frozen a visitor fingerprint for a session. Fires once, on the first freeze.                                                                     |
| `session.result.persisted`       | A non-provisional verdict has been written for a session. Fires once per session, after the final scoring pass — provisional updates do not deliver.                 |
| `gate.session.approved`          | A developer approved a CLI signup through Gate. Your handler must respond with an encrypted credentials envelope — see the [Gate webhook quickstart](/gate/webhook). |

For the full payload of each event, see the **Webhooks** group in the [API reference](/api-reference/introduction).

## Envelope

Every event is wrapped in the same envelope:

```json theme={"dark"}
{
  "id": "wevt_0123456789abcdef0123456789abcdef",
  "object": "webhook_event",
  "type": "session.result.persisted",
  "created": "2026-03-24T20:00:05.000Z",
  "data": { ... }
}
```

| Field     | Description                                                                          |
| --------- | ------------------------------------------------------------------------------------ |
| `id`      | Unique event identifier. Use it to dedupe retries — see [Idempotency](#idempotency). |
| `object`  | Always `"webhook_event"`.                                                            |
| `type`    | The event type. Switch on this to dispatch to the right handler.                     |
| `created` | ISO-8601 UTC timestamp of when the event was recorded.                               |
| `data`    | Event-specific payload. See the API reference for the full schema.                   |

## Endpoints

A **webhook endpoint** is a URL on your server, an event-type subscription list, and a signing secret. Each endpoint is scoped to a single organization. You can have multiple endpoints — for example, separate URLs for staging and production, or per-team fan-out.

### Create an endpoint

From the [dashboard](https://dashboard.usefoil.com), open **Webhooks → Endpoints**, click **New endpoint**, paste your HTTPS URL, name it, and check the events you want to receive. Copy the `signing_secret` from the success screen — **it is shown once.** Store it as `FOIL_WEBHOOK_SECRET` (or similar) on the receiving service.

To create an endpoint via the API, `POST /v1/organizations/{organizationId}/webhooks/endpoints` with an `sk_*` key that has the `webhooks:manage` scope:

```bash theme={"dark"}
curl https://api.usefoil.com/v1/organizations/org_.../webhooks/endpoints \
  -X POST \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production receiver",
    "url": "https://app.example.com/webhooks/foil",
    "event_types": [
      "session.fingerprint.calculated",
      "session.result.persisted"
    ]
  }'
```

The 201 response includes the `signing_secret` exactly once. Persist it before discarding the response.

### URL requirements

Foil validates every endpoint URL on create, on update, and again before each delivery attempt:

* **HTTPS only** in production. Plain HTTP is allowed only against `localhost` / `127.0.0.1` in non-production environments.
* **No credentials in the URL** — `https://user:pass@host/path` is rejected.
* **Public IPs only.** Foil resolves the hostname and refuses to deliver to private, reserved, or link-local ranges. This protects your infrastructure from SSRF-style misuse if a webhook secret leaks.
* **Reachable.** DNS must resolve and the request must complete within the per-attempt timeout (10 seconds).

If your receiver sits behind a private network, terminate TLS on a public ingress and proxy internally.

### Event subscriptions

A subscription is the `(endpoint, event_type)` pair. When you `PATCH .../endpoints/{endpointId}` with a new `event_types` array, Foil **replaces** the endpoint's subscriptions — pass the full desired set, not just additions.

```bash theme={"dark"}
curl https://api.usefoil.com/v1/organizations/org_.../webhooks/endpoints/we_... \
  -X PATCH \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "event_types": ["session.result.persisted"] }'
```

### Disable, re-enable, or delete

* **Disable** — `PATCH .../endpoints/{endpointId}` with `{"status": "disabled"}`. New events are not queued for the endpoint; in-flight deliveries finish in `skipped` state.
* **Re-enable** — `PATCH .../endpoints/{endpointId}` with `{"status": "active"}`. Past skipped deliveries are not replayed; only new events are delivered.
* **Delete** — `DELETE .../endpoints/{endpointId}` soft-disables the endpoint. The endpoint and its event history remain visible for auditing.

### Send a test event

`POST .../endpoints/{endpointId}/test` (or **Send test event** in the dashboard) enqueues a delivery with `type: "webhook.test"` and a tiny payload. The signature flow is identical to production events, so a passing test verifies your verification code as well as connectivity.

## Authentication

Every Foil webhook is signed with HMAC-SHA256. Verify the signature before reading the body — without it, anyone who knows your URL can post arbitrary payloads.

### What gets signed

For every request, Foil computes:

```text theme={"dark"}
HMAC-SHA256(signing_secret, `${X-Foil-Timestamp}.${rawBody}`)
```

and sends the lowercase hex digest in `X-Foil-Signature`. The signing secret has the format `whsec_<base64url>` and is shown once when you create or rotate the endpoint.

The timestamp is the Unix epoch in seconds, as a string. Both headers are required; missing or malformed values must be rejected as 401.

<Warning>
  Compute the HMAC over the **raw request bytes**, not over `JSON.stringify(req.body)`. Most JSON parsers reorder keys and strip whitespace, which produces a different digest and a guaranteed signature mismatch. Capture the raw body before any middleware parses it.
</Warning>

### Verify the signature

<CodeGroup>
  ```javascript Node.js theme={"dark"}
  const crypto = require('crypto');

  app.post('/webhooks/foil', (req, res) => {
    const timestamp = req.headers['x-foil-timestamp'];
    const signature = req.headers['x-foil-signature'];
    const expected = crypto
      .createHmac('sha256', process.env.FOIL_WEBHOOK_SECRET)
      .update(`${timestamp}.${req.rawBody}`)
      .digest('hex');

    if (
      typeof signature !== 'string'
      || signature.length !== expected.length
      || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    ) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Process the event...
    res.status(200).end();
  });
  ```

  ```python Python theme={"dark"}
  import hmac, hashlib, os

  @app.post("/webhooks/foil")
  def foil_webhook(request):
      timestamp = request.headers.get("X-Foil-Timestamp", "")
      signature = request.headers.get("X-Foil-Signature", "")
      expected = hmac.new(
          os.environ["FOIL_WEBHOOK_SECRET"].encode(),
          f"{timestamp}.".encode() + request.data,
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(signature, expected):
          return {"error": "Invalid signature"}, 401

      # Process the event...
  ```

  ```go Go theme={"dark"}
  func foilWebhook(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      timestamp := r.Header.Get("X-Foil-Timestamp")
      mac := hmac.New(sha256.New, []byte(os.Getenv("FOIL_WEBHOOK_SECRET")))
      mac.Write([]byte(timestamp + "."))
      mac.Write(body)
      expected := hex.EncodeToString(mac.Sum(nil))

      if !hmac.Equal([]byte(r.Header.Get("X-Foil-Signature")), []byte(expected)) {
          http.Error(w, "Invalid signature", 401)
          return
      }

      // Process the event...
  }
  ```

  ```ruby Ruby theme={"dark"}
  post '/webhooks/foil' do
    body = request.body.read
    timestamp = request.env['HTTP_X_FOIL_TIMESTAMP']
    signature = request.env['HTTP_X_FOIL_SIGNATURE']
    expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['FOIL_WEBHOOK_SECRET'], "#{timestamp}.#{body}")

    halt 401, { error: 'Invalid signature' }.to_json unless Rack::Utils.secure_compare(signature, expected)

    # Process the event...
  end
  ```

  ```php PHP theme={"dark"}
  $body = file_get_contents('php://input');
  $timestamp = $_SERVER['HTTP_X_FOIL_TIMESTAMP'] ?? '';
  $signature = $_SERVER['HTTP_X_FOIL_SIGNATURE'] ?? '';
  $expected = hash_hmac('sha256', "{$timestamp}.{$body}", getenv('FOIL_WEBHOOK_SECRET'));

  if (!hash_equals($signature, $expected)) {
      http_response_code(401);
      echo json_encode(['error' => 'Invalid signature']);
      exit;
  }

  // Process the event...
  ```
</CodeGroup>

Always compare with a constant-time helper (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal`, `Rack::Utils.secure_compare`, `hash_equals`) — naive `==` leaks information about partial matches.

### Replay protection

The timestamp is signed, so an attacker cannot reuse a captured request with a forged body. To also reject **replays of the original request**, reject anything whose timestamp is more than a few minutes old:

```javascript theme={"dark"}
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skewSeconds) || skewSeconds > 5 * 60) {
  return res.status(401).json({ error: 'Stale webhook' });
}
```

Five minutes is a comfortable bound; tighten it if you have strict clock sync. Also dedupe by the envelope `id` to drop legitimate retries — see [Idempotency](#idempotency).

### Rotating secrets

`POST .../endpoints/{endpointId}/rotations` (or **Rotate secret** in the dashboard) returns a new `signing_secret` and immediately uses it for outgoing deliveries. Rotate when:

* a teammate with access to the secret leaves
* the secret may have been logged or committed
* on a regular schedule (annually is a reasonable default)

To rotate without dropping in-flight deliveries, do an overlap window:

1. Add the new secret as a second verifier in your code, alongside the current one.
2. Deploy. Both signatures now verify.
3. Rotate via the API. Foil signs new requests with the new secret.
4. After the longest possible retry window (≥ 10 minutes covers all delivery attempts), drop the old secret from your code and redeploy.

```javascript theme={"dark"}
const secrets = [
  process.env.FOIL_WEBHOOK_SECRET_NEW,
  process.env.FOIL_WEBHOOK_SECRET_OLD,
].filter(Boolean);

const valid = secrets.some((secret) => {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${req.rawBody}`)
    .digest('hex');
  return signature.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
});
```

## Delivery

### The request

Every webhook is an HTTP `POST` with a JSON body and these headers:

| Header              | Description                                                                               |
| ------------------- | ----------------------------------------------------------------------------------------- |
| `Content-Type`      | Always `application/json`.                                                                |
| `X-Foil-Event`      | The event ID — same as `id` in the [envelope](#envelope). Use this for idempotency.       |
| `X-Foil-Event-Type` | The event type, e.g. `session.result.persisted`. Lets you route without parsing the body. |
| `X-Foil-Timestamp`  | Unix timestamp in seconds, as a string. Part of the signature base string.                |
| `X-Foil-Signature`  | HMAC-SHA256 hex digest. See [Authentication](#authentication).                            |

There is no fixed source IP range and no IP allowlist. Authenticate via the signature, not the network.

### What counts as success

* **2xx** — delivery succeeds. The endpoint will not be retried for this event.
* **anything else, plus connection errors and timeouts** — delivery fails and is retried (up to the limit below).

The per-attempt timeout is **10 seconds**. If your handler takes longer, do the heavy work asynchronously: enqueue a job, then return 200 immediately.

The first 256 KB of the response body is captured for the delivery log. Larger responses are truncated; the log stores up to 4000 characters.

### Retries

If a delivery is not 2xx, Foil retries up to **5 attempts total**. The first retry waits at least one minute; subsequent retries use exponential backoff and run on a worker queue, so the actual delay can extend during high load. Treat all timing as a lower bound — design for "delivered eventually" rather than "delivered every minute on the dot."

After the 5th failed attempt the delivery moves to terminal `failed` status and stops retrying. Re-deliver manually from the dashboard or by sending a fresh test event.

Delivery status values you'll see in the dashboard and the API:

| Status       | Meaning                                               |
| ------------ | ----------------------------------------------------- |
| `pending`    | Queued, not yet attempted (or scheduled retry).       |
| `delivering` | A worker is currently sending the request.            |
| `succeeded`  | The endpoint returned 2xx.                            |
| `failed`     | All retries exhausted with non-2xx / errors.          |
| `skipped`    | The endpoint was disabled or deleted before delivery. |

### Idempotency

You may receive the **same event ID more than once** — retries after a transient 5xx, network blips, or worker reschedules. Make your handler idempotent on `X-Foil-Event`:

```javascript theme={"dark"}
async function handleEvent(envelope) {
  const eventId = envelope.id;
  const inserted = await db.query(
    `INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING`,
    [eventId],
  );
  if (inserted.rowCount === 0) return; // already processed

  // ... do the work
}
```

Foil also dedupes internally per `(event_id, endpoint_id)`, so the same event never produces parallel deliveries to one endpoint. But your handler must still tolerate retries of the same event ID over time.

For Gate's `gate.session.approved` event, also key on `data.gate_session_id` — the [Gate webhook quickstart](/gate/webhook#idempotency) shows the pattern.

### Event log

Every event is recorded with its delivery attempts. View it in two places:

* **Dashboard** — **Webhooks → Events** lists recent events with delivery status, attempt count, response code, and the captured response body. Filter by endpoint or event type to debug a specific receiver.
* **API** — `GET /v1/organizations/{organizationId}/events` returns event resources with nested delivery attempts. Retrieve one event with `GET /v1/organizations/{organizationId}/events/{eventId}`. Both require `webhooks:read`.

```bash theme={"dark"}
curl 'https://api.usefoil.com/v1/organizations/org_.../events?endpoint_id=we_...&type=session.result.persisted&limit=50' \
  -H "Authorization: Bearer sk_live_..."
```

Supported list filters:

| Query param   | Description                                                              |
| ------------- | ------------------------------------------------------------------------ |
| `endpoint_id` | Only return events that produced a delivery for this endpoint.           |
| `type`        | Only return events of this type. Includes `webhook.test` for test sends. |
| `limit`       | 1–200, default 50.                                                       |

Each event resource looks like:

```json theme={"dark"}
{
  "object": "event",
  "id": "wevt_0123456789abcdef0123456789abcdef",
  "type": "session.result.persisted",
  "subject": { "type": "session", "id": "sid_..." },
  "data": { ... },
  "webhook_deliveries": [
    {
      "object": "webhook_delivery",
      "id": "wdlv_0123456789abcdef0123456789abcdef",
      "event_id": "wevt_...",
      "endpoint_id": "we_...",
      "event_type": "session.result.persisted",
      "status": "succeeded",
      "attempts": 1,
      "response_status": 200,
      "response_body": "ok",
      "error": null,
      "created_at": "2026-03-24T20:00:05.000Z",
      "updated_at": "2026-03-24T20:00:06.000Z"
    }
  ],
  "created_at": "2026-03-24T20:00:05.000Z"
}
```

| Field                         | Description                                                                                                                                                                                                                                                                |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                          | Event ID — also sent as `X-Foil-Event` and as the envelope `id`.                                                                                                                                                                                                           |
| `type`                        | The event type.                                                                                                                                                                                                                                                            |
| `subject.type` / `subject.id` | The resource the event is about (e.g. `session` / `sid_...`).                                                                                                                                                                                                              |
| `data`                        | The same `data` object delivered in the webhook envelope.                                                                                                                                                                                                                  |
| `webhook_deliveries[]`        | One entry per endpoint subscribed at the time of fan-out. Each entry tracks attempts, the latest `response_status` and `response_body` (truncated to 4000 chars), the most recent `error`, and a `status` of `pending`, `delivering`, `succeeded`, `failed`, or `skipped`. |
| `created_at`                  | When the event was recorded.                                                                                                                                                                                                                                               |

### Local development

Foil refuses to deliver to private IPs in production. To develop against your laptop, use a tunnel (`ngrok`, `cloudflared`, etc.) and register the tunnel's public URL as the endpoint. The signature flow and payloads are identical to production.
