Event types
An endpoint receives the events it subscribes to.
Foil also sends
webhook.test events when you send a test event to an endpoint. You can’t subscribe to webhook.test.
Envelope
Every event is wrapped in the same envelope:Event payloads
session.result.persisted
The data object describes the decision that Foil persisted for the session:
webhook.test
A test event carries data with the endpoint_id and a message field that reads This is a Foil webhook test event.
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.Endpoint API
All webhook and event endpoints require a secret key with a webhook scope, and they work only for the organization that owns the key. See Authentication.
An unknown endpoint or event ID returns
404, and an invalid name, url, or event_types value returns 422.
Create an endpoint
From the dashboard, open Webhooks. In the Endpoints card, click New endpoint, name it, paste your HTTPS URL, and check the events you want to receive. The dashboard asks you to verify your identity before it creates, edits, or rotates an endpoint. Copy thesigning_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:
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.1in non-production environments. - No credentials in the URL -
https://user:pass@host/pathis 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).
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. Foil ignores event types it doesn’t support, and an array with no supported event type returns 422.
Disable, re-enable, or delete
- Disable -
PATCH .../endpoints/{endpointId}with{"status": "disabled"}, or Disable endpoint in the endpoint’s menu in the dashboard. New events are not queued for the endpoint; in-flight deliveries finish inskippedstate. - Re-enable -
PATCH .../endpoints/{endpointId}with{"status": "active"}, or Enable endpoint in the dashboard menu. 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 endpoint’s menu in the dashboard) enqueues a delivery with type: "webhook.test" and a small payload. The API returns 202 with an object that carries the event_id, the delivery_ids, and the latest_delivery. The signature flow is identical to production events, so a passing test verifies your verification code as well as connectivity.
A test event goes through the same delivery queue as other events, so it follows the retry rules. If the endpoint is disabled, Foil skips the delivery, and the dashboard turns off Send test event for that endpoint.
Event history and retention
Foil records subscribed events when your organization has an active endpoint with an active subscription to that event type. Events with no active subscribers are skipped and do not appear in webhook event history. Test events still create an event for the selected endpoint. Recorded events and their delivery diagnostics are retained for 7 days. Events with pending or in-flight deliveries remain until those deliveries finish; they can therefore remain visible longer. Cleanup runs in batches, so expired events can take additional time to disappear. This retention applies to webhook history, not to the underlying session or scoring data.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: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.
Verify the signature
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:id to drop legitimate retries - see Idempotency.
Rotating secrets
POST .../endpoints/{endpointId}/rotations (or Rotate secret in the endpoint’s menu in the dashboard) returns a new signing_secret and immediately uses it for outgoing deliveries. Foil signs each attempt with the secret that is current when it makes the attempt, so a retry that starts after you rotate uses the new secret. 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)
- Add the new secret as a second verifier in your code, alongside the current one.
- Deploy. Both signatures now verify.
- Rotate via the API. Foil signs new requests with the new secret.
- After the retry window ends, drop the old secret from your code and redeploy. Foil finishes all attempts for an event within about a minute of the first attempt, plus any delay in the delivery queue, so waiting 10 minutes leaves a wide margin.
Delivery
The request
Every webhook is an HTTPPOST with a JSON body and these headers:
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 - delivery fails and is retried (up to the limit below). This includes non-2xx statuses, connection errors, DNS failures, and timeouts. Foil doesn’t follow redirects, so a 3xx response counts as a failure.
- A response body over 256 KB - the attempt fails even if the status is 2xx. Foil stops reading at 256 KB and abandons the attempt.
Retries
If an attempt fails, Foil retries the delivery from a worker queue, for up to 5 attempts in total. It waits 1 second after the first failed attempt, then 2, 4, and 8 seconds after the next three. That is exponential backoff that doubles from a 1-second base. Each attempt can take up to 10 seconds, so all five attempts finish within about a minute of the first. When the worker queue is busy, the actual delay can extend, so treat all timing as a lower bound and design for “delivered eventually” rather than “delivered on a fixed schedule.” After the 5th failed attempt the delivery moves to terminalfailed status and stops retrying. Foil has no manual redelivery, in the dashboard or the API. To recover from a failed delivery, retrieve the event with GET /v1/organizations/{organizationId}/events/{eventId} while it is still within the retention period and process its data, or read the session with the Sessions API. Sending a test event checks that your endpoint is healthy again, but it doesn’t redeliver earlier events.
Delivery status values you’ll see in the dashboard and the API:
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 onX-Foil-Event:
(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.
A session produces at most one session.result.persisted event, so data.session.id also works as a key for that event.
Event log
Every event is recorded with its delivery attempts. View it in two places:- Dashboard - the Delivery history card on the Webhooks page lists recent deliveries with the event type, delivery status, attempt count, and last update time. It refreshes automatically. The dashboard doesn’t show response codes or response bodies, and it has no filters.
- API -
GET /v1/organizations/{organizationId}/eventsreturns event resources with nested deliveries, including the latest response code and response body. Retrieve one event withGET /v1/organizations/{organizationId}/events/{eventId}. Both requirewebhooks:read.
Events are ordered newest first. The list has no
cursor. It returns at most limit events in one page, and pagination.has_more is always false. An unknown type, or a limit outside the range, returns 422.
Each event resource looks like:
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.