Assets API

Upload, list, and delete images and media in Playtest Parlor.

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

  1. Request a presigned URL from the server. The file counts toward storage from this moment, at the size you give.
  2. Upload the file directly to cloud storage using the presigned URL
  3. 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:

FieldDescription
resolvedThe URLs that were fetched and stored, keyed by the URL you sent
failuresURLs 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.
skippedURLs 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 fieldFrom the resolved entry
keyoriginal.key
sourceUrloriginal.url
contentTypeoriginal.contentType
widthPxdisplay.metadata.sourceWidthPx
heightPxdisplay.metadata.sourceHeightPx
sourcePixelsPerInchdisplay.metadata.sourcePixelsPerInch
bleedModedisplay.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: gameId or assets is missing, or an entry has no url
  • RATE_LIMITED: Upload rate limit exceeded
  • GAME_NOT_FOUND: The game ID does not exist
  • PERMISSION_DENIED: You cannot edit this game
  • RETRY_LATER: One of the images is being deleted from storage right now, as another game's last copy. Retry after the Retry-After seconds

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:

FieldTypeRequiredDescription
fileNamestringyesOriginal filename (max 256 characters)
contentTypestringyesMIME type (e.g., image/png, image/jpeg)
byteSizenumberyesFile size in bytes, a whole number (max 20 MB). The URL takes exactly this many bytes.
checksumstringyesSHA-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.
keyPrefixstringnoFolder 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:

FieldTypeDescription
signedUrlstringURL where you PUT the file (valid for 10 minutes)
keystringStorage key for this asset. Pass it as the component's asset key field.
publicUrlstringPublic URL of the uploaded file. Pass it as the component's asset sourceUrl field.
headersobjectHeaders 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.
contentTypestringThe content type you provided

Errors:

  • VALIDATION_ERROR: Missing or invalid metadata
  • PLAN_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 exceeded
  • GAME_NOT_FOUND: The game ID does not exist
  • PERMISSION_DENIED: You cannot edit this game
  • RETRY_LATER: This exact file is being deleted from storage right now, as another game's last copy. Retry after the Retry-After seconds

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:

ParameterDescription
limitHow many assets to return, 1 to 200. Default 100. A page can hold fewer, so keep going until nextCursor is null.
cursorWhere 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:

FieldTypeDescription
idstringUnique asset identifier
componentIdstring or nullThe component this asset belongs to
kindstringAsset type (e.g., image)
sourceUrlstringPublic URL of the uploaded file
mimeTypestringMIME type of the file
widthPxnumberImage width in pixels
heightPxnumberImage height in pixels
createdAtnumberCreation timestamp (milliseconds)
nextCursorstring or nullPass as cursor to get the next page; null when there are no more

Errors:

  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game
  • VALIDATION_ERROR (400): limit is not a whole number from 1 to 200, or cursor is not a nextCursor this 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 app
  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game
  • RETRY_LATER (503): Imports are being upgraded, or an import is being applied to your app's pieces. Retry after the Retry-After seconds

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
  }]
});