Skip to content
GridRouterhome

Search

Search providers, capabilities and pages

Docs
Concepts

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_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.
  • 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" } }
}
FieldMeaning
idThe event id (evt_…), the same for every endpoint that receives it
typeOne of the event types
api_versionThe payload version, currently 2026-10-01
created_atWhen the event happened (ISO 8601, UTC)
testtrue on events sent with Send test event; absent otherwise
dataThe 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

HeaderValue
webhook-idThe delivery id (msg_…). The same on every retry and replay of this delivery
webhook-timestampUnix seconds when this attempt was signed
webhook-signatureOne or more space-separated v1,<signature>
content-typeapplication/json
user-agentGridRouter-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

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.

AttemptSentTime since the first attempt
1When the event happens0 s
25 seconds after attempt 1 failedAbout 5 s
35 minutes after attempt 2 failedAbout 5 min 5 s
430 minutes after attempt 3 failedAbout 35 min 5 s
52 hours after attempt 4 failedAbout 2 h 35 min 5 s
65 hours after attempt 5 failedAbout 7 h 35 min 5 s
78 hours after attempt 6 failedAbout 15 h 35 min 5 s
88 hours after attempt 7 failedAbout 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 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

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=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, marked test: 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:

  • 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

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-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

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.

EventGroupSummary
call.completedCallsA call finished with a hit or a miss.
call.failedCallsA call failed.
job.completedJobs and listsAn async job succeeded.
job.failedJobs and listsAn async job failed or expired.
list.completedJobs and listsA list finished processing every row.
waterfall.run.completedWaterfallsA waterfall run finished.
alert.triggeredAlertsAn alert rule fired.
alert.resolvedAlertsAn alert resolved.
vendor_key.failedVendors and billingA vendor rejected one of your own vendor keys.
billing.mismatch_detectedVendors and billingA vendor billed a call against its own published rule.
budget.threshold_reachedVendors and billingA budget crossed a threshold.
key.createdAccessAn API key was created.
key.revokedAccessAn API key was revoked.
member.addedAccessSomeone joined the workspace.
member.removedAccessSomeone 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"
  }
}