Webhooks
Signed HTTPS events for calls, jobs, lists, waterfall runs, alerts, vendor keys, billing checks, budgets, keys and members. Standard Webhooks signatures, retries for about a day, a delivery log and replay.
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 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 instead.
Endpoints
Create an endpoint in Settings → Alerts & integrations, or with a key that has
credentials:write:
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_typeslists the types delivered to the endpoint (up to 40).["*"]means every type, including types added later. - Status.
active;failingafter 3 failed attempts in a row;disabledwhen you turn it off or GridRouter disables it. - 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
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. Alerts from muted rules send no
webhook events.
The envelope
Every delivery is one JSON object:
{
"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 |
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 |
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
| 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:
- Read the raw body exactly as received, before any JSON parsing.
- Reject the request if
webhook-timestampis more than 5 minutes from your clock. - Compute the signature and compare it, in constant time, with each
v1,entry inwebhook-signature. Accept if any one matches. - Answer
2xxwithin 15 seconds. Do slow work after you respond.
Verify a delivery
verifyWebhook from @relaygrid/sdk checks the timestamp (5-minute tolerance by default,
toleranceSec to change it) and every signature in the header.
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 });
}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
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 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 it.
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
- Dedupe on
webhook-id. A retry or replay carries the samewebhook-idand 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.completedcan land after a later event. Order bycreated_at, and treat the object's own status indataas the truth.
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
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=failedlists deliveries, newest first.POST /v1/webhooks/deliveries/{id}/replaysends a delivery again with the samewebhook-idand a fresh retry schedule. The endpoint must be enabled.- Send test event (
POST /v1/webhooks/endpoints/{id}/testwith{ "event_type": "job.completed" }, defaultalert.triggered) sends that type's example payload from the reference, markedtest: true, right away. It's one attempt, not retried, and the result is returned and logged.
URL rules
Endpoint URLs are checked when you save them and again before every attempt:
httpsonly, 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
3xxis a failed attempt.
API and MCP
Every operation is a REST route and an MCP tool 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-typeswebhooks_endpoints_listandwebhooks_endpoints_get:GET /v1/webhooks/endpointsandGET /v1/webhooks/endpoints/{id}webhooks_deliveries_listandwebhooks_deliveries_get:GET /v1/webhooks/deliveriesandGET /v1/webhooks/deliveries/{id}
Changes, with credentials:write:
webhooks_endpoints_create:POST /v1/webhooks/endpointswebhooks_endpoints_updateandwebhooks_endpoints_delete:PUTandDELETE /v1/webhooks/endpoints/{id}webhooks_endpoints_rotate_secret:POST /v1/webhooks/endpoints/{id}/rotate-secretwebhooks_endpoints_test:POST /v1/webhooks/endpoints/{id}/testwebhooks_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
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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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).
{
"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.
{
"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).
{
"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.
{
"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.
{
"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"
}
}Alerts
Rules over your calls, budgets, vendor keys, billing checks and deliveries. Alerts open, repeat, get acknowledged and resolve, and notify the destinations you choose.
Integrations
Send alerts to email, your own webhook endpoints, Slack, Discord, Microsoft Teams, PagerDuty and Opsgenie. Where to find each credential, what the message looks like and how to test it.