Gamearly API v1
Dashboard

Webhooks / Webhooks

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 seconds
  • v1HMAC_SHA256(secret, "<t>." + <raw JSON body bytes>), hex encoded

Validation rules#

  • Parse t and v1 from the header.
  • Reject stale timestamps — e.g. abs(now - t) > 300 seconds.
  • Recompute expected_v1 with your secret using the exact raw request body bytes, not a re-serialised JSON string.
  • Compare expected_v1 and v1 using a constant-time function.
  • If any check fails, return 401.

Responding#

  • Return 200 OK quickly after validating, and enqueue any heavy work.
  • Use X-Gamearly-Delivery for 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})