Webhooks
Zeridion Flare supports two kinds of webhooks:
- Outbound webhooks — Zeridion pushes job lifecycle events to your server. Managed via the
/flare/v1/webhooksAPI (API-key auth, tenant-scoped). - Inbound webhooks — Zeridion receives events from external providers (SendGrid, Stripe). These are anonymous, provider-signature-verified endpoints on
/platform/v1.
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.started | A worker picks up the job. |
job.succeeded | The job completes successfully. |
job.failed | An attempt fails (retries may follow). |
job.dead_letter | The job has exhausted all attempts. |
job.cancelled | The job is cancelled via the API. |
job.retry | A manual retry is triggered via the API. |
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 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:
{
"id": "wh_evt_01J...",
"type": "job.succeeded",
"created_at": "2026-04-15T12:34:56Z",
"data": {
"id": "job_01J...",
"state": "succeeded",
"job_type": "email.send",
"queue": "email",
"attempt": 1,
"duration_ms": 240
}
}
Per-event-type data payload
Every event shares the same envelope (id, type, created_at, data); the data object varies by event type. Fields marked required are always present; fields marked optional may be absent or null.
| Event | Required fields | Optional fields |
|---|---|---|
job.created | id, state (= "pending" or "scheduled"), job_type, queue, attempt (= 0), created_at | scheduled_at, parent_job_id, tags |
job.started | id, state (= "processing"), job_type, queue, attempt, worker_id, started_at | tags |
job.succeeded | id, state (= "succeeded"), job_type, queue, attempt, duration_ms, completed_at | tags, progress (final value, typically 1.0) |
job.failed | id, state (= "failed"), job_type, queue, attempt, error_message, error_code, completed_at | duration_ms, next_retry_at, tags |
job.dead_letter | id, state (= "dead_letter"), job_type, queue, attempt (= max_attempts), error_message, error_code, completed_at | duration_ms, tags |
job.cancelled | id, state (= "cancelled"), job_type, queue, attempt, cancelled_at | cancelled_reason, tags |
job.retry | id, state (= "pending" or "scheduled"), job_type, queue, attempt (resets to retry attempt number), retried_by | scheduled_at, previous_error_code, tags |
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", "job.dead_letter"]
}
Response
201 Created
{
"id": "whk_01J...",
"url": "https://your-app.example.com/hooks/zeridion",
"events": ["job.succeeded", "job.failed", "job.dead_letter"],
"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. |
Inbound webhooks
Base URL: https://api.zeridion.com/platform/v1
POST /platform/v1/webhooks/sendgrid
Receives SendGrid Event Webhook payloads and records email suppressions. When an address bounces, unsubscribes, or marks mail as spam, Zeridion suppresses future transactional mail to that address automatically.
Authentication: Anonymous. Signature-verified using ECDSA P-256 (SendGrid Event Webhook Signature Verification).
Supported event types
| SendGrid event | Action |
|---|---|
bounce | Adds suppression with reason bounce. |
dropped | Adds suppression with reason bounce. |
spamreport | Adds suppression with reason spam. |
unsubscribe | Adds suppression with reason unsubscribe. |
invalid | Adds suppression with reason invalid. |
All other event types (e.g. open, click, delivered) are accepted and acknowledged but produce no side effects.
Request
POST /platform/v1/webhooks/sendgrid
X-Twilio-Email-Event-Webhook-Signature: <base64-encoded ECDSA signature>
X-Twilio-Email-Event-Webhook-Timestamp: <Unix timestamp>
Content-Type: application/json
SendGrid sends a JSON array of event objects. Each object must contain at minimum event and email fields.
[
{
"email": "user@example.com",
"timestamp": 1715000000,
"event": "bounce",
"type": "bounce",
"reason": "550 5.1.1 The email account does not exist."
},
{
"email": "other@example.com",
"timestamp": 1715000001,
"event": "spamreport"
}
]
Response
200 OK
{
"received": true,
"suppressed": 2
}
suppressed is the count of addresses upserted into the suppression list for this request. Repeated delivery of the same event for the same address is idempotent — it updates the stored timestamp and raw event but does not create duplicate rows.
Error responses
| Status | Code | When |
|---|---|---|
| 400 | missing_signature | X-Twilio-Email-Event-Webhook-Signature or X-Twilio-Email-Event-Webhook-Timestamp header is absent. |
| 400 | invalid_signature | ECDSA signature verification failed. |
| 400 | invalid_payload | Request body is not a valid JSON array. |
:::note Signature verification in dev
Signature verification is only enforced when SendGrid:WebhookPublicKey is configured. In local development (without this setting), any payload is accepted. Never skip the key in production.
:::
Setting up in SendGrid
- Open your SendGrid dashboard and go to Settings → Mail Settings → Event Webhook.
- Set the HTTP Post URL to
https://api.zeridion.com/platform/v1/webhooks/sendgrid. - Enable Signature Verification and copy the generated ECDSA public key.
- Store the public key in your Azure Key Vault (or app configuration) as
SendGrid:WebhookPublicKey. - Select at minimum: Bounces, Dropped, Spam Reports, Unsubscribes, Invalid Emails.
- Save. SendGrid will immediately send a test event — the endpoint returns
200with{"received":true,"suppressed":0}for event types that are not in the suppression list.
POST /platform/v1/billing/webhook
Receives Stripe webhook events and updates subscription state, plan limits, and payment status.
Authentication: Anonymous. Signature-verified using the Stripe-Signature header and your webhook signing secret.
Idempotency is enforced: each Stripe event ID is stored after first processing. Duplicate deliveries (Stripe retries on non-2xx) are detected and silently ignored.
Supported event types
| Stripe event | Action |
|---|---|
checkout.session.completed | Links the Stripe subscription to the user's project; applies the new plan and limits. |
customer.subscription.updated | Updates the project's plan and limits when the subscription changes (upgrade, downgrade, trial end). |
customer.subscription.deleted | Downgrades the project to the Free plan when the subscription is cancelled or lapses. |
customer.subscription.trial_will_end | Marks the user's trial-end date so the dashboard can warn the customer 3 days before charges begin. |
customer.deleted | Clears the linked Stripe customer reference on the user. The project stays on its current plan until the subscription is also resolved. |
invoice.payment_failed | Logs a payment failure for the Stripe customer. Used for alerting and dunning. |
invoice.paid | Records the paid invoice (with hosted/PDF URLs) so the customer can fetch receipts from the dashboard. |
invoice.finalized | Same record as invoice.paid, but for the moment the invoice is issued — used to surface upcoming charges before payment posts. |
All other event types are acknowledged with 200 OK but produce no side effects.
Request
POST /platform/v1/billing/webhook
Stripe-Signature: t=<timestamp>,v1=<HMAC-SHA256>
Content-Type: application/json
The request body must be the raw Stripe event JSON — do not parse or re-encode it before forwarding, as this invalidates the signature.
Response
200 OK
{
"received": true
}
Error responses
| Status | Code | When |
|---|---|---|
| 400 | missing_signature | Stripe-Signature header is absent. |
| 400 | invalid_signature | HMAC signature verification failed (wrong secret or tampered body). |
Setting up in Stripe
- Open your Stripe dashboard → Developers → Webhooks.
- Click Add endpoint and set the URL to
https://api.zeridion.com/platform/v1/billing/webhook. - Select the following events to listen for:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedcustomer.subscription.trial_will_endcustomer.deletedinvoice.payment_failedinvoice.paidinvoice.finalized
- After saving, reveal the Signing secret (starts with
whsec_). - Store it as
Stripe:WebhookSecretin your environment configuration (or Azure Key Vault).
:::tip Local testing Use the Stripe CLI to forward events to your local dev server:
stripe listen --forward-to http://localhost:5100/platform/v1/billing/webhook
The CLI prints a temporary whsec_... secret — set it as Stripe:WebhookSecret in appsettings.Development.json for the session.
:::
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