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 with exponential back-off, so a single failing event can occupy roughly ~50 seconds of total wall-clock before it is finally marked
failed. Plan your receiver's timeouts and idempotency around that envelope.
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.
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.
:::info Planned for a future release
The outbound webhook CRUD API (GET/POST/PATCH/DELETE /flare/v1/webhooks and GET /flare/v1/webhooks/{id}/deliveries) is not yet live. Signing and verification helpers in the SDKs (Webhook.Verify, verifyWebhook) are functional today against any HMAC payload you generate — subscription management is via the dashboard until this surface ships.
The shapes below are the planned wire contract and will not change before launch. :::
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": "wh_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.
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": "wh_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)
When the rotation endpoint ships, calling it will generate a fresh secret and hold 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. Until the rotation endpoint is live, the workaround is to create a parallel subscription with the new URL+secret, switch traffic, then delete the old subscription.
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).
404 Not Found — subscription does not exist or belongs to a different project.
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
404 Not Found — subscription does not exist or belongs to a different project.
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). |
cursor | string | — | Cursor from next_cursor for the next page. |
Response
200 OK
{
"data": [
{
"id": "whd_01J...",
"event_type": "job.succeeded",
"status": "delivered",
"response_status": 200,
"attempt": 1,
"attempted_at": "2026-04-15T12:34:57Z",
"duration_ms": 84
},
{
"id": "whd_01J...",
"event_type": "job.failed",
"status": "failed",
"response_status": 503,
"attempt": 3,
"attempted_at": "2026-04-15T11:00:00Z",
"duration_ms": 10041
}
],
"has_more": false,
"next_cursor": null
}
| Field | Type | Description |
|---|---|---|
status | string | delivered, failed, or pending. |
response_status | integer | HTTP status code returned by your endpoint, or null if the request could not be sent. |
404 Not Found — subscription does not exist or belongs to a different project.
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