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

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.


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

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

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

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

ParamTypeDefaultDescription
limitinteger50Results per page (1–100).
cursorstringCursor 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
}
FieldTypeDescription
statusstringdelivered, failed, or pending.
response_statusintegerHTTP 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 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