All API errors follow a consistent JSON format. The HTTP status code indicates the category of error, and the error code provides specific details.
Error Format
Every error response is JSON whose data.error object holds the code, the message and, for some codes, details:
{
"statusCode": 400,
"data": {
"error": {
"code": "VALIDATION_ERROR",
"message": "The game name is required"
}
}
}
Read the code and message from data.error. The web server adds other top-level fields (a top-level error, when present, is only true); do not rely on them. A response that is not JSON, or has no data.error, comes from a proxy in between, not from Playtest Parlor.
HTTP Status Codes
| Status | Meaning | When It Occurs |
|---|---|---|
| 400 | Bad Request | Invalid request data (missing fields, wrong types, etc.) |
| 401 | Unauthorized | Missing or invalid authentication token |
| 403 | Forbidden | Token is valid but you lack permission (e.g., don't own the resource) |
| 402 | Payment Required | A limit of the account's plan was reached (games or storage). The message names the limit and the plan that lifts it |
| 404 | Not Found | Resource does not exist (game, asset, component, session) |
| 409 | Conflict | The data changed underneath the request (a session's history rewritten while its log downloaded) |
| 410 | Gone | The resource existed but has been deleted for good (a session past its history retention) |
| 413 | Payload Too Large | A single file is over the per-file limit |
| 429 | Too Many Requests | Rate limit exceeded |
| 502 | Bad Gateway | Server error or temporary service issue |
| 503 | Service Unavailable | Try again after the Retry-After seconds when the code is RETRY_LATER: a file you are uploading is being deleted from storage, imports are being upgraded, or an import is being applied to your app's pieces. A 503 without an API error body comes from a proxy in between, not from Playtest Parlor |
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | No authentication token provided |
INVALID_TOKEN | 401 | Token is malformed or no longer valid |
VALIDATION_ERROR | 400 | Request body has invalid or missing fields |
GAME_NOT_FOUND | 404 | Game does not exist |
ASSET_NOT_FOUND | 404 | Asset does not exist |
COMPONENT_NOT_FOUND | 404 | Component does not exist |
SESSION_NOT_FOUND | 404 | Play session does not exist or belongs to another game |
HISTORY_EXPIRED | 410 | The session's history has expired (24 hours after the session ended, or 90 days for a kept replay) and has been deleted |
HISTORY_CHANGED | 409 | The session's history changed while its action log downloaded (the newest action that stood when the download started was undone, or the table was reset). Download it again |
PERMISSION_DENIED | 403 | You do not own this resource |
RATE_LIMITED | 429 | Too many requests in this time window |
PLAN_LIMIT | 402 | A limit of the plan was reached: a new game past the plan's game limit, or an upload past the storage it counts against (the game owner's, for a file of a game) |
FILE_TOO_LARGE | 413 | This single file is over the 20 MB per-file limit |
UPSTREAM_FAILED | 502 | The image service refused or is unreachable. Retryable, and not your fault |
RETRY_LATER | 503 | Something the request depends on passes on its own. Retry after the Retry-After seconds; the message says which: the file you are uploading is being deleted from storage right now, as another game's last copy (the upload then stores it afresh); Playtest Parlor is upgrading how imported pieces are stored ("Imports are being upgraded.", about 2 minutes); or an import or copy is being applied to your app's pieces ("Your app's pieces in this game are being updated by an import.", about 30 seconds). Sending the same request again is always safe: a request that writes or deletes one component applies nothing when refused, and a batch refused part way keeps the entries written before the refusal, which sending the whole batch again writes again in place (every entry is an upsert) before it finishes the rest; see When Your Changes Are Live |
DERIVATION_FAILED | 400 | Asset image processing failed (invalid image, corrupted file, etc.) |
INVALID_CODE | 400 | Authorization code is invalid or malformed |
CODE_EXPIRED | 400 | Authorization code has expired (valid for 10 minutes) |
REDIRECT_URI_MISMATCH | 400 | Redirect URI does not match registered value |
CLIENT_ID_MISMATCH | 400 | Client ID is invalid or doesn't match the code |
Rate Limits
Two kinds of request are rate limited, and no others. Each limit is counted per Playtest Parlor account: every app acting for the same person, and that person's own use of the site, draw on one allowance.
| Request | Limit | Shares its allowance with |
|---|---|---|
POST /api/v1/games/{gameId}/assets/presign | 200 at once, then 600 a minute (one every 0.1 seconds) | POST /api/assets/cache-external, and every file the person uploads on the site |
POST /api/assets/cache-external | Counts against the presign allowance: one request counts once, however many URLs it sends | Presign, and every file the person uploads on the site |
POST /api/v1/games | 5 at once, then 10 an hour (one every 6 minutes) | Games the person creates on the site, or imports from a .pp file |
When you exceed a limit, you receive a 429 response with RATE_LIMITED code. The response includes a Retry-After header (in whole seconds) telling you when to retry, and the same number appears in the body as data.error.details.retryAfterSeconds. The data.error.message also says the wait in words, for example "Upload rate limit exceeded. Try again in 3 seconds."
The value comes from the rate limiter itself, so it is exactly when your next request will be accepted, never an estimate. It is rounded up, so waiting it out always works.
Waiting When Asked To
Two answers ask you to wait and send the same request again: 429 RATE_LIMITED and 503 RETRY_LATER. Both carry a Retry-After header with the seconds to wait, and the same number in data.error.details.retryAfterSeconds.
- Wait the seconds in
data.error.details.retryAfterSeconds(the same number asRetry-After). Waiting longer only slows you down; the server says when it expects to accept the request. - Send the same request again.
- Expect to be asked more than once, and keep going for several minutes before giving up: an upgrade of how imports are stored asks for 2 minutes at a time and can take a few minutes.
Any other 429 or 503, such as a proxy's error page with no API error body, is not one of these and should not be waited out.
A browser script on another site can call the /api/v1/ routes, and can read Retry-After from them: their CORS answer lists it in Access-Control-Expose-Headers. POST /api/assets/cache-external sends no CORS headers, so a browser script on another site cannot call it at all; call it from your server.
Example:
// Sends a request, waiting out RATE_LIMITED and RETRY_LATER for as long as each answer
// asks, up to ten minutes in all. Returns every other answer, or the last refusal once
// waiting longer would pass ten minutes.
async function fetchWithRetry(url, options, maxWaitSeconds = 10 * 60) {
for (let waited = 0; ; ) {
const response = await fetch(url, options);
if (response.status !== 429 && response.status !== 503) return response;
let error;
try { error = (await response.clone().json()).data?.error; } catch { /* not JSON */ }
if (error?.code !== "RATE_LIMITED" && error?.code !== "RETRY_LATER") return response;
const seconds = error.details.retryAfterSeconds;
if (waited + seconds > maxWaitSeconds) return response;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
waited += seconds;
}
}
Common Errors and Solutions
VALIDATION_ERROR on game creation
Error: Missing required field name
Solution: Ensure you include the name field in your request:
{
"name": "My Game"
}
INVALID_TOKEN on API request
Error: Your token is invalid or expired
Solution:
- Check that your token is correctly formatted (starts with
pp_) - Verify you're including it in the
Authorization: Bearerheader - If the user revoked the token in Settings, redirect them to authorize again
RATE_LIMITED on asset upload
Error: Upload rate limit exceeded. Try again in 1 second.
Solution:
- Wait the number of seconds specified in
Retry-After, then send the same request again - Upload fewer files at once: 200 uploads can start together, then one every 0.1 seconds
- Remember the allowance is shared with the person's own uploads on the site and with any other app acting for them
PLAN_LIMIT on creating a game
Error: You can have one game on Starter. To start a new one, upgrade to Casual for $18 per year.
The account already has as many games as its plan allows: one on Starter, 5 on Casual. Pro has no game limit. Nothing already there is deleted.
Solution: Upgrade the plan, then create the game again.
PLAN_LIMIT on asset upload
Error: This upload would not fit the storage it counts against
A file of a game counts against the storage of the game's owner. Each plan has its own storage: 50 MB on Starter, 300 MB on Casual, 20 GB on Pro. The message names the size of the file, how much room is left, and the plan's storage. POST /api/assets/cache-external cannot know an image's size before it fetches it, so it is refused before fetching only once the storage is full, and after fetching when the images it stored do not fit, which are then deleted.
Solution:
- Upgrade the plan
- Delete games or assets that are no longer needed
- Replace large art with smaller exports
FILE_TOO_LARGE on asset upload
Error: A single file is over the 20 MB per-file limit
This is a different problem from PLAN_LIMIT, and it has a different fix: the account has room, this one file is too big. Export the image at a lower resolution and upload it again.
DERIVATION_FAILED on image processing
Error: Image could not be processed
Solution:
- Verify the image is a valid PNG or JPEG
- Check that the file is not corrupted
- Ensure widthPx and heightPx values match the actual image dimensions
- Try re-uploading the asset