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().


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