Skip to main content

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 failed immediately 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 typeFired when
job.createdA new job is enqueued.
job.succeededThe job completes successfully.
job.failedThe 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:

HeaderDescription
X-Zeridion-SignatureHMAC-SHA256 signature in the form t=<unix_timestamp>,v1=<hex_digest> — see HMAC-SHA256 signature.
X-Zeridion-Event-IdUnique 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-TypeThe event type (e.g. job.succeeded), mirroring the event_type field in the body. Lets you route without parsing the payload.
User-AgentZeridion-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.

Eventdata fields
job.createdschema_version, job_id, queue
job.succeededschema_version, job_id, project_id, job_type, queue, state (= "succeeded"), duration_ms, completed_at
job.failedschema_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​

StatusCodeCondition
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
429rate_limit_exceededPer-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​

StatusCodeCondition
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
404webhook_not_foundSubscription does not exist or belongs to a different project.
429rate_limit_exceededPer-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​

FieldTypeRequiredDescription
urlstringyesThe HTTPS endpoint that will receive POST deliveries. Max 2048 characters.
eventsstring[] or "*"yesArray 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​

StatusCodeCondition
400invalid_requestBody 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.
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
429rate_limit_exceededPer-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.

FieldTypeDescription
urlstringNew target URL.
eventsstring[] or "*"New event filter.
is_activebooleanEnable or disable the subscription.

Response​

200 OK — returns the updated subscription object (without secret).

Errors​

StatusCodeCondition
400invalid_requestBody 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.
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
404webhook_not_foundSubscription does not exist or belongs to a different project.
429rate_limit_exceededPer-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"
}
FieldTypeDescription
idstringThe subscription id. Unchanged by rotation.
secretstringNew plaintext HMAC signing secret — returned only once. Store it immediately; subsequent reads of the subscription will never expose either secret.
previous_secret_expires_atISO 8601When 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_atISO 8601When the rotation was recorded.

Errors​

StatusCodeCondition
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
404webhook_not_foundSubscription does not exist or belongs to a different project.
429rate_limit_exceededPer-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​

StatusCodeCondition
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
404webhook_not_foundSubscription does not exist or belongs to a different project.
429rate_limit_exceededPer-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​

ParamTypeDefaultDescription
limitinteger50Results per page (1–100). Values outside the range are clamped.
cursorstring—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
}
FieldTypeDescription
idstringDelivery id (del_ prefix).
event_typestringThe event that triggered the delivery (e.g. job.succeeded, job.failed).
statusstringdelivered, failed, or pending.
response_statusinteger | nullHTTP status code returned by your endpoint, or null if the request could not be sent (DNS failure, connection refused, SSRF block).
attemptinteger1-indexed attempt counter; bumps on every retry.
attempted_atISO 8601When this row was most recently attempted. For pending rows, the creation time.
has_morebooleantrue if more rows exist past this page.
next_cursorstring | nullPass this back as cursor to fetch the next page. Omitted when has_more is false.

Errors​

StatusCodeCondition
401unauthorizedAPI key missing, malformed, or invalid.
402billing_state_blockedProject's billing state is not Active. Resolve via the dashboard billing portal.
404webhook_not_foundSubscription does not exist or belongs to a different project.
429rate_limit_exceededPer-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