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/runsand 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
gameIdmust equal the game id; limitsinside 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
| Sent | Trust | Meaning |
|---|---|---|
| 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 }
}
| Field | Applies to | Meaning |
|---|---|---|
perSecond | events, stats | Sustained rate allowed per second of elapsed run time. |
burst | events, stats | Flat allowance on top, so a fast opening second isn't punished. Defaults to 0. |
maxPerRun | events | Hard ceiling for a single run, whatever its length. |
max | stats | Hard ceiling the value can never exceed. |
runs.perMinute | the game | Runs a player may start per minute. Default 30. |
runs.ttlSeconds | the 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.
-
scorealways 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
| Status | Means |
|---|---|
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.