Gamearly API v1
Dashboard

Quests / Verification endpoint

Verification endpoint#

Required for manual verification

You must implement this endpoint to enable manual verification of in-game quest tasks.

You host this endpoint. Gamearly calls it when a player clicks Verify in the UI.

Method: POST · Content-Type: application/json

{
  "task_id": 123,
  "partner_user_id": "A1B2C3"
}
{
  "task_id": 123,
  "partner_user_id": "A1B2C3",
  "completed": true
}

Authenticating Gamearly's request#

We sign the request body using a shared secret you can reveal or rotate in your project settings (Verification 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. if 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.

Implementation#

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_VERIFICATION_SECRET;

function parseSignature(headerValue = "") {
  // Example: "t=1699999999,v1=abc123..."
  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) {
  // Constant-time compare for hex strings
  const A = Buffer.from(a, "utf8");
  const B = Buffer.from(b, "utf8");
  return A.length === B.length && crypto.timingSafeEqual(A, B);
}

app.post("/gamearly/verify-task", (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}.` + rawBodyBytes)
  const hmac = crypto.createHmac("sha256", SECRET);
  hmac.update(`${t}.`);
  hmac.update(req.rawBody); // exact bytes
  const expected = hmac.digest("hex");

  if (!safeEqualHex(expected, v1)) {
    return res.status(401).send("bad signature");
  }

  const { task_id, partner_user_id } = req.body;

  // TODO: look up completion in your DB
  const completed = false;

  return res.json({ task_id, partner_user_id, completed });
});

app.listen(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_VERIFICATION_SECRET"]

@csrf_exempt
def verify_task(request):
    if request.method != "POST":
        return HttpResponseBadRequest("POST only")

    sig = request.headers.get("X-Gamearly-Signature", "")
    parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
    t = parts.get("t")
    v1 = parts.get("v1")
    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_bytes)
    raw = request.body  # exact bytes as received
    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)

    data = json.loads(raw or b"{}")
    task_id = data.get("task_id")
    partner_user_id = data.get("partner_user_id")

    # TODO: look up completion in your DB
    completed = False

    return JsonResponse({
        "task_id": task_id,
        "partner_user_id": partner_user_id,
        "completed": completed
    })
// ASP.NET Core minimal API (.NET 6+)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

app.MapPost("/gamearly/verify-task", async (HttpRequest request) =>
{
    var secret = Environment.GetEnvironmentVariable("GAMEARLY_VERIFICATION_SECRET")!;

    // Read the exact raw bytes before any JSON binding touches them.
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var raw = buffer.ToArray();

    var header = request.Headers["X-Gamearly-Signature"].ToString();
    var parts = header.Split(',')
        .Select(p => p.Split('=', 2))
        .Where(kv => kv.Length == 2)
        .ToDictionary(kv => kv[0].Trim(), kv => kv[1].Trim());

    if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1))
        return Results.Unauthorized();

    if (!long.TryParse(t, out var ts) ||
        Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
        return Results.Unauthorized();

    // expected = HMAC_SHA256(secret, $"{t}." + rawBodyBytes)
    var prefix = Encoding.UTF8.GetBytes($"{t}.");
    var message = new byte[prefix.Length + raw.Length];
    Buffer.BlockCopy(prefix, 0, message, 0, prefix.Length);
    Buffer.BlockCopy(raw, 0, message, prefix.Length, raw.Length);

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1)))
        return Results.Unauthorized();

    var payload = JsonSerializer.Deserialize<VerifyRequest>(raw)!;

    // TODO: look up completion in your DB
    var completed = false;

    return Results.Ok(new
    {
        task_id = payload.task_id,
        partner_user_id = payload.partner_user_id,
        completed
    });
});

record VerifyRequest(int task_id, string partner_user_id);

Verification logic#

The actual logic is up to you. Two patterns work well and scale.

Pattern A — precomputed completions table#

Fastest. Have a job write to a dedicated table whenever a player finishes something relevant. Associate the action with the player and the Gamearly task id. Your endpoint then just reads that table and replies.

Pros: very fast responses, no heavy joins on the request path. Cons: you must keep the table up to date.

Tip

With this pattern you can also proactively report completions via /quests/complete_task for a smoother experience.

Pattern B — on-the-fly verification#

Store a task config row describing how to verify a task_id, then compute the answer at request time.

Pros: simpler, always up to date. Cons: slower responses, more complex queries.

Notes#

  • You don't need Gamearly's user id. We send your own partner_user_id. You can store our id if it helps analytics, but it isn't required for verification.
  • Unknown task_id — return completed: false.
  • Latency — keep it fast, e.g. under 500 ms. Requests that take multiple seconds time out. If you expect long processing, respond quickly with completed: false and use /quests/complete_task to report completion later.