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().
Python
import hmac, hashlib
def verify_partner_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if not signature or not timestamp:
return False
expected = hmac.new(
secret.encode(),
timestamp.encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)In Flask, read request.get_data() (bytes) rather than request.json. In FastAPI, await request.body().
Zapier: use Catch Raw Hook, not Catch Hook
If you receive these webhooks in Zapier, you must use the Catch Raw Hook trigger. The ordinary Catch Hook trigger parses the JSON and hands your steps a re-serialised copy, so the bytes you can reach are no longer the bytes we signed. Key order and whitespace change, the HMAC will not match, and every single delivery fails verification.
This is the most common integration failure we see, and it looks like a wrong secret rather than a body problem. Catch Raw Hook gives you the untouched body in raw_body. Verify against that, then parse.
The same trap applies to any middleware that parses before you verify: API gateways, ngrok inspectors that re-emit, and logging proxies. Verify first, parse second, always.
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.