Harness API
The endpoints a build pipeline calls directly. Everything lives under /api/v1/harness and answers JSON.
Authentication
Requests carry a harness token as a bearer token:
Authorization: Bearer <token>
Accept: application/json
A harness token is issued by a person with harness access: an arcade administrator or an approved developer, and can be revoked at any time. Two ways to get one:
- Device link: the flow
taploka-mcp loginuses, described below. Nothing is copied by hand. - Console: an administrator or the developer's own panel creates a named key and hands it over.
Treat the token like a password: it can publish to the catalog. Keep it in a secret store, never in a repository.
Device link
A three-step handshake that gets a key onto a machine without anyone pasting it.
1. Ask for a code
POST /api/v1/harness/device
Content-Type: application/json
{ "name": "build-box" }
{
"device_code": "…",
"user_code": "RT2H-Z46S",
"verification_uri": "https://your-arcade.example.com/admin/link",
"verification_uri_complete": "https://your-arcade.example.com/admin/link?code=RT2H-Z46S",
"interval": 2,
"expires_in": 600
}
2. A person approves the user code in the console. An arcade administrator or the developer who owns that machine can: approving mints the harness key in the approver's name.
3. Poll for the key
POST /api/v1/harness/device/token
Content-Type: application/json
{ "device_code": "…" }
While nobody has clicked yet the answer is 202 with {"status": "pending"}, not an error. Once approved it is 200
with the token. The key is handed over exactly once; the pending link is destroyed with it, and an unapproved link
expires after ten minutes. Both endpoints are rate limited per IP.
Push a bundle
POST /api/v1/harness/bundles
Content-Type: multipart/form-data
| Field | Meaning |
|---|---|
bundle |
The ZIP file. Required. |
game |
Slug of an existing game. Required unless title is given. |
title |
Name for a first push; the slug is derived from it. |
rollout |
0-100, default 100. |
changelog |
Short note. |
draft |
1 to store without releasing. |
{
"game": "rosok-garage",
"game_created": true,
"version": 1,
"status": "released",
"rollout": 100,
"sdk_min": 1,
"size_bytes": 69414,
"files": 5,
"play_url": "https://your-arcade.example.com/main/rosok-garage",
"checks": [{ "label": "Struktur wajib", "detail": "index.html + game.json ditemukan", "ok": true }]
}
A bundle that fails validation returns 422 with code BUNDLE_INVALID and the same checks array, so a pipeline can
print exactly what went wrong.
Read the catalog
GET /api/v1/harness/games
GET /api/v1/harness/games/{game}
games lists slug, title, status, version count, active version and play URL. games/{game} adds the metadata block,
every version with its rollout, SDK floor, size and changelog, score totals including how many are flagged for review,
and the leaderboard URL.
It also carries languages, the codes this arcade advertises right now, which are the only keys translations accepts.
Read them from here rather than hardcoding a list: an operator can add a language, and copy pushed under a code that is
not on the list is dropped.
Update metadata
POST /api/v1/harness/games/{game}
Send only the fields you want changed: title, tagline, description, translations, orientation, max_score,
max_score_per_second, min_score_version, cumulative_score, latest_score, sort_order, categories, features, cover_url, banner_url, coin_price,
screenshots_url, or a cover / banner / screenshots[] file as multipart. The response is the same shape as reading the game, so a pipeline can
write and read back in one call.
screenshots behaves like shop: sending it replaces the whole set (up to 6 images). Files are re-encoded to WebP
like the cover; screenshots_url takes already-hosted URLs instead, and null clears the list.
title, tagline and description are the arcade's house language, Indonesian. Push the Indonesian copy there and put
the English in translations.en, not the other way round: the bare URL a search engine indexes is the Indonesian one,
so English in the base columns means the Indonesian page reads English.
translations carries the store pitch per language, keyed by the language codes the arcade advertises (the same ones
in the hreflang links; game_status returns the current set). Like shop, it replaces the whole map. Only tagline
and description are translated, and a language the arcade does not advertise is dropped rather than stored:
{
"translations": {
"en": { "tagline": "Cook fast, serve faster.", "description": "Run the stall, chase the record." }
}
}
A field left out reads through to the game's own column, so an English page with no English tagline still shows the Indonesian one rather than nothing. The title is never translated: it is the brand name, and renaming it per language would split the game across search results.
coin_price is what makes a game premium: a number keeps it browsable in the catalogue but starting a session needs a
coin purchase, credited to the game's owner; null turns it back to free.
max_score flags a single score above the ceiling; max_score_per_second flags one that climbs too fast. Leave the
per-second value null and the global default applies, which suits a puzzle but not an idle game handing in millions.
cumulative_score is for a game that submits a running total rather than a per-round score, which is most idle and
tycoon games. Without it every period shows the same number, because the best total of today is also the best total of
all time. With it, the daily and weekly boards hold what a player gained inside that period and only the all-time board
keeps the total.
latest_score is for a rating ladder, where a score goes down as well as up. Without it a board keeps each player's
best score of the period and the meta of that run, so a player who drops stays listed at their peak. With it, every
submission replaces the player's row on every board, meta included, so the board shows where people stand now.
min_score_version stops scores from bundles older than that version reaching the boards, for a release that changes
what the score means. They still count for quests.
Uploaded covers are re-encoded to WebP and scaled to fit 1200x800, so cover_url always points at a .webp file
regardless of what was sent. The upload itself accepts PNG, JPEG or WebP up to 3 MB.
banner is the wide artwork (about 1200x500) for the home hero and the header of your detail page, where the 2:3
cover does not fit. It is re-encoded the same way. Leave it unset and both slots fall back to the cover.
The coin shop
shop is the one field that is not partial: it replaces the game's whole price list.
{
"shop": [
{ "sku": "engine_lv2", "title": "Mesin Lv2", "coin_price": 800 },
{ "sku": "fuel", "title": "Bensin", "coin_price": 150, "type": "consumable" }
]
}
A sku missing from the list is deactivated, not deleted, so a player who bought it keeps a name to show. type is
non_consumable (the default, owned once) or consumable (stacks a quantity), and active retires an entry in place.
Reading the game back returns shop with every entry, retired ones included.
The price is only ever read from here. See Coins and shop for how a bundle buys against it.
Errors
Failures answer with a stable shape:
{ "message": "…", "code": "BUNDLE_INVALID", "data": { "checks": [] } }
| Code | Meaning |
|---|---|
BUNDLE_INVALID |
A validation check failed; data.checks says which |
DEVICE_UNKNOWN |
Device code is unknown, already used, or expired away |
DEVICE_EXPIRED |
The link was not approved in time; start again |
Example: publish from CI
curl -sS -X POST "$TAPLOKA_URL/api/v1/harness/bundles" \
-H "Authorization: Bearer $TAPLOKA_KEY" \
-H "Accept: application/json" \
-F "game=rosok-garage" \
-F "bundle=@build/game.zip" \
-F "rollout=20" \
-F "changelog=$(git log -1 --pretty=%s)"