Webhooks
Webhooks send an HTTPS request to your backend when something happens in a campaign. Use them to credit loyalty points, send a prize email, or keep your own records. Every request is signed, so you can check that it came from Keliplay.
Events
Section titled “Events”| Event | Sent when |
|---|---|
session_completed |
A player finishes a play. |
winner_selected |
A draw picks winners. This covers draws run by hand, after each play and on a schedule. |
reward_granted |
A player is given a coupon: at the end of a play, by a draw, or by hand through the API. |
Add an endpoint
Section titled “Add an endpoint”In the dashboard, open Settings, then Webhooks, and add the URL that should
receive events. Choose the events it should get. The URL must use https, and its
host must resolve to a public address when you save it.
When you save, the dashboard shows the endpoint’s signing secret, which starts
with whsec_. Copy it now: it is shown only once. Store it with your other
secrets.
You can also manage endpoints with the API. The secret is in the response to the create request only.
curl -X POST https://api.keliplay.com/v1/tenant/webhooks/endpoints \ -H "Authorization: Bearer $KELIPLAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/keliplay", "events": ["session_completed", "reward_granted"] }'{ "endpoint": { "id": "0199f0c2-5b1e-7a40-9d3c-2e6f1a8b4c70", "url": "https://hooks.example.com/keliplay", "events": ["session_completed", "reward_granted"], "gameInstanceIds": [], "channelIds": [], "status": "active", "secret": "whsec_Qm9vZ2llLXdvb2dpZS1leGFtcGxlLXNlY3JldA", "createdAt": "2026-10-05T09:12:40.118Z" }}Choose which campaigns an endpoint hears about
Section titled “Choose which campaigns an endpoint hears about”By default an endpoint gets events from every campaign. To narrow it down, set either list, or both:
channelIds: events from whatever campaign is running on these channels. This keeps working as campaigns change, so it is usually the one you want.gameInstanceIds: events from these campaigns only.
An event is sent if its campaign matches either list.
Receive coupon codes
Section titled “Receive coupon codes”By default a reward_granted event never carries the coupon code. If the endpoint
is the system that delivers coupons to players, for example by email or in your
app, you can turn on Include coupon codes for it in the dashboard, or send
"includeCouponCodes": true when you create or update it with the API. The code
then arrives as coupon.code.
- Turning it on needs the Add, generate and withdraw coupon codes permission as well as Manage webhook endpoints. Anyone can turn it off.
- It applies to that endpoint only. Other endpoints, such as one for analytics, keep receiving events without codes.
- Codes never appear in the delivery log in the dashboard.
- Anyone who can read requests to the URL can read the codes, so keep its logs as private as the codes themselves.
What a request looks like
Section titled “What a request looks like”Keliplay sends a POST with a JSON body:
POST /keliplay HTTP/1.1Host: hooks.example.comContent-Type: application/jsonX-Keliplay-Event: session_completedX-Keliplay-Delivery-Id: 0199f0c4-91aa-7c2e-8f10-6b3d2a9e5f14X-Keliplay-Signature: t=1791190331,v1=5f0c1e9a...
{ "type": "session_completed", "createdAt": "2026-10-05T09:32:11.204Z", "data": { ... }}| Header | Description |
|---|---|
X-Keliplay-Event |
The event type, the same as type in the body. |
X-Keliplay-Delivery-Id |
Unique to this delivery. It stays the same when a delivery is retried, so use it to ignore duplicates. |
X-Keliplay-Signature |
The timestamp and signature. See below. |
session_completed
Section titled “session_completed”{ "type": "session_completed", "createdAt": "2026-10-05T09:32:11.204Z", "data": { "gameInstanceId": "0199e8a1-4c2f-7b10-a3d4-5e6f7a8b9c0d", "sessionId": "0199f0c4-8e21-7d03-b5a6-1c2d3e4f5a6b", "participantRef": "user_8412", "score": 840, "outcome": null }}score is null for games that do not keep score. outcome is set for games with
an instant result, such as Spin the Wheel, and contains id, label and isWin.
winner_selected
Section titled “winner_selected”{ "type": "winner_selected", "createdAt": "2026-10-05T18:00:02.551Z", "data": { "gameInstanceId": "0199e8a1-4c2f-7b10-a3d4-5e6f7a8b9c0d", "runId": "0199f2a0-1b2c-7d3e-8f4a-5b6c7d8e9f01", "winners": [ { "participantRef": "user_8412", "rank": 1, "entryId": "0199f0c4-8e30-7a11-9b22-3c4d5e6f7a8b", "prize": { "label": "Free coffee", "externalRef": "SKU-COFFEE" } }, { "participantRef": "user_1907", "rank": 2, "entryId": "0199f0c9-02d4-7e55-8a66-7b8c9d0e1f2a", "prize": { "label": "Free coffee", "externalRef": "SKU-COFFEE" } } ] }}runId identifies the draw. prize is the campaign’s prize at the time of the
draw: its label, and its description and externalRef (your own reference,
such as a SKU) if you set them. It is null when the campaign has no prize set.
reward_granted
Section titled “reward_granted”{ "type": "reward_granted", "createdAt": "2026-10-05T09:32:11.377Z", "data": { "gameInstanceId": "0199e8a1-4c2f-7b10-a3d4-5e6f7a8b9c0d", "channelId": "0199e8a0-1b2c-7d3e-8f4a-5b6c7d8e9f0a", "grantId": "0199f0c4-8f02-7c44-a1b2-3c4d5e6f7a8b", "participantRef": "user_8412", "source": "rule", "sessionId": "0199f0c4-8e21-7d03-b5a6-1c2d3e4f5a6b", "tierId": "0199e8b2-6d7e-7f80-9a1b-2c3d4e5f6a7b", "prize": { "title": "10% off", "description": "On your next order." }, "coupon": { "ref": "CRM-00417", "name": "Spring 10%" }, "expiresAt": "2026-11-04T09:32:11.000Z" }}| Field | Description |
|---|---|
participantRef |
The player, as you named them when you asked for their client token. |
channelId |
The channel the campaign runs on. |
tierId, prize |
Which prize this was, and its title and description. |
coupon.ref, coupon.name |
Your own reference and name for the coupon, from when you added it. null when it has none. Use ref to find the coupon in your own system. |
coupon.code |
The coupon code. Only sent to an endpoint with coupon codes turned on (see Receive coupon codes). |
expiresAt |
When the coupon stops being valid, or null for never. |
source says how the coupon was given:
| Source | Meaning | Extra fields |
|---|---|---|
rule |
The player qualified at the end of a play. | sessionId |
draw |
The player was picked by a draw. | runId, winnerId |
manual |
You gave it through the API. |
The coupon code is sent only to an endpoint that has
coupon codes turned on. Otherwise, to know which coupon a
player got, give each coupon a ref when you add it (see
Add codes with your own reference),
and match on coupon.ref.
Verify the signature
Section titled “Verify the signature”X-Keliplay-Signature looks like t=1791190331,v1=5f0c1e9a.... t is when the
request was signed, in Unix seconds. v1 is a hex HMAC-SHA256 of the timestamp, a
full stop, and the raw request body, keyed with your signing secret.
To check a request:
- Read the raw body exactly as received, before any JSON parsing.
- Compute the HMAC of
t + "." + bodywith your secret. - Compare it with
v1using a constant-time comparison. - Reject requests whose timestamp is more than five minutes from your clock, so an old request cannot be replayed.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyKeliplaySignature(rawBody, header, secret) { const parts = Object.fromEntries( (header ?? '').split(',').map((part) => part.split('=', 2)), ) const timestamp = Number(parts.t) const signature = parts.v1 if (!timestamp || !signature) return false
const age = Math.abs(Date.now() / 1000 - timestamp) if (age > TOLERANCE_SECONDS) return false
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex')
const a = Buffer.from(expected, 'hex') const b = Buffer.from(signature, 'hex') return a.length === b.length && crypto.timingSafeEqual(a, b)}With Express, keep the raw body for the webhook route:
app.post('/keliplay', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyKeliplaySignature( req.body.toString('utf8'), req.get('X-Keliplay-Signature'), process.env.KELIPLAY_WEBHOOK_SECRET, ) if (!ok) return res.status(400).end()
const event = JSON.parse(req.body) // Handle the event, then answer quickly. res.status(200).end()})import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_keliplay_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part) try: timestamp = int(parts["t"]) signature = parts["v1"] except (KeyError, ValueError): return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False
signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)With Flask, request.get_data() gives the raw body:
@app.post("/keliplay")def keliplay_webhook(): if not verify_keliplay_signature( request.get_data(), request.headers.get("X-Keliplay-Signature", ""), os.environ["KELIPLAY_WEBHOOK_SECRET"], ): abort(400)
event = request.get_json() # Handle the event, then answer quickly. return "", 200Answer quickly
Section titled “Answer quickly”Reply with any 2xx status within 10 seconds. Do slow work, such as sending an
email, after you have replied, for example by putting the event on your own queue.
Retries
Section titled “Retries”If a delivery fails, Keliplay tries again, up to five attempts in all. The wait doubles each time, starting at 5 seconds.
A delivery is retried when your endpoint:
- times out or cannot be reached,
- answers
408or429, - answers with any
5xxstatus.
Any other answer, such as 400 or 404, is treated as final and is not retried.
Because a delivery can arrive more than once, use X-Keliplay-Delivery-Id to skip
ones you have already handled.
Delivery log
Section titled “Delivery log”The Webhooks screen in the dashboard lists recent deliveries with their status, the response code your endpoint gave, and the number of attempts. To stop an endpoint receiving events without deleting it, set it to Disabled.