Webhooks
When a campaign trigger fires under a reward-bearing game_mode, RetentionPlay records a claim and POSTs a signed webhook to the project (or campaign) webhook_url. Use webhooks to grant rewards in your own system.
Delivery
Method:
POSTwith JSON body.X-PlayFlow-Idempotency-Keycarries the claim'sidempotency_key, the same as in the body.Signed with
X-RetentionPlay-Signature: t=<unix>,v=<version>,v1=<hex_hmac>and the legacy-compatibleX-PlayFlow-Signatureheader (identical value).Retried with backoff on non-2xx or a network failure — up to 6 attempts over roughly 32 hours (1min, 5min, 30min, 2h, 6h, 24h) before the delivery is marked dead and surfaced in the admin Webhooks panel with a manual retry action.
Reward callbacks carry idempotency keys; deliveries can repeat (including after a manual retry), so make your handler idempotent.
Payload
Every reward delivery is a reward.eligible event:
{
"campaign_id": "camp_1",
"event": "reward.eligible",
"external_user_id": "user-42",
"idempotency_key": "camp_1:user-42",
"level_number": 3,
"mechanic": "match3_v1",
"payload_version": 2,
"project_id": "proj_demo",
"reward_metadata": {
"code": "WIN10"
},
"reward_type": "promo_code",
"run_id": "00000000-0000-0000-0000-000000000007",
"timestamp": "2026-10-01T12:00:00+00:00",
"trigger": "game_won"
}| Field | Meaning |
|---|---|
event | Always reward.eligible |
payload_version | Shape version of this body (2). Missing on deliveries queued before versioning — treat as 1 |
project_id, campaign_id | The project and the campaign that fired |
external_user_id | Your user id; anonymized in deliveries still queued when the player's data was erased |
reward_type, reward_metadata | What to grant, as configured on the campaign |
trigger | The campaign trigger, for example game_won |
idempotency_key | The claim's key — grant at most once per key |
timestamp | When the delivery was queued |
run_id, mechanic, level_number | Only for rewards for a game run (trigger: "game_won"); level_number only when the run had a level number. Absent otherwise |
- Verify the signature over the raw body bytes before parsing JSON; key order is not guaranteed.
- Ignore fields you do not know: new optional fields are added without a new event name.
Getting your signing secret
Unlike a typical webhook integration, you never choose this secret yourself. It's derived per project and versioned, and you obtain it from the admin API:
POST /v1/admin/projects/{project_id}/webhook-secret/reveal— returns the current{ "secret": ..., "version": ... }. Store the secret; you'll need the version too (see below).POST /v1/admin/projects/{project_id}/webhook-secret/rotate— generates a new secret and bumps the version. Deliveries already in the retry queue automatically re-sign with your new secret on their next attempt — you don't need to keep the old one around once you've updated your endpoint.
Signature verification
The signed string is "{timestamp}.{raw_request_body}", HMAC-SHA256 with your project's current signing secret. Steps:
- Parse
t,v(the secret version — may be absent on very old deliveries, treat that as version 1), andv1fromX-RetentionPlay-Signature(or legacyX-PlayFlow-Signature). - Reject if
abs(now - t)exceeds your skew window (300s recommended) — replay protection. - Compute
hmac_sha256("{t}.{body}", secret)for the secret matchingvand constant-time compare tov1.
If you keep your previous secret around for a short grace period after rotating (recommended: match the ~24h window above), you can accept a signature computed with either version during that window and fall back to rejecting once it has passed — the same policy the platform's own test-inbox receiver enforces on itself.
Use the SDK helpers: PHP Webhook::verify, Python verify_webhook.
# example header
X-RetentionPlay-Signature: t=1750000000,v=1,v1=9f1c...e4
X-PlayFlow-Signature: t=1750000000,v=1,v1=9f1c...e4Testing without your endpoint
The admin Webhooks panel provides an inbox URL: temporarily set it as the project webhook_url, trigger a flow, and inspect the received, signed payload.
Idempotency on your side
Key your reward grant on idempotency_key (also in X-PlayFlow-Idempotency-Key) and ignore repeats. RetentionPlay derives it from campaign, player and reward source. Retries and manual re-sends may deliver the same webhook more than once. run_id identifies a run, not a grant's retry key.
Reopening a game, switching the project's selected mechanic, or receiving a browser run_finished message is not a new reward grant. Use the server-issued idempotency_key for fulfilment even when the UI repeats its result or the notification arrives after a game switch.
Server-confirmed outcomes
For material rewards, the browser cannot self-confirm. Your backend confirms the outcome with POST /v1/server/events (API key, Idempotency-Key required for promo outcomes). For game-run games, the server computes commands and creates the claim on a confirmed win; no additional event confirms it. The resulting reward triggers the webhook. See security.