Skip to content

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.

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.

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.

Terminal window
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.

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.

Keliplay sends a POST with a JSON body:

POST /keliplay HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-Keliplay-Event: session_completed
X-Keliplay-Delivery-Id: 0199f0c4-91aa-7c2e-8f10-6b3d2a9e5f14
X-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.
{
"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.

{
"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.

{
"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.

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:

  1. Read the raw body exactly as received, before any JSON parsing.
  2. Compute the HMAC of t + "." + body with your secret.
  3. Compare it with v1 using a constant-time comparison.
  4. 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()
})

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.

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 408 or 429,
  • answers with any 5xx status.

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.

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.