Skip to Content
Partner APIWebhooks

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

EventFired byDescription
child.createdpartner-create-child-keyA new customer was provisioned
child.updatedpartner-update-childA customer’s name, quota, status, rate, or period was changed (payload delta lists what)
child.releasedpartner-release-childA customer was released back to your direct account; their topup balance was folded back into yours
child.adoptedpartner-adopt-existing-keyOne of your existing direct trackers was converted into a managed customer
child.suspendedidentification burn that exhausts the customerThe 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.reactivatedpartner-update-child quota bump on a suspended customer, OR automatic period roll-overThe customer is back in service. Payload includes a reason field so you know which path triggered it.
child.period_rolledautomatic 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_changedStripe 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:

ReasonTrigger
quota_increasedPartner called partner-update-child to bump monthly_id_quota
period_rolledThe subscription period rolled over and ids_used_this_period reset to 0
topup_purchasedA 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 topup metadata) — always fires immediately, no throttle. reason is "purchase" (or "gift" / "transfer" once KAN-76’s partner→child topup transfer ships).
  • Consumption (identification burn that drained from topup_balance rather than monthly quota) — throttled at most once per child per 5 minutes. The delta field reflects only the most recent burn (not the summed change across the throttle window) so partners doing accurate counting should treat topup_balance (the current absolute value) as the source of truth and diff against their last snapshot. reason is "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

HeaderDescription
X-Warm-EventThe event value from the payload (child.created etc.)
X-Warm-TimestampUnix milliseconds when the event was dispatched
X-Warm-SignatureHMAC-SHA256 of ${timestamp}.${rawBody} using your partner_webhook_secret
Content-Typeapplication/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 timestamp field 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-webhooks endpoint for fetching dead-lettered events and partner-replay-webhook for 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_identified event delivered to each child’s webhook_url) use the same envelope and signing scheme as the partner webhooks above. Dedicated reference docs for that flow are being rewritten — email support@getwarmai.com if you need details now.
Last updated on