Webhooks#
Optional, for events
You host a single HTTPS endpoint to receive Gamearly events about your project. It's optional for basic linking and verification, but highly recommended so you can react to platform activity in real time.
- Method:
POST - Content-Type:
application/json - URL: configured in your project settings
Request headers#
| Header | Meaning |
|---|---|
X-Gamearly-Event |
The event type — see the list below. |
X-Gamearly-Signature |
HMAC signature for authentication. |
Idempotency-Key |
Optional. A unique key for the delivery, so you can ignore duplicates. |
Event types#
| Event | Fires when |
|---|---|
user.linked |
A user successfully linked their Gamearly account to your project. |
quest.new |
A new quest was created for your project. |
quest.completed |
A user completed a quest in your project. |
vote.new |
A new vote has been created (referendum). |
contest.new |
A new contest or mission has been created. |
session.started |
A linked user started a play session of your game. |
session.ended |
A play session ended (includes duration). |
entitlement.granted |
A user gained access to your game (purchase, key, or studio grant). |
entitlement.revoked |
A user's access was revoked (refund, expiry, or revoke). |
webhook.ping |
A simple ping or test message. |
Example payloads#
Note
Final payloads may evolve — always code defensively and tolerate extra fields.
{
"id": "evt_abc123",
"type": "user.linked",
"created_at": "2025-08-22T12:34:56Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"partner_user_id": "A1B2C3",
"gamearly_user_id": "8a8c0198-6e85-4fa3-8c7f-9f6d43bcb8b2",
"linked_at": "2025-08-22T12:34:56Z"
}
}
{
"id": "evt_abc123",
"type": "quest.new",
"created_at": "2025-08-22T12:35:10Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"quest": { "id": 123 }
}
}
{
"id": "evt_abc123",
"type": "quest.completed",
"created_at": "2025-08-22T12:36:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"quest": { "id": 123 },
"partner_user_id": "A1B2C3",
"times_completed": 1,
"completed_at": "2025-08-22T12:36:00Z"
}
}
{
"id": "evt_abc123",
"type": "vote.new",
"created_at": "2025-08-22T12:40:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"referendum": { "id": 55 }
}
}
{
"id": "evt_abc123",
"type": "contest.new",
"created_at": "2025-08-22T12:41:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"mission": { "id": 77 }
}
}
{
"id": "evt_abc123",
"type": "session.started",
"created_at": "2025-08-22T12:30:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"partner_user_id": "A1B2C3",
"gamearly_user_id": "8a8c0198-6e85-4fa3-8c7f-9f6d43bcb8b2",
"session_id": "…",
"game_id": 42,
"channel_id": "…",
"build_version": "1.4.2",
"installation_id": "…device-uuid…",
"started_at": "2025-08-22T12:30:00Z"
}
}
{
"id": "evt_abc123",
"type": "session.ended",
"created_at": "2025-08-22T12:45:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"partner_user_id": "A1B2C3",
"gamearly_user_id": "8a8c0198-6e85-4fa3-8c7f-9f6d43bcb8b2",
"session_id": "…",
"game_id": 42,
"channel_id": "…",
"build_version": "1.4.2",
"installation_id": "…device-uuid…",
"started_at": "2025-08-22T12:30:00Z",
"ended_at": "2025-08-22T12:45:00Z",
"duration_seconds": 900,
"exit_code": 0
}
}
{
"id": "evt_abc123",
"type": "entitlement.granted",
"created_at": "2025-08-22T12:36:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"partner_user_id": "A1B2C3",
"gamearly_user_id": "8a8c0198-6e85-4fa3-8c7f-9f6d43bcb8b2",
"access_id": "…",
"game_id": 42,
"channel_id": "…",
"source": "STORE",
"build_version": "1.4.2",
"claimed_at": "2025-08-22T12:36:00Z",
"expires_at": null,
"revoked_at": null
}
}
{
"id": "evt_abc123",
"type": "entitlement.revoked",
"created_at": "2025-08-22T13:00:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"partner_user_id": "A1B2C3",
"gamearly_user_id": "8a8c0198-6e85-4fa3-8c7f-9f6d43bcb8b2",
"access_id": "…",
"game_id": 42,
"channel_id": "…",
"source": "DIRECT",
"build_version": "1.4.2",
"claimed_at": "2025-08-22T12:36:00Z",
"expires_at": null,
"revoked_at": "2025-08-22T13:00:00Z"
}
}
{
"id": "evt_abc123",
"type": "webhook.ping",
"created_at": "2025-08-22T12:00:00Z",
"client_id": "clt_abc123",
"project_id": 42,
"data": {
"message": "pong"
}
}
Authenticating Gamearly's request#
We sign the raw request body using a shared secret you can reveal or rotate in project settings (Webhook Secret).
Header
X-Gamearly-Signature: t=<unix>,v1=<hex_hmac>
t— UNIX timestamp in secondsv1—HMAC_SHA256(secret, "<t>." + <raw JSON body bytes>), hex encoded
Validation rules#
- Parse
tandv1from the header. - Reject stale timestamps — e.g.
abs(now - t) > 300seconds. - Recompute
expected_v1with your secret using the exact raw request body bytes, not a re-serialised JSON string. - Compare
expected_v1andv1using a constant-time function. - If any check fails, return
401.
Responding#
- Return
200 OKquickly after validating, and enqueue any heavy work. - Use
X-Gamearly-Deliveryfor idempotency — ignore duplicates. - Non-2xx responses or timeouts may be retried with exponential backoff.
-
A minimal success response is fine:
{ "ok": true }
Receiving the webhook#
import crypto from "crypto";
import express from "express";
const app = express();
// Keep the exact raw body bytes for HMAC verification
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
const SECRET = process.env.GAMEARLY_WEBHOOK_SECRET;
/** Parse "t=...,v1=..." */
function parseSignature(headerValue = "") {
const out = {};
headerValue.split(",").forEach(part => {
const [k, v] = part.split("=", 2);
if (k && v) out[k.trim()] = v.trim();
});
return { t: out.t, v1: out.v1 };
}
function safeEqualHex(a, b) {
const A = Buffer.from(a, "utf8");
const B = Buffer.from(b, "utf8");
return A.length === B.length && crypto.timingSafeEqual(A, B);
}
app.post("/webhooks/gamearly", (req, res) => {
const sigHeader = req.get("X-Gamearly-Signature") || "";
const { t, v1 } = parseSignature(sigHeader);
if (!t || !v1) return res.status(401).send("missing signature");
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(t)) > 300) {
return res.status(401).send("stale timestamp");
}
// expected = HMAC_SHA256(SECRET, `${t}.` + rawBody)
const hmac = crypto.createHmac("sha256", SECRET);
hmac.update(`${t}.`);
hmac.update(req.rawBody);
const expected = hmac.digest("hex");
if (!safeEqualHex(expected, v1)) {
return res.status(401).send("bad signature");
}
// Idempotency
const deliveryId = req.get("X-Gamearly-Delivery") || "";
// if (alreadyProcessed(deliveryId)) return res.json({ ok: true });
const ev = req.get("X-Gamearly-Event") || "";
const payload = req.body;
switch (ev) {
case "user.linked":
// handle payload.data.partner_user_id / payload.data.gamearly_user_id
break;
case "quest.new":
// handle payload.data.quest.id
break;
case "quest.completed":
// handle payload.data.quest.id, payload.data.partner_user_id
break;
case "entitlement.revoked":
// revoke in-game access
break;
case "webhook.ping":
// respond ok
break;
default:
// unknown event — ignore gracefully
break;
}
// markProcessed(deliveryId)
return res.json({ ok: true });
});
app.listen(3000, () => {
console.log("Webhook server listening on port 3000");
});
import os
import hmac
import hashlib
import time
import json
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
SECRET = os.environ["GAMEARLY_WEBHOOK_SECRET"]
def _parse_sig(header: str):
# "t=1699999999,v1=abc123..."
parts = {}
for p in (header or "").split(","):
if "=" in p:
k, v = p.split("=", 1)
parts[k.strip()] = v.strip()
return parts.get("t"), parts.get("v1")
@csrf_exempt
def gamearly_webhook(request):
if request.method != "POST":
return HttpResponseBadRequest("POST only")
sig_header = request.headers.get("X-Gamearly-Signature", "")
t, v1 = _parse_sig(sig_header)
if not t or not v1:
return JsonResponse({"error": "missing signature"}, status=401)
now = int(time.time())
try:
t_int = int(t)
except ValueError:
return JsonResponse({"error": "bad timestamp"}, status=401)
if abs(now - t_int) > 300:
return JsonResponse({"error": "stale timestamp"}, status=401)
# expected = HMAC_SHA256(SECRET, f"{t}." + raw_body)
raw = request.body # exact bytes
msg = f"{t}.".encode("utf-8") + raw
expected = hmac.new(SECRET.encode("utf-8"), msg, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
return JsonResponse({"error": "bad signature"}, status=401)
event = request.headers.get("X-Gamearly-Event", "")
try:
payload = json.loads(raw or b"{}")
except Exception:
return JsonResponse({"error": "invalid json"}, status=400)
if event == "user.linked":
data = payload.get("data", {})
partner_user_id = data.get("partner_user_id")
gamearly_user_id = data.get("gamearly_user_id")
# handle link...
elif event == "quest.completed":
# grant the in-game reward...
pass
elif event == "entitlement.revoked":
# revoke in-game access...
pass
elif event == "webhook.ping":
# quick ack
pass
else:
# unknown event — ignore gracefully
pass
return JsonResponse({"ok": True})