Skip to main content

Webhooks

Zeridion Flare supports two kinds of webhooks:

  • Outbound webhooks — Zeridion pushes job lifecycle events to your server. Managed via the /flare/v1/webhooks API (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 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.startedA worker picks up the job.
job.succeededThe job completes successfully.
job.failedAn attempt fails (retries may follow).
job.dead_letterThe job has exhausted all attempts.
job.cancelledThe job is cancelled via the API.
job.retryA 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:

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

{
"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.

EventRequired fieldsOptional fields
job.createdid, state (= "pending" or "scheduled"), job_type, queue, attempt (= 0), created_atscheduled_at, parent_job_id, tags
job.startedid, state (= "processing"), job_type, queue, attempt, worker_id, started_attags
job.succeededid, state (= "succeeded"), job_type, queue, attempt, duration_ms, completed_attags, progress (final value, typically 1.0)
job.failedid, state (= "failed"), job_type, queue, attempt, error_message, error_code, completed_atduration_ms, next_retry_at, tags
job.dead_letterid, state (= "dead_letter"), job_type, queue, attempt (= max_attempts), error_message, error_code, completed_atduration_ms, tags
job.cancelledid, state (= "cancelled"), job_type, queue, attempt, cancelled_atcancelled_reason, tags
job.retryid, state (= "pending" or "scheduled"), job_type, queue, attempt (resets to retry attempt number), retried_byscheduled_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

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

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

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 eventAction
bounceAdds suppression with reason bounce.
droppedAdds suppression with reason bounce.
spamreportAdds suppression with reason spam.
unsubscribeAdds suppression with reason unsubscribe.
invalidAdds 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

StatusCodeWhen
400missing_signatureX-Twilio-Email-Event-Webhook-Signature or X-Twilio-Email-Event-Webhook-Timestamp header is absent.
400invalid_signatureECDSA signature verification failed.
400invalid_payloadRequest 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

  1. Open your SendGrid dashboard and go to Settings → Mail Settings → Event Webhook.
  2. Set the HTTP Post URL to https://api.zeridion.com/platform/v1/webhooks/sendgrid.
  3. Enable Signature Verification and copy the generated ECDSA public key.
  4. Store the public key in your Azure Key Vault (or app configuration) as SendGrid:WebhookPublicKey.
  5. Select at minimum: Bounces, Dropped, Spam Reports, Unsubscribes, Invalid Emails.
  6. Save. SendGrid will immediately send a test event — the endpoint returns 200 with {"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 eventAction
checkout.session.completedLinks the Stripe subscription to the user's project; applies the new plan and limits.
customer.subscription.updatedUpdates the project's plan and limits when the subscription changes (upgrade, downgrade, trial end).
customer.subscription.deletedDowngrades the project to the Free plan when the subscription is cancelled or lapses.
customer.subscription.trial_will_endMarks the user's trial-end date so the dashboard can warn the customer 3 days before charges begin.
customer.deletedClears the linked Stripe customer reference on the user. The project stays on its current plan until the subscription is also resolved.
invoice.payment_failedLogs a payment failure for the Stripe customer. Used for alerting and dunning.
invoice.paidRecords the paid invoice (with hosted/PDF URLs) so the customer can fetch receipts from the dashboard.
invoice.finalizedSame 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

StatusCodeWhen
400missing_signatureStripe-Signature header is absent.
400invalid_signatureHMAC signature verification failed (wrong secret or tampered body).

Setting up in Stripe

  1. Open your Stripe dashboardDevelopers → Webhooks.
  2. Click Add endpoint and set the URL to https://api.zeridion.com/platform/v1/billing/webhook.
  3. Select the following events to listen for:
    • checkout.session.completed
    • customer.subscription.updated
    • customer.subscription.deleted
    • customer.subscription.trial_will_end
    • customer.deleted
    • invoice.payment_failed
    • invoice.paid
    • invoice.finalized
  4. After saving, reveal the Signing secret (starts with whsec_).
  5. Store it as Stripe:WebhookSecret in 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
  • Errorswebhook_not_found, invalid_signature, and other webhook-specific failure modes
  • Billing API — the Stripe webhook receiver shares the signature-verification pattern