Webhooks (outbound)
ScaiControl emits webhook events for every billing- and subscription-related state change. External systems (CRM, accounting bridges, dashboards) subscribe to topic patterns and receive signed HTTP POSTs.
Topic catalog#
Fourteen topics in the launch catalog. Full envelope/payload definitions in Events reference; the short version:
| Topic | Trigger |
|---|---|
tenant.billing_linked.v1 |
First creation of a tenant_billing_profiles row |
tenant.billing_updated.v1 |
Any subsequent change to billing-relevant fields |
partner.billing_linked.v1 |
A partner first gains legal_name + seller_country_code |
partner.billing_updated.v1 |
Any subsequent change to partner seller fields |
subscription.activated.v1 |
Subscription created (in active or trialing) |
subscription.changed.v1 |
Plan change, status change, scheduled-cancellation, renewal |
subscription.cancelled.v1 |
Reached terminal cancelled (immediate or after period end) |
subscription.suspended.v1 |
Active subscription paused |
subscription.resumed.v1 |
Suspended subscription brought back to active |
subscription.trial_ending.v1 |
Daily cron fires at 7/3/1 days remaining |
subscription.payment_failed.v1 |
A scheduled payment attempt failed |
pack_subscription.activated.v1 |
Pack subscription created |
pack_subscription.changed.v1 |
(Reserved — no producer yet) |
pack_subscription.cancelled.v1 |
Pack subscription cancelled |
Topic naming is <resource>.<event>.v<major>. Patches/MINORs to a topic add fields (backwards-compatible) without bumping the version; breaking changes increment the major and ship both versions in parallel for a transition window.
Envelope#
1 2 3 4 5 6 7 8 9 | |
event_type does NOT include the scaicontrol. prefix or the .vN suffix — those are constants in the envelope itself (source + event_version).
idempotency_key is stable across retries of the same logical event, derived from (resource_type, resource_id, event_type, lifecycle_step). Subscribers should use it as their inbox dedup key in addition to event_id.
Headers#
| Header | Value |
|---|---|
Content-Type |
application/json |
X-ScaiControl-Signature |
sha256=<hex> — HMAC-SHA256 of the raw body with the per-subscriber secret |
X-ScaiControl-Timestamp |
Unix seconds; 5-minute replay window |
X-ScaiControl-Event-Id |
UUID, mirrors event_id in the body |
X-ScaiControl-Event-Type |
Echo of event_type from the body |
User-Agent |
ScaiControl-Webhook/1.0 |
Delivery semantics#
- At-least-once. Subscribers must dedup on
event_idoridempotency_key. - Retry/backoff for 5xx / network errors: 1m → 5m → 30m → 2h → 12h → 24h, then dead-letter. 4xx (except 409) → dead-letter immediately. 409 → success (subscriber idempotent ack).
- Per-subscriber isolation. One subscriber's outage doesn't block deliveries to others — each delivery has its own outbox row with independent retry state.
- Replay window. Subscribers should reject events whose
X-ScaiControl-Timestampis more than 5 minutes from server time.
How the producer works#
- Domain code calls
emit_event(db, event_type="...", data={...}, idempotency_key="...")inside its transaction. A row is INSERTed intoevent_outboxwithstatus='pending'andsubscription_id=NULL— the "source row". - The
event_dispatcherarq cron (every 30 s) claims pending source rows, looks up matchingwebhook_subscriptions, and INSERTs one "delivery row" per match (same idempotency_key, withsubscription_idset). - Same dispatcher tick attempts delivery for each pending delivery row. HMAC-signed POST, 10 s timeout. Response code drives status.
A single logical event therefore lives in the outbox as:
- 1 source row (
subscription_id=NULL,status='dispatched'after fan-out) - N delivery rows, one per matching subscriber, each with independent retry timeline.
Subscriber management#
Subscribers are managed in the admin UI at /admin/webhook-subscriptions. Each row has:
name— operator-chosen labeltarget_url— HTTPS POST targetsecret(inline) orsecret_vault_path(ScaiVault) — HMAC keytopics— JSON array of glob patterns;subscription.*matches everything in that family;*is a wildcardis_active— temporarily disable without deleting
API: /admin/webhook-subscriptions (CRUD) — see Admin — webhook subscribers.
Topic-pattern matching#
Glob patterns use fnmatch semantics:
subscription.*matchessubscription.activated,subscription.cancelled, etc., but NOTpack_subscription.activated.pack_subscription.*matches only pack events.*matches everything.tenant.billing_linkedexact-matches one topic.
A subscriber with ["subscription.*", "pack_subscription.*", "tenant.*", "partner.*"] gets the full launch catalog. The seeded default for the ScaiCRM staging integration is just that.
Inbound webhooks#
Separate from outbound: ScaiControl also receives webhooks from payment providers (Stripe, Mollie, Crypto). Those land at /api/v1/webhooks/{provider} — see Inbound webhooks. They're verified by the provider's own signature scheme, not the HMAC convention used for outbound.