Error Handling

Error codes, HTTP status codes, and rate limits for the Playtest Parlor API.

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

StatusMeaningWhen It Occurs
400Bad RequestInvalid request data (missing fields, wrong types, etc.)
401UnauthorizedMissing or invalid authentication token
403ForbiddenToken is valid but you lack permission (e.g., don't own the resource)
402Payment RequiredA limit of the account's plan was reached (games or storage). The message names the limit and the plan that lifts it
404Not FoundResource does not exist (game, asset, component, session)
409ConflictThe data changed underneath the request (a session's history rewritten while its log downloaded)
410GoneThe resource existed but has been deleted for good (a session past its history retention)
413Payload Too LargeA single file is over the per-file limit
429Too Many RequestsRate limit exceeded
502Bad GatewayServer error or temporary service issue
503Service UnavailableTry 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

CodeHTTP StatusDescription
UNAUTHORIZED401No authentication token provided
INVALID_TOKEN401Token is malformed or no longer valid
VALIDATION_ERROR400Request body has invalid or missing fields
GAME_NOT_FOUND404Game does not exist
ASSET_NOT_FOUND404Asset does not exist
COMPONENT_NOT_FOUND404Component does not exist
SESSION_NOT_FOUND404Play session does not exist or belongs to another game
HISTORY_EXPIRED410The session's history has expired (24 hours after the session ended, or 90 days for a kept replay) and has been deleted
HISTORY_CHANGED409The 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_DENIED403You do not own this resource
RATE_LIMITED429Too many requests in this time window
PLAN_LIMIT402A 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_LARGE413This single file is over the 20 MB per-file limit
UPSTREAM_FAILED502The image service refused or is unreachable. Retryable, and not your fault
RETRY_LATER503Something 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_FAILED400Asset image processing failed (invalid image, corrupted file, etc.)
INVALID_CODE400Authorization code is invalid or malformed
CODE_EXPIRED400Authorization code has expired (valid for 10 minutes)
REDIRECT_URI_MISMATCH400Redirect URI does not match registered value
CLIENT_ID_MISMATCH400Client 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.

RequestLimitShares its allowance with
POST /api/v1/games/{gameId}/assets/presign200 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-externalCounts against the presign allowance: one request counts once, however many URLs it sendsPresign, and every file the person uploads on the site
POST /api/v1/games5 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.

  1. Wait the seconds in data.error.details.retryAfterSeconds (the same number as Retry-After). Waiting longer only slows you down; the server says when it expects to accept the request.
  2. Send the same request again.
  3. 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:

  1. Check that your token is correctly formatted (starts with pp_)
  2. Verify you're including it in the Authorization: Bearer header
  3. 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:

  1. Wait the number of seconds specified in Retry-After, then send the same request again
  2. Upload fewer files at once: 200 uploads can start together, then one every 0.1 seconds
  3. 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:

  1. Upgrade the plan
  2. Delete games or assets that are no longer needed
  3. 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:

  1. Verify the image is a valid PNG or JPEG
  2. Check that the file is not corrupted
  3. Ensure widthPx and heightPx values match the actual image dimensions
  4. Try re-uploading the asset