Skip to content

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: POST with JSON body.

  • X-PlayFlow-Idempotency-Key carries the claim's idempotency_key, the same as in the body.

  • Signed with X-RetentionPlay-Signature: t=<unix>,v=<version>,v1=<hex_hmac> and the legacy-compatible X-PlayFlow-Signature header (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:

json
{
  "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"
}
FieldMeaning
eventAlways reward.eligible
payload_versionShape version of this body (2). Missing on deliveries queued before versioning — treat as 1
project_id, campaign_idThe project and the campaign that fired
external_user_idYour user id; anonymized in deliveries still queued when the player's data was erased
reward_type, reward_metadataWhat to grant, as configured on the campaign
triggerThe campaign trigger, for example game_won
idempotency_keyThe claim's key — grant at most once per key
timestampWhen the delivery was queued
run_id, mechanic, level_numberOnly 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:

  1. Parse t, v (the secret version — may be absent on very old deliveries, treat that as version 1), and v1 from X-RetentionPlay-Signature (or legacy X-PlayFlow-Signature).
  2. Reject if abs(now - t) exceeds your skew window (300s recommended) — replay protection.
  3. Compute hmac_sha256("{t}.{body}", secret) for the secret matching v and constant-time compare to v1.

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.

text
# example header
X-RetentionPlay-Signature: t=1750000000,v=1,v1=9f1c...e4
X-PlayFlow-Signature: t=1750000000,v=1,v1=9f1c...e4

Testing 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.

Internal & integration documentation