Partner Webhooks
Receive real-time events when your customers’ state changes (created, updated, released, adopted) — so your partner-side dashboard stays in sync without polling partner-list-children.
These webhooks are partner-level (fire to your partner endpoint) and are completely distinct from the per-child visitor identification webhooks (which fire to each child’s own webhook_url, set when you create the child). One partner — one webhook URL — many events.
Setup
1. Set your webhook URL
curl -X POST https://api.warmai.uk/functions/v1/partner-set-webhook \
-H "x-access-key: warm_YOUR_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.your-product.com/warmai-partner-webhook"
}'Response (200):
{
"success": true,
"partner_webhook_url": "https://api.your-product.com/warmai-partner-webhook",
"partner_webhook_secret": "whsec_a1b2c3d4e5f607182930405160708091a2b3c4d5",
"secret_unchanged": false
}partner_webhook_secret is returned once and only when newly minted. Store it immediately — there’s no read endpoint and you’ll need it to verify signatures.
2. Optional: supply your own secret
If you want to control the secret yourself (e.g. centrally rotate across many providers), pass it in the body:
curl -X POST https://api.warmai.uk/functions/v1/partner-set-webhook \
-H "x-access-key: warm_YOUR_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.your-product.com/warmai-partner-webhook",
"secret": "your-32-char-min-shared-secret-here"
}'Secret must be at least 8 characters.
3. Rotate the secret
Pass "secret": null (explicit null, not omitted) to mint a fresh one:
curl -X POST https://api.warmai.uk/functions/v1/partner-set-webhook \
-H "x-access-key: warm_YOUR_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.your-product.com/warmai-partner-webhook",
"secret": null
}'Cut over your verifier to the new secret as soon as you receive it — events between rotate and cutover will fail signature verification on your end.
4. Clear
Pass "url": null to disable webhooks entirely (clears both URL and secret):
curl -X POST https://api.warmai.uk/functions/v1/partner-set-webhook \
-H "x-access-key: warm_YOUR_PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": null }'Events
| Event | Fired by | Description |
|---|---|---|
child.created | partner-create-child-key | A new customer was provisioned |
child.updated | partner-update-child | A customer’s name, quota, status, rate, or period was changed (payload delta lists what) |
child.released | partner-release-child | A customer was released back to your direct account; their topup balance was folded back into yours |
child.adopted | partner-adopt-existing-key | One of your existing direct trackers was converted into a managed customer |
child.suspended | identification burn that exhausts the customer | The customer just used the last ID available to them (ids_used_this_period reached monthly_id_quota AND topup_balance is 0). Tracker pages keep loading on their site but no further identifications happen until they’re reactivated. Fires once per active→suspended transition (not on every blocked burn afterwards). |
child.reactivated | partner-update-child quota bump on a suspended customer, OR automatic period roll-over | The customer is back in service. Payload includes a reason field so you know which path triggered it. |
child.period_rolled | automatic hourly cron when current_period_end <= now() | The customer’s 30-day billing period has rolled over. ids_used_this_period resets to 0 and current_period_end advances by another 30 days. If they were suspended, this triggers an additional child.reactivated with reason: "period_rolled" (paired event, fired in the same cron tick). |
child.topup_changed | Stripe topup checkout / invoice (purchase path) OR identification consumption that drained from topup (throttled — see below) | The customer’s topup_balance changed. Payload includes the delta, the previous_topup_balance, the current topup_balance, and a reason (purchase, consumption, gift, transfer). On a purchase that lifts a suspended customer back into burnable territory, a paired child.reactivated with reason: "topup_purchased" fires in the same SQL transaction. |
The full lifecycle event surface is live. No polling required for any state change today — partner-list-children is for bootstrapping your mirror, webhooks keep it in sync.
Payload format
All partner webhook payloads use the standard envelope:
{
"event": "child.created",
"timestamp": "2026-05-31T15:30:00.000Z",
"data": {
"child_api_key_id": "aeec6fcf-7138-4742-ac4e-0adc0221c737",
"child_user_id": "8a4c1d3b-9e7f-4a2b-b0c1-2d3e4f5a6b7c",
"name": "Acme Corp",
"domain": "acme.com",
"tracking_script_id": "12345678-90ab-cdef-1234-567890abcdef",
"monthly_id_quota": 1000,
"current_period_end": "2026-06-30T12:00:00.000Z",
"sub_status": "active",
"has_webhook": true
}
}The data shape depends on the event type:
child.created
{
"child_api_key_id": "uuid",
"child_user_id": "uuid",
"name": "string",
"domain": "string",
"tracking_script_id": "uuid",
"monthly_id_quota": 1000,
"current_period_end": "iso8601",
"sub_status": "active",
"has_webhook": true
}child.updated
The delta field lists only the keys that changed in this call — useful for partial-update mirroring without diffing against your last snapshot. All other fields reflect the post-update state.
{
"child_api_key_id": "uuid",
"delta": {
"monthly_id_quota": 5000,
"id_price_pence_override": 4.5
},
"name": "string",
"status": "active",
"monthly_id_quota": 5000,
"ids_used_this_period": 0,
"topup_balance": 0,
"current_period_end": "iso8601",
"sub_status": "active",
"id_price_pence_override": 4.5
}child.released
{
"child_api_key_id": "uuid",
"topup_returned": 127
}child.adopted
Same shape as child.created.
child.suspended
{
"child_api_key_id": "uuid",
"ids_used_this_period": 1000,
"monthly_id_quota": 1000,
"topup_balance": 0,
"reason": "quota_exhausted"
}The reason field is currently always "quota_exhausted" for child.suspended — there’s only one suspension path today (exhausting quota mid-burn).
child.reactivated
{
"child_api_key_id": "uuid",
"ids_used_this_period": 0,
"monthly_id_quota": 5000,
"topup_balance": 0,
"reason": "quota_increased"
}The reason field tells you which path triggered the reactivation. Possible values:
| Reason | Trigger |
|---|---|
quota_increased | Partner called partner-update-child to bump monthly_id_quota |
period_rolled | The subscription period rolled over and ids_used_this_period reset to 0 |
topup_purchased | A topup purchase added credits to topup_balance |
child.period_rolled
{
"child_api_key_id": "uuid",
"previous_period_end": "2026-05-31T17:25:05.058Z",
"new_period_end": "2026-06-30T17:25:08.261Z",
"monthly_id_quota": 1000,
"ids_used_this_period": 0,
"topup_balance": 423
}Fires when the 30-day billing period rolls over. An hourly cron sweeps for any partner-managed child whose current_period_end has passed, advances it by another 30 days (anchored at GREATEST(previous_period_end, now()) — so a stale child catches up to “now + 30d” rather than chasing past cadence), and resets ids_used_this_period to 0.
If the child was suspended at roll-over time, a paired child.reactivated event with reason: "period_rolled" fires in the same cron tick. So a partner integration receives two events for a “previously-suspended customer’s period rolling”: the child.period_rolled first, then the child.reactivated.
child.topup_changed
{
"child_api_key_id": "uuid",
"previous_topup_balance": 0,
"topup_balance": 5000,
"delta": 5000,
"reason": "purchase"
}Fires when a customer’s topup_balance changes. Two emission paths:
- Purchase (Stripe checkout / invoice with
topupmetadata) — always fires immediately, no throttle.reasonis"purchase"(or"gift"/"transfer"once KAN-76’s partner→child topup transfer ships). - Consumption (identification burn that drained from
topup_balancerather than monthly quota) — throttled at most once per child per 5 minutes. Thedeltafield reflects only the most recent burn (not the summed change across the throttle window) so partners doing accurate counting should treattopup_balance(the current absolute value) as the source of truth and diff against their last snapshot.reasonis"consumption".
If a purchase event lifts a previously-suspended customer back into burnable territory (i.e. is_suspended was true and now topup_balance > 0), a paired child.reactivated with reason: "topup_purchased" fires in the same SQL transaction. As with period-rolled reactivations, partners receive two events for this flow.
Headers
| Header | Description |
|---|---|
X-Warm-Event | The event value from the payload (child.created etc.) |
X-Warm-Timestamp | Unix milliseconds when the event was dispatched |
X-Warm-Signature | HMAC-SHA256 of ${timestamp}.${rawBody} using your partner_webhook_secret |
Content-Type | application/json |
Verifying signatures
Identical scheme to the per-customer identification webhooks. Compute the HMAC-SHA256 of ${timestamp}.${rawBody} using your partner_webhook_secret and compare against X-Warm-Signature:
import crypto from "node:crypto"
function verifyPartnerWebhook(rawBody: string, headers: Headers, secret: string): boolean {
const sig = headers.get("X-Warm-Signature")
const timestamp = headers.get("X-Warm-Timestamp")
if (!sig || !timestamp) return false
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}Always verify against the raw body (the unparsed request bytes), not against JSON.stringify(parsedBody) — JSON serialisation isn’t byte-stable and timingSafeEqual will fail intermittently.
In Express: express.raw({ type: 'application/json' }) then verify, then parse. In Next.js App Router: read request.text() first, verify, then JSON.parse().
Delivery semantics
- Synchronous best-effort. When you call a
partner-*mutating endpoint, we enqueue the event and trigger delivery in the same request — but the endpoint’s success response does not block on your webhook responding. If your endpoint is slow or down, the partner API call still succeeds; the event is marked as failed and waits for a retry. - Timeout. Your endpoint has 10 seconds to respond. Anything beyond that is treated as a failure.
- Retries. Failed deliveries get up to 3 retries with exponential backoff (1 min, 5 min, 30 min) on top of the initial attempt — so 4 total attempts per event. A background sweep runs every minute, so a retry “scheduled for 5 minutes from now” will fire within ~1 minute of its scheduled time. After the 3rd retry, the event sits in
delivery_status='failed'and won’t be re-attempted automatically. - Rate-limited. The retry sweep is rate-capped: max 5 retried events per webhook URL per minute. So if your endpoint comes back online after an outage that accumulated 100 failed events, we drain at ~5/min over ~20 minutes rather than bombing your endpoint in one burst. Initial delivery (the first attempt right after the partner API action) is not rate-limited — it fires immediately.
- Order. No ordering guarantees — events are dispatched in fire-and-forget mode and may arrive out of order if you have high volume. Use the
timestampfield in the payload to reconcile. - At-least-once. A 5xx or timeout from your endpoint will be retried, so design for idempotency on your side. The combination
${child_api_key_id}-${event}-${timestamp}is unique enough to dedupe on. - Replay. A
partner-list-failed-webhooksendpoint for fetching dead-lettered events andpartner-replay-webhookfor self-service replay are on the roadmap (low priority — most partners won’t need them). Until they ship, contact support to manually replay specific events.
What’s next
- Endpoints — full reference for every
partner-*endpoint. - Per-customer visitor-identification webhooks (the
visitor_identifiedevent delivered to each child’swebhook_url) use the same envelope and signing scheme as the partner webhooks above. Dedicated reference docs for that flow are being rewritten — emailsupport@getwarmai.comif you need details now.