The Assets API lets you upload, list, and delete images and other media. Uploads use a two-step presigned URL process. Image derivation (generating optimized display versions) happens automatically when you create components with assets -- you do not need to trigger it yourself.
Overview
- Request a presigned URL from the server. The file counts toward storage from this moment, at the size you give.
- Upload the file directly to cloud storage using the presigned URL
- Create components with your assets via the Components API -- image derivation is handled automatically
If your images are already online, you can skip all of that and give Playtest Parlor the URLs instead.
Ingest by URL
If the image already lives at a URL Playtest Parlor will fetch from, hand over the URL and skip the download-and-re-upload entirely. This is the fast path for a tool that is mirroring a collection it does not hold the bytes for.
Request:
POST /api/assets/cache-external
Authorization: Bearer pp_your_token
Content-Type: application/json
{
"gameId": "GAME_ID",
"assets": [
{ "url": "https://files.thegamecrafter.com/card_back.png" },
{ "url": "https://files.thegamecrafter.com/card_face.png", "bleedMode": "noBleed" }
]
}
gameId is the game the images are for; you must be able to edit it. The stored images belong
to that game, and are deleted with it unless another game has stored the same image. Up to
20 URLs per request. bleedMode defaults to bleed. Once the game owner's storage is full,
the request is refused with PLAN_LIMIT (402) before any image is fetched.
Response:
| Field | Description |
|---|---|
resolved | The URLs that were fetched and stored, keyed by the URL you sent |
failures | URLs that could not be fetched or processed, each with a message. This includes a URL whose image changed while it was being stored: add it again. |
skipped | URLs that were not attempted, each with a message saying why |
Each entry of resolved holds the stored image (original) and its display version (display).
To use it as a component image, pass these fields in the component's assets entry:
| Asset field | From the resolved entry |
|---|---|
key | original.key |
sourceUrl | original.url |
contentType | original.contentType |
widthPx | display.metadata.sourceWidthPx |
heightPx | display.metadata.sourceHeightPx |
sourcePixelsPerInch | display.metadata.sourcePixelsPerInch |
bleedMode | display.metadata.bleedMode |
widthPx and heightPx are required on every component image, so take them from the
display metadata: they are the stored image's own size in pixels.
// One entry of `resolved`, as an entry of a component's `assets`.
function componentAsset(stored, sideKey) {
const { metadata } = stored.display;
return {
sideKey,
key: stored.original.key,
sourceUrl: stored.original.url,
contentType: stored.original.contentType,
widthPx: metadata.sourceWidthPx,
heightPx: metadata.sourceHeightPx,
sourcePixelsPerInch: metadata.sourcePixelsPerInch,
bleedMode: metadata.bleedMode
};
}
Fetching is limited to a list of known hosts, because an endpoint that fetches any URL on request
is a way to reach things it should not. A URL on a host that is not on the list comes back under
skipped, naming the host as the reason, so you can tell it apart from a broken image. Today the
list covers The Game Crafter, Steam user content, component.studio caches, and your own Playtest
Parlor asset URLs. Anything else has to be uploaded with the presign flow above.
Bytes fetched this way count toward your storage the same as an upload, each image and its display copy at the size stored. A request whose images do not fit the game owner's storage is refused with PLAN_LIMIT.
This route sends no CORS headers, so a browser script on another site cannot call it. Call it from your server.
Errors:
VALIDATION_ERROR:gameIdorassetsis missing, or an entry has nourlRATE_LIMITED: Upload rate limit exceededGAME_NOT_FOUND: The game ID does not existPERMISSION_DENIED: You cannot edit this gameRETRY_LATER: One of the images is being deleted from storage right now, as another game's last copy. Retry after theRetry-Afterseconds
Step 1: Request Presigned URL
Request an upload URL for your asset.
Request:
POST /api/v1/games/:gameId/assets/presign
Authorization: Bearer pp_your_token
Content-Type: application/json
{
"fileName": "card_back.png",
"contentType": "image/png",
"byteSize": 15342,
"checksum": "sha256-abc123..."
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
fileName | string | yes | Original filename (max 256 characters) |
contentType | string | yes | MIME type (e.g., image/png, image/jpeg) |
byteSize | number | yes | File size in bytes, a whole number (max 20 MB). The URL takes exactly this many bytes. |
checksum | string | yes | SHA-256 hash of file contents, as sha256-BASE64 or as 64 hex characters. The storage key is built from it, so any other value is refused. |
keyPrefix | string | no | Folder for the file. Defaults to uploads; only folders for a game's own content (uploads, imports, imports/images, imports/models, books/pages, game-resources, table-backgrounds, game-og-images) are accepted. |
The file name's extension decides the stored type, whatever contentType says. Accepted:
png, jpg, jpeg, webp, gif, avif, bmp, tif, tiff, heic, heif, ico, apng,
jxl, svg, pdf, glb, gltf and bin. A file name without an extension, or with one not
on this list (a photo.jfif, say), is stored as contentType instead when that is one of these
types, under that type's extension. Anything else is refused with VALIDATION_ERROR.
The uploaded file belongs to the game. Deleting the game deletes it, unless another game has uploaded the same file.
The file counts toward the game owner's storage from the moment the URL is issued, at byteSize.
Uploading the same file again adds nothing. A file no component shows stops counting an hour
after it was presigned.
Response:
{
"signedUrl": "https://...r2.cloudflarestorage.com/uploads/sh/sha256-...",
"key": "uploads/sh/sha256-RGXC3ce7w3...",
"publicUrl": "https://pub-....r2.dev/uploads/sh/sha256-RGXC3ce7w3...",
"headers": {
"Content-Type": "image/png",
"x-amz-checksum-sha256": "RGXC3ce7w3...",
"If-None-Match": "*",
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Encoding": "identity",
"Content-Disposition": "inline",
"Content-Language": "und",
"Expires": "Tue, 28 Sep 2027 12:00:00 GMT"
},
"contentType": "image/png"
}
Response Fields:
| Field | Type | Description |
|---|---|---|
signedUrl | string | URL where you PUT the file (valid for 10 minutes) |
key | string | Storage key for this asset. Pass it as the component's asset key field. |
publicUrl | string | Public URL of the uploaded file. Pass it as the component's asset sourceUrl field. |
headers | object | Headers to send with the upload in Step 2, exactly as given: the file's type, its SHA-256 digest, If-None-Match: *, and the caching and encoding headers the file is served with. |
contentType | string | The content type you provided |
Errors:
VALIDATION_ERROR: Missing or invalid metadataPLAN_LIMIT(402): This upload would not fit the storage it counts against: the game owner's plan's storage (50 MB on Starter, 300 MB on Casual, 20 GB on Pro). The message names how much room is left, how large the file was, and the plan that gives more.RATE_LIMITED: Upload rate limit exceededGAME_NOT_FOUND: The game ID does not existPERMISSION_DENIED: You cannot edit this gameRETRY_LATER: This exact file is being deleted from storage right now, as another game's last copy. Retry after theRetry-Afterseconds
Step 2: Upload File
Upload your file directly to the presigned URL using a PUT request, sending every header from the presign response's headers:
curl -X PUT "SIGNED_URL" \
-H "Content-Type: image/png" \
-H "x-amz-checksum-sha256: RGXC3ce7w3..." \
-H "If-None-Match: *" \
-H "Cache-Control: public, max-age=31536000, immutable" \
-H "Content-Encoding: identity" \
-H "Content-Disposition: inline" \
-H "Content-Language: und" \
-H "Expires: Tue, 28 Sep 2027 12:00:00 GMT" \
--data-binary @card_back.png
The URL takes only the file whose SHA-256 digest you gave as checksum, exactly byteSize bytes
long, stored as the type its file name's extension names and served with the caching and
encoding headers listed. Your HTTP client sends the Content-Length itself. Storage refuses the
upload if a listed header is missing, changed or joined by another, or the file is not
byteSize bytes (403), or the bytes have a different digest (400 BadDigest). If the file
changed after you presigned it, presign again with its new checksum and size.
A 412 (PreconditionFailed) answer means the file is already stored: storage keys are
built from the file's digest, so the same file always has the same key. Treat it as success and
carry on to creating your component.
The presigned URL is valid for 10 minutes. If it expires, request a new presigned URL.
Expected Response: HTTP 200 OK (exact response depends on cloud storage provider)
List Assets
Retrieve the images your app's components show now, for a game, a page at a time: one per side of each component you have not deleted. Useful for deduplication (skip uploading assets that already exist) and cleanup. A page can hold fewer than limit while more follow, so keep going until nextCursor is null.
Request:
GET /api/v1/games/:gameId/assets?limit=100
Authorization: Bearer pp_your_token
Query Parameters:
| Parameter | Description |
|---|---|
limit | How many assets to return, 1 to 200. Default 100. A page can hold fewer, so keep going until nextCursor is null. |
cursor | Where to continue. Pass the previous response's nextCursor unchanged to get the next page. |
Response:
{
"assets": [
{
"id": "a1b2...",
"componentId": "j57a...",
"kind": "image",
"sourceUrl": "https://pub-....r2.dev/uploads/sh/sha256-RGXC3ce7w3...",
"mimeType": "image/png",
"widthPx": 750,
"heightPx": 1050,
"createdAt": 1713800000000
}
],
"nextCursor": null
}
Pages work as they do for List Components: pass nextCursor back as cursor until it is null.
Response Fields:
| Field | Type | Description |
|---|---|---|
id | string | Unique asset identifier |
componentId | string or null | The component this asset belongs to |
kind | string | Asset type (e.g., image) |
sourceUrl | string | Public URL of the uploaded file |
mimeType | string | MIME type of the file |
widthPx | number | Image width in pixels |
heightPx | number | Image height in pixels |
createdAt | number | Creation timestamp (milliseconds) |
nextCursor | string or null | Pass as cursor to get the next page; null when there are no more |
Errors:
GAME_NOT_FOUND: The game does not existPERMISSION_DENIED: You do not own this gameVALIDATION_ERROR(400):limitis not a whole number from 1 to 200, orcursoris not anextCursorthis API gave you
Delete Asset
Delete one image of a component, live as soon as it returns: the side shows no image from then on, the image is taken out of the component's payload.imageFaces, and the image and its derived versions are deleted shortly after. See When Your Changes Are Live.
Request:
DELETE /api/v1/games/:gameId/assets/:assetId
Authorization: Bearer pp_your_token
Response: 204 No Content
Errors:
ASSET_NOT_FOUND: The asset does not exist or belongs to a different appGAME_NOT_FOUND: The game does not existPERMISSION_DENIED: You do not own this gameRETRY_LATER(503): Imports are being upgraded, or an import is being applied to your app's pieces. Retry after theRetry-Afterseconds
Rate Limits
Presign and Ingest by URL share one allowance per account: 200 at once, then 600 a minute (one every 0.1 seconds). Every file the person uploads on the site counts against it too. An Ingest by URL request counts once, however many URLs it sends. Listing and deleting are not rate limited. See Errors.
Typical Workflow
Here is the complete workflow for uploading assets and creating components:
// Node 18 has no global `crypto`; add: import { webcrypto as crypto } from "node:crypto";
const api = `https://playtestparlor.com/api/v1/games/${gameId}`;
// How long to keep waiting out RATE_LIMITED and RETRY_LATER before giving up. An upgrade
// of how imports are stored asks for 2 minutes at a time and can take a few.
const MAX_WAIT_SECONDS = 10 * 60;
// The failure in a response. API errors are JSON with the code and message in
// data.error ({ data: { error: { code, message, details } } }), but a proxy in between
// may answer with an HTML page, so fall back to the HTTP status.
async function failureOf(res) {
const text = await res.text();
let error;
try { error = JSON.parse(text).data?.error; } catch { /* not JSON */ }
return { code: error?.code, message: error?.message ?? `HTTP ${res.status}`, details: error?.details };
}
// POST to the API and return its JSON answer. RATE_LIMITED (429) and RETRY_LATER (503)
// say in details.retryAfterSeconds (and Retry-After) how many seconds to wait before
// asking again; anything else fails.
async function post(path, body) {
for (let waited = 0; ; ) {
const res = await fetch(`${api}${path}`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (res.ok) return res.json();
const { code, message, details } = await failureOf(res);
const retry = (res.status === 429 && code === "RATE_LIMITED")
|| (res.status === 503 && code === "RETRY_LATER");
if (!retry) throw new Error(`POST ${path} failed: ${message}`);
const seconds = details.retryAfterSeconds;
if (waited + seconds > MAX_WAIT_SECONDS) {
throw new Error(`POST ${path} still failing after waiting ${waited} seconds: ${message}`);
}
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
waited += seconds;
}
}
// The file's SHA-256 digest, as the presign request's `checksum`.
const bytes = await fileBlob.arrayBuffer();
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
const base64Hash = btoa(String.fromCharCode(...digest));
// 1. Get presigned URL. RETRY_LATER means this exact file is being deleted from storage
// right now; post() waits and asks again.
const { signedUrl, key, publicUrl, headers, contentType } = await post("/assets/presign", {
fileName: "card.png",
contentType: "image/png",
byteSize: fileBlob.size,
checksum: `sha256-${base64Hash}`
});
// 2. Upload the same bytes to the presigned URL, sending every header the presign
// returned, unchanged. 412 means this file is already stored: treat it as success.
const put = await fetch(signedUrl, {
method: "PUT",
headers,
body: bytes
});
if (!put.ok && put.status !== 412) {
throw new Error(`Upload failed: ${(await failureOf(put)).message}`);
}
// 3. Create component with asset -- derivation happens automatically
await post("/components", {
kind: "tile",
name: "My Card",
externalKey: "my-card-001",
widthMm: 63.5,
heightMm: 88.9,
assets: [{
sideKey: "front",
key,
sourceUrl: publicUrl,
contentType,
widthPx: 750,
heightPx: 1050
}]
});