Overview

The Integration API is how a game or service outside this site records achievements to a player's Achv account. It is the path to use when your game has its own backend, its own accounts, or simply lives somewhere else.

It is not a way to tell Achv “this player has earned these achievements”. You submit runs — what happened during one session of play — and Achv decides what that earns. That distinction is the whole design, and the next section explains why it is worth living with.

The SDK in packages/sdk is a separate thing and makes no network calls at all. If you only need local achievements in a browser game, you want the Web Games guide instead. Use this API when progress has to reach an Achv account.

What Achv will and won't take your word for

Anything only the player sees — their own dashboard and profile — is self-reported and always has been. Anything other players see is not: the league tables, the global unlock counts, and the rarity tiers those counts drive. Those fill only from runs Achv has verified.

So a run is timed and checked, not simply accepted:

  • Achv times the run, not you. The clock starts when you call POST /v1/runs and stops when you finish it, both by the database clock. A duration you report is not used.
  • What you report is checked against the game's limits. Every event count and stat value has to be achievable in the time that actually elapsed.
  • Achv works out the achievements. You never send an achievement id. Verified progress accumulates, and the manifest's own triggers are re-evaluated against it.
  • Validation fails closed. A game with no limits verifies nothing. An event or stat an achievement depends on, with no limit declared, verifies nothing.

A rejected run is stored with its reasons and changes nothing shared. It costs the player nothing on their own profile.

Being honest about the ceiling: for a game whose logic runs on the player's machine, this makes cheating harder, not impossible — scripted play that stays inside the limits still passes. A game whose logic runs on your own server can do better, which is what server trust below is for.

Before you start: registering

Integrations are approved by the project owner rather than self-serve, while the project is small. Registering a game means supplying:

  • a game id, unique across Achv, matching ^[a-z0-9][a-z0-9_-]{1,62}$;
  • a display name;
  • your manifest, in the same shape as the SDK's, whose gameId must equal the game id;
  • limits inside that manifest — without them nothing you submit can ever verify.

Getting the limits right is the part worth thinking about, so it has its own section below.

Authentication

Every call answers two separate questions, and keeping them separate is what makes trust levels mean anything.

Whose account? A player key

The player generates one on Achv under Settings → Integrations → Game keys, and pastes it into your game. It looks like achv_pk_1a2b3c4d_… and is shown to them exactly once — Achv stores only a hash.

A key is scoped to one player and one game. It can submit that game's runs and read that game's verified progress. It cannot read or change their profile, reach their other games, or act on their account. They can revoke it at any time, and see when it was last used.

Authorization: Bearer achv_pk_1a2b3c4d_...

Which game is calling? A server key

Optional, issued to your integration rather than to a player, and only meaningful if your game has a server that can keep a secret. Send it alongside the player key:

X-Achv-Server-Key: achv_sk_5e6f7a8b_...

Never put a server key in client-side code. Anything shipped to a browser or an app bundle is not a secret. If your game has no backend, use the player key alone — that is the supported arrangement, not a degraded one.

What the two combinations buy

SentTrustMeaning
Player key client Achv knows whose account, not who is calling. The player holds the key and could use it themselves. Same trust the games on this site get.
Player key + server key server Achv also knows the call came from your server, which the player cannot impersonate.

Every response tells you the trust it was actually granted. If your integration is not approved for server trust yet, a server key does not fail the call — it is recorded at client trust and the response carries a note saying so. A server key belonging to a different game is refused outright with 403.

Endpoints

Base URL:

https://<your-achv-project>.supabase.co/functions/v1/api

All responses are JSON and carry ok. The game is never a parameter you choose: it follows from the key. You may pass ?game= to make a request self-describing, and it is checked against the key rather than trusted — a mismatch is 403, which catches a key pasted into the wrong game.

POST /v1/runs

Starts a run and starts its clock. No body required.

curl -X POST \
  https://<project>.supabase.co/functions/v1/api/v1/runs \
  -H "Authorization: Bearer $ACHV_PLAYER_KEY"

{
  "ok": true,
  "game": "subakogames-arena",
  "runId": "8f14e45f-ceea-467a-9f3a-1c2b3d4e5f60",
  "startedAt": "2026-09-15T17:41:02.118Z",
  "trust": "client"
}

POST /v1/runs/{runId}/finish

Ends the run and submits what happened. events is how many times each event fired during this run; stats is the run's final values. Both are plain objects of numbers.

curl -X POST \
  https://<project>.supabase.co/functions/v1/api/v1/runs/$RUN_ID/finish \
  -H "Authorization: Bearer $ACHV_PLAYER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events":{"enemy_destroyed":12},"stats":{"score":4200}}'

{
  "ok": true,
  "game": "subakogames-arena",
  "verified": true,
  "reasons": [],
  "unlocks": [
    { "achievementId": "first_kill", "verified": true }
  ],
  "elapsedSeconds": 96.4,
  "trust": "client"
}

A run can be finished once. Finishing one that is already finished, has expired, or is not yours returns 409.

When verified is false, reasons says exactly what failed and unlocks is empty. That is a normal 200, not an error — the run was recorded, it just earned nothing shared.

GET /v1/me/progress

The player's verified progress for the key's game.

curl https://<project>.supabase.co/functions/v1/api/v1/me/progress \
  -H "Authorization: Bearer $ACHV_PLAYER_KEY"

{
  "ok": true,
  "game": "subakogames-arena",
  "trust": "client",
  "verified": {
    "events": { "enemy_destroyed": 137 },
    "stats": { "score": 9100 },
    "updatedAt": "2026-09-15T17:41:38.552Z",
    "unlocked": [
      { "achievementId": "first_kill", "unlockedAt": "2026-09-15T17:41:38.552Z" }
    ]
  },
  "score": 9100
}

Verified results only, and nothing identifying. A player key does not carry their handle, display name, email or any other game's progress, because that is not what they granted you.

A worked integration

A run should wrap one unit of play — a life, a match, a level. Start it when play starts, not when the process boots: the elapsed time is your budget, and a run left open for an hour makes an implausible score look plausible.

const BASE = "https://<project>.supabase.co/functions/v1/api";

async function achv(path, { method = "POST", body, playerKey, serverKey } = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${playerKey}`,
      ...(serverKey ? { "X-Achv-Server-Key": serverKey } : {}),
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${res.status}: ${data.message ?? "request failed"}`);
  return data;
}

// One match.
const { runId } = await achv("/v1/runs", { playerKey });

// ... the player actually plays ...

const result = await achv(`/v1/runs/${runId}/finish`, {
  playerKey,
  body: {
    events: { enemy_destroyed: kills, round_won: won ? 1 : 0 },
    stats: { score, accuracy },
  },
});

if (!result.verified) {
  // Not an error. Log the reasons -- during development they are usually
  // telling you a limit is too tight for how the game really plays.
  console.warn("run not verified:", result.reasons);
}

If a finish never reaches Achv, the run simply stays unverified. There is no retry yet, so treat a failed finish as that session not counting rather than something to replay.

Limits

Limits are your game's own rules, written down so a server can enforce them. They live under limits in your manifest.

"limits": {
  "events": {
    "enemy_destroyed": { "perSecond": 4, "burst": 3 }
  },
  "stats": {
    "score":  { "max": 100000, "perSecond": 250, "burst": 500 },
    "accuracy": { "max": 100 }
  },
  "runs": { "perMinute": 30, "ttlSeconds": 21600 }
}
FieldApplies toMeaning
perSecondevents, stats Sustained rate allowed per second of elapsed run time.
burstevents, stats Flat allowance on top, so a fast opening second isn't punished. Defaults to 0.
maxPerRunevents Hard ceiling for a single run, whatever its length.
maxstats Hard ceiling the value can never exceed.
runs.perMinutethe game Runs a player may start per minute. Default 30.
runs.ttlSecondsthe game How long a run may stay open before it expires. Default 21600 (6 hours).

The allowance for a run of t seconds is:

min(burst + perSecond * t,  maxPerRun,  max)

Any part you leave out is simply not a constraint — omit perSecond and only the hard ceilings apply.

Two rules that catch people out

  • Anything an achievement depends on needs a limit. An event or stat with no limit that a trigger references makes the run unverifiable, rather than being waved through. An event nothing references is simply ignored.
  • score always needs a limit if you send it, achievement or not, because it feeds the public league table.

Cumulative stats don't work

stats describes this run. A running career total kept by your client cannot be checked against one run's elapsed time, and will be rejected as soon as it grows. Send a per-run event instead and let verified progress accumulate server-side — the same fix this site's own Artillery needed for its wins stat.

Pick limits from the real game

Take them from what the game can actually do, then leave a little headroom. Snake allows 4 foods a second because the board is 20×20 and the snake moves that fast; Artillery caps score at 300 when the real maximum is 289. Too tight and honest players get rejected; too loose and the limit stops meaning anything.

Errors

StatusMeans
401 The player key is missing, malformed, unknown, revoked, or its integration is suspended. Deliberately one message for all of those, so the API can't be used to discover which keys exist.
403 A server key for a different game, or ?game= disagreeing with the key.
404 No such endpoint, or the game is unknown or suspended.
409 That run is already finished, expired, or not yours.
429 Too many runs started in the last minute.
500 Achv's fault. Safe to retry a start; do not retry a finish blindly, since the run may already be claimed.

A rejected run is 200 with "verified": false, not an error status. Check verified, not just the status code.