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
- Request a presigned URL from the server
- Upload the file directly to cloud storage using the presigned URL
- Confirm the upload with the server
- 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:
| 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 (max 20 MB) |
checksum | string | yes | SHA-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:
| Field | Type | Description |
|---|---|---|
signedUrl | string | URL where you PUT the file (valid for 30 minutes) |
key | string | Storage key for this asset. Pass to upload-complete and to the component's asset key field. |
publicUrl | string | Public URL of the uploaded file. Pass to upload-complete and to the component's asset sourceUrl field. |
contentType | string | The content type you provided |
Errors:
VALIDATION_ERROR: Missing or invalid metadataSTORAGE_LIMIT_EXCEEDED: Your account has reached its 2 GB storage quotaRATE_LIMITED: Upload rate limit exceededGAME_NOT_FOUND: The game ID does not existPERMISSION_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:
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | The storage key from the presign response |
publicUrl | string | yes | The public URL from the presign response |
byteSize | number | yes | File size in bytes (must match the original presign request) |
contentType | string | yes | MIME type (must match the original presign request) |
Response:
{
"ok": true
}
Errors:
VALIDATION_ERROR: Missing or invalid fieldsUNAUTHORIZED: 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:
| 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) |
Errors:
GAME_NOT_FOUND: The game does not existPERMISSION_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 appREVISION_FINALIZED: The asset belongs to a finalized revision and cannot be deletedGAME_NOT_FOUND: The game does not existPERMISSION_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
}]
})
}
);