Webhooks
Zeridion Flare pushes job lifecycle events to your server as outbound webhooks, managed via the /flare/v1/webhooks API (API-key auth, tenant-scoped).
Outbound webhooks
When an event occurs (job succeeded, failed, dead-lettered, etc.), Zeridion posts a signed JSON payload to each active webhook subscription for your project. Delivery outcomes:
- 2xx — delivered, no further attempts.
- 4xx — treated as a permanent client error (bad URL, auth, payload format). The delivery is marked
failedimmediately with no retry. Fix the subscription and re-trigger the event source if you need redelivery. - 5xx, network error, or timeout — each delivery attempt allows 10 seconds wall-clock before timing out; the delivery is retried up to 5 attempts total on a fixed back-off schedule. The waits between attempts are 5 s → 30 s → 5 min → 1 h, so a single failing event keeps retrying for roughly an hour (worst case ≈ 66 minutes including per-attempt timeouts) before it is finally marked
failed. Plan your receiver's timeouts and idempotency around that envelope — an outage shorter than an hour will usually still get every event delivered.
Delivery flow
Base URL: https://api.zeridion.com/flare/v1
All outbound webhook endpoints require Bearer token authentication (API key).
Event types
| Event type | Fired when |
|---|---|
job.created | A new job is enqueued. |
job.succeeded | The job completes successfully. |
job.failed | The job exhausts all attempts and is dead-lettered — its data.state is dead_letter. |
Use "*" as the events value to subscribe to all event types.
Delivery headers
Every delivery request carries these headers:
| Header | Description |
|---|---|
X-Zeridion-Signature | HMAC-SHA256 signature in the form t=<unix_timestamp>,v1=<hex_digest> — see HMAC-SHA256 signature. |
X-Zeridion-Event-Id | Unique id for this event delivery. Stable across retries of the same event — use it to deduplicate if your receiver sees the same event twice. |
X-Zeridion-Event-Type | The event type (e.g. job.succeeded), mirroring the event_type field in the body. Lets you route without parsing the payload. |
User-Agent | Zeridion-Webhooks/1.0 |
Payload shape
Every delivery sends a POST request with Content-Type: application/json and the following envelope:
{
"event_id": "evt_01J...",
"event_type": "job.succeeded",
"created_at": "2026-04-15T12:34:56Z",
"data": {
"schema_version": 1,
"job_id": "job_01J...",
"project_id": "prj_01J...",
"state": "succeeded",
"job_type": "email.send",
"queue": "email",
"duration_ms": 240,
"completed_at": "2026-04-15T12:34:56Z"
}
}
Per-event-type data payload
Every event shares the same envelope (event_id, event_type, created_at, data); the data object varies by event type. Every data object carries a schema_version so receivers can evolve without a coordinated migration.
| Event | data fields |
|---|---|
job.created | schema_version, job_id, queue |
job.succeeded | schema_version, job_id, project_id, job_type, queue, state (= "succeeded"), duration_ms, completed_at |
job.failed | schema_version, job_id, project_id, job_type, queue, state (= "dead_letter"), attempt_number, max_attempts, error_type, error_message, duration_ms, completed_at |
HMAC-SHA256 signature
Every delivery includes an X-Zeridion-Signature header in the form t=<unix_timestamp>,v1=<lowercase_hex_digest>. The digest is HMAC-SHA256(key=secret, msg="<unix_timestamp>.<raw_request_body>") where secret is the value returned when you created the subscription, and the . between timestamp and body is a literal period. Always verify this signature before processing the event.
The official SDKs ship a helper that handles parsing, constant-time comparison, and optional replay-protection via a timestamp tolerance. Prefer it over rolling your own:
from zeridion_flare import verify_webhook
if not verify_webhook(raw_body, header, secret, tolerance_seconds=300):
return Response(status_code=400)
import { verifyWebhook } from "@zeridion/flare";
const ok = await verifyWebhook(rawBody, header, secret, { toleranceSeconds: 300 });
using Zeridion.Flare;
if (!Webhook.Verify(rawBody, header, secret, tolerance: TimeSpan.FromMinutes(5)))
return Results.BadRequest();
If you need to verify manually (e.g., from a language without an official SDK), the reference algorithm is:
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, max_age_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
if "t" not in parts or "v1" not in parts:
return False
timestamp = int(parts["t"])
if abs(int(time.time()) - timestamp) > max_age_seconds:
return False
signing_input = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signing_input, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
The header may contain multiple v1= values during secret rotation (e.g. t=1700000000,v1=<old>,v1=<new>); verify against each and accept if any match.
GET /flare/v1/webhooks
List all webhook subscriptions for the authenticated project.
Request
GET /flare/v1/webhooks
Authorization: Bearer <api_key>
Response
200 OK
{
"data": [
{
"id": "whk_01J...",
"url": "https://your-app.example.com/hooks/zeridion",
"events": ["job.succeeded", "job.failed"],
"is_active": true,
"created_at": "2026-04-01T00:00:00Z",
"updated_at": null
}
]
}
The secret field is not returned in list responses. Store it on creation.
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
GET /flare/v1/webhooks/{id}
Fetch a single webhook subscription by id.
Request
GET /flare/v1/webhooks/{id}
Authorization: Bearer <api_key>
Response
200 OK
{
"id": "whk_01J...",
"url": "https://your-app.example.com/hooks/zeridion",
"events": ["job.succeeded", "job.failed"],
"is_active": true,
"created_at": "2026-04-01T00:00:00Z",
"updated_at": null
}
The secret is never returned on reads — it is shown once at creation (or rotation) only.
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 404 | webhook_not_found | Subscription does not exist or belongs to a different project. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
POST /flare/v1/webhooks
Create a new webhook subscription.
Request
POST /flare/v1/webhooks
Authorization: Bearer <api_key>
Content-Type: application/json
Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | The HTTPS endpoint that will receive POST deliveries. Max 2048 characters. |
events | string[] or "*" | yes | Array of event type strings, or "*" to receive all events. |
A new subscription is always created active. To create one in a disabled
state, create it and then disable it with a follow-up PATCH.
{
"url": "https://your-app.example.com/hooks/zeridion",
"events": ["job.succeeded", "job.failed"]
}
Response
201 Created
{
"id": "whk_01J...",
"url": "https://your-app.example.com/hooks/zeridion",
"events": ["job.succeeded", "job.failed"],
"secret": "whsec_a3f8...",
"is_active": true,
"created_at": "2026-04-15T12:00:00Z"
}
The secret is only returned once at creation time. Store it securely — it cannot be retrieved again.
Secret rotation (grace window)
Calling the rotate-secret endpoint generates a fresh secret and holds the previous secret for a 24-hour grace window. During the window every outgoing delivery's X-Zeridion-Signature header carries two v1= digests — one computed with the new secret and one with the previous secret — and your receiver should accept either:
X-Zeridion-Signature: t=1700000000,v1=<new>,v1=<previous>
After the grace window elapses the previous secret is dropped and only the new secret signs deliveries. The reference verifier on this page already loops over every v1= value, so SDK helpers (Webhook.Verify, verifyWebhook) are rotation-safe with no code change on the receiver.
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | invalid_request | Body failed validation — url is missing, exceeds 2048 characters, or is not an absolute http/https URL that resolves to a public address (loopback, link-local, and private addresses are rejected); or events exceeds 1024 characters. |
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
PATCH /flare/v1/webhooks/{id}
Update an existing webhook subscription.
Request
PATCH /flare/v1/webhooks/{id}
Authorization: Bearer <api_key>
Content-Type: application/json
All fields are optional — only include the fields you want to change.
| Field | Type | Description |
|---|---|---|
url | string | New target URL. |
events | string[] or "*" | New event filter. |
is_active | boolean | Enable or disable the subscription. |
Response
200 OK — returns the updated subscription object (without secret).
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | invalid_request | Body failed validation — url exceeds 2048 characters or is not an absolute http/https URL that resolves to a public address; or events exceeds 1024 characters. |
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 404 | webhook_not_found | Subscription does not exist or belongs to a different project. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
POST /flare/v1/webhooks/{id}/rotate-secret
Generate a new HMAC signing secret for the subscription. The previous secret continues to sign outgoing deliveries for 24 hours after the rotate — see Secret rotation (grace window) above. Use this when the signing secret may have been compromised, or as a periodic hygiene step.
Request
POST /flare/v1/webhooks/{id}/rotate-secret
Authorization: Bearer <api_key>
The request body is empty.
Response
200 OK
{
"id": "whk_01J...",
"secret": "<new-plaintext-secret>",
"previous_secret_expires_at": "2026-04-16T12:00:00Z",
"rotated_at": "2026-04-15T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
id | string | The subscription id. Unchanged by rotation. |
secret | string | New plaintext HMAC signing secret — returned only once. Store it immediately; subsequent reads of the subscription will never expose either secret. |
previous_secret_expires_at | ISO 8601 | When the prior secret stops signing outgoing deliveries (always rotated_at + 24h). Until this instant, every delivery's X-Zeridion-Signature header carries two v1= digests. |
rotated_at | ISO 8601 | When the rotation was recorded. |
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 404 | webhook_not_found | Subscription does not exist or belongs to a different project. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
Example
curl -X POST https://api.zeridion.com/flare/v1/webhooks/whk_01J.../rotate-secret \
-H "Authorization: Bearer $ZERIDION_API_KEY"
After a successful rotate, persist the new secret in your receiver's verifier store before deleting the prior one. The reference verifier on this page already loops over every v1= digest in the signature header, so a verifier that pulls the latest secret each request will not drop events during the grace window.
DELETE /flare/v1/webhooks/{id}
Delete a webhook subscription. Pending or in-flight deliveries are not affected.
Request
DELETE /flare/v1/webhooks/{id}
Authorization: Bearer <api_key>
Response
204 No Content
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 404 | webhook_not_found | Subscription does not exist or belongs to a different project. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
GET /flare/v1/webhooks/{id}/deliveries
List recent delivery attempts for a webhook subscription, newest first.
Request
GET /flare/v1/webhooks/{id}/deliveries?limit=20
Authorization: Bearer <api_key>
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Results per page (1–100). Values outside the range are clamped. |
cursor | string | — | Opaque cursor from a prior response's next_cursor. Omit for the first page. |
Response
200 OK
{
"data": [
{
"id": "del_01J...",
"event_type": "job.succeeded",
"status": "delivered",
"response_status": 200,
"attempt": 1,
"attempted_at": "2026-04-15T12:34:57Z"
},
{
"id": "del_01J...",
"event_type": "job.failed",
"status": "failed",
"response_status": 503,
"attempt": 3,
"attempted_at": "2026-04-15T11:00:00Z"
}
],
"has_more": false,
"next_cursor": null
}
| Field | Type | Description |
|---|---|---|
id | string | Delivery id (del_ prefix). |
event_type | string | The event that triggered the delivery (e.g. job.succeeded, job.failed). |
status | string | delivered, failed, or pending. |
response_status | integer | null | HTTP status code returned by your endpoint, or null if the request could not be sent (DNS failure, connection refused, SSRF block). |
attempt | integer | 1-indexed attempt counter; bumps on every retry. |
attempted_at | ISO 8601 | When this row was most recently attempted. For pending rows, the creation time. |
has_more | boolean | true if more rows exist past this page. |
next_cursor | string | null | Pass this back as cursor to fetch the next page. Omitted when has_more is false. |
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | unauthorized | API key missing, malformed, or invalid. |
| 402 | billing_state_blocked | Project's billing state is not Active. Resolve via the dashboard billing portal. |
| 404 | webhook_not_found | Subscription does not exist or belongs to a different project. |
| 429 | rate_limit_exceeded | Per-project hourly request limit exceeded. Honour the Retry-After header. |
See also
- Monitoring guide — wire webhooks into PagerDuty, Slack, or your incident channel
- Errors —
webhook_not_found,invalid_signature, and other webhook-specific failure modes - Billing API — the Stripe webhook receiver shares the signature-verification pattern