---
title: Webhooks (outbound)
path: concepts/webhooks
status: published
---

# 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](../reference/events/overview); 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
{
  "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](../reference/api/admin-webhook-subscriptions).

## 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](../reference/api/webhooks). They're verified by the provider's own signature scheme, not the HMAC convention used for outbound.
