Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

json
1
2
3
4
5
6
7
8
9
{
  "event_id": "<uuid>",
  "event_type": "subscription.activated",
  "event_version": "1.0",
  "occurred_at": "2026-05-10T14:32:11+00:00",
  "source": "scaicontrol",
  "idempotency_key": "subscription:<id>:activated:initial",
  "data": { /* per-topic shape; see Events reference */ }
}

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_id or idempotency_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-Timestamp is more than 5 minutes from server time.

How the producer works#

  1. Domain code calls emit_event(db, event_type="...", data={...}, idempotency_key="...") inside its transaction. A row is INSERTed into event_outbox with status='pending' and subscription_id=NULL — the "source row".
  2. The event_dispatcher arq cron (every 30 s) claims pending source rows, looks up matching webhook_subscriptions, and INSERTs one "delivery row" per match (same idempotency_key, with subscription_id set).
  3. 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 label
  • target_url — HTTPS POST target
  • secret (inline) or secret_vault_path (ScaiVault) — HMAC key
  • topics — JSON array of glob patterns; subscription.* matches everything in that family; * is a wildcard
  • is_active — temporarily disable without deleting

API: /admin/webhook-subscriptions (CRUD) — see Admin — webhook subscribers.

Topic-pattern matching#

Glob patterns use fnmatch semantics:

  • subscription.* matches subscription.activated, subscription.cancelled, etc., but NOT pack_subscription.activated.
  • pack_subscription.* matches only pack events.
  • * matches everything.
  • tenant.billing_linked exact-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.

Updated 2026-05-18 01:48:39 View source (.md) rev 2