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 three-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
  2. Upload the file directly to cloud storage using the presigned URL
  3. Confirm the upload with the server
  4. Create components with your assets via the Components API -- image derivation is handled automatically

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 (max 20 MB)
checksumstringyesSHA-256 hash of file contents as sha256-BASE64

Response:

{
  "signedUrl": "https://...r2.cloudflarestorage.com/uploads/sh/sha256-...",
  "key": "uploads/sh/sha256-RGXC3ce7w3...",
  "publicUrl": "https://pub-....r2.dev/uploads/sh/sha256-RGXC3ce7w3...",
  "contentType": "image/png"
}

Response Fields:

FieldTypeDescription
signedUrlstringURL where you PUT the file (valid for 30 minutes)
keystringStorage key for this asset. Pass to upload-complete and to the component's asset key field.
publicUrlstringPublic URL of the uploaded file. Pass to upload-complete and to the component's asset sourceUrl field.
contentTypestringThe content type you provided

Errors:

  • VALIDATION_ERROR: Missing or invalid metadata
  • STORAGE_LIMIT_EXCEEDED: Your account has reached its 2 GB storage quota
  • RATE_LIMITED: Upload rate limit exceeded
  • GAME_NOT_FOUND: The game ID does not exist
  • PERMISSION_DENIED: You do not own this game

Step 2: Upload File

Upload your file directly to the presigned URL using a PUT request:

curl -X PUT "SIGNED_URL" \
  -H "Content-Type: image/png" \
  --data-binary @card_back.png

The presigned URL is valid for 30 minutes. If it expires, request a new presigned URL.

Expected Response: HTTP 200 OK (exact response depends on cloud storage provider)

Step 3: Confirm Upload

After the file is successfully uploaded, confirm it with the server. Use the key, publicUrl, and contentType from the presign response plus the original byteSize.

Request:

POST /api/v1/games/:gameId/assets/upload-complete
Authorization: Bearer pp_your_token
Content-Type: application/json

{
  "key": "uploads/sh/sha256-RGXC3ce7w3...",
  "publicUrl": "https://pub-....r2.dev/uploads/sh/sha256-RGXC3ce7w3...",
  "byteSize": 15342,
  "contentType": "image/png"
}

Request Body:

FieldTypeRequiredDescription
keystringyesThe storage key from the presign response
publicUrlstringyesThe public URL from the presign response
byteSizenumberyesFile size in bytes (must match the original presign request)
contentTypestringyesMIME type (must match the original presign request)

Response:

{
  "ok": true
}

Errors:

  • VALIDATION_ERROR: Missing or invalid fields
  • UNAUTHORIZED: Your token is invalid

List Assets

Retrieve all assets your app has uploaded for a game. Useful for deduplication (skip uploading assets that already exist) and cleanup.

Request:

GET /api/v1/games/:gameId/assets
Authorization: Bearer pp_your_token

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

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)

Errors:

  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game

Delete Asset

Delete a single asset and its derived versions. If the asset is attached to a component, the reference is automatically removed from that component's payload.imageFaces. Only works on assets in a draft revision.

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
  • REVISION_FINALIZED: The asset belongs to a finalized revision and cannot be deleted
  • GAME_NOT_FOUND: The game does not exist
  • PERMISSION_DENIED: You do not own this game

Rate Limits

  • Presign requests: 60 per minute
  • Upload completion: 60 per minute

See Errors for all rate limits.

Typical Workflow

Here is the complete workflow for uploading assets and creating components:

// 1. Get presigned URL
const presignResponse = await fetch(
  `https://playtestparlor.com/api/v1/games/${gameId}/assets/presign`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      fileName: "card.png",
      contentType: "image/png",
      byteSize: fileSize,
      checksum: `sha256-${base64Hash}`
    })
  }
);
const { signedUrl, key, publicUrl, contentType } =
  await presignResponse.json();

// 2. Upload file to presigned URL
await fetch(signedUrl, {
  method: "PUT",
  headers: { "Content-Type": contentType },
  body: fileBlob
});

// 3. Confirm upload
await fetch(
  `https://playtestparlor.com/api/v1/games/${gameId}/assets/upload-complete`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ key, publicUrl, byteSize: fileSize, contentType })
  }
);

// 4. Create component with asset -- derivation happens automatically
await fetch(
  `https://playtestparlor.com/api/v1/games/${gameId}/components`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      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
      }]
    })
  }
);