Errors
Every failure returns the same body, as application/problem+json. It is a
RFC 9457 problem document with three fields added:
code, errors and traceId.
{
"title": "Not Found",
"status": 404,
"detail": "Event 'order_placed' was not found.",
"instance": "/v1/events",
"code": "event.not_found",
"errors": [
{
"code": "event.not_found",
"message": "Event 'order_placed' was not found.",
"meta": null
}
],
"traceId": "00-4f3c1a9e7b2d48c0a1e5f60b93d2c714-9a1c7f0e2b6d4358-01"
}| Field | What it holds |
|---|---|
status | The HTTP status, repeated in the body. |
title | The standard phrase for that status. It says nothing about your request. |
detail | The first error's message, written for somebody reading a log. |
instance | The request path, with no query string and no host. |
code | The first error's code. Branch on this. |
errors | Every error behind the response, each with code, message and meta. |
traceId | Quote it in a support thread. |
When a request breaks several rules at once, all of them are in errors and the first is copied
into code and detail.
Branch on code, not on the message
code is stable. detail and every message are prose and can be reworded at any time.
if (!response.ok) {
const problem = await response.json()
switch (problem.code) {
case "points.insufficient_balance":
return showBalanceTooLow()
case "marketplace_item.out_of_stock":
return showSoldOut()
case "points.concurrent_update":
return retryOnce()
default:
throw new Error(`${problem.code}: ${problem.detail} (trace ${problem.traceId})`)
}
}Statuses
400, 401, 402, 403, 404, 409, 429 and 500 are the only ones you will see.
A 400 names the field it refused, so you do not need a table of every validation code: read
errors and fix the request. A field the request could not be read at all without comes back as
validation. plus that field, and validation.request when nothing can be named.
{
"title": "Bad Request",
"status": 400,
"detail": "The request is invalid.",
"instance": "/v1/players",
"code": "validation.player_id",
"errors": [
{
"code": "validation.player_id",
"message": "The PlayerId field is required.",
"meta": null
}
],
"traceId": "00-44583bf7ff0c47e3c30eb3277bb1eb81-547d059bf239562f-00"
}detail on these is always the same sentence, so read errors.
A 500 is always server.unexpected, carries no stack trace, and may be worth one retry.
On any endpoint
| Code | Status | What causes it |
|---|---|---|
auth.api_key_invalid | 401 | Missing, malformed, unknown or revoked key. See Authentication. |
billing_required | 402 | A Live key with no active billing. |
billing_payment_overdue | 402 | A Live key after a failed payment. |
project_environment.not_found | 404 | The environment behind the key did not load. |
player.external_id_missing | 400 | No player id in the request. |
player.external_id_too_long | 400 | Longer than 200 characters. |
player.time_zone_invalid | 400 | Not an IANA zone, spelled as IANA spells it. |
tenant.too_many_concurrent_requests | 429 | Too many of your requests at once. See below. |
server.unexpected | 500 | An unexpected failure. |
How many requests at once
There is no limit on requests per second. There is a limit on how many you have in flight at the
same time: 16, with 8 more allowed to wait their turn. Anything beyond that is refused
straight away with 429, tenant.too_many_concurrent_requests and a Retry-After header in
seconds.
The limit belongs to your organization, not to a key or an environment, so a backfill running against Sandbox takes slots from Live.
This is what makes batching worth it. One request carrying 1,000 events takes one slot; a thousand requests carrying one event each need a thousand.
Sending events
POST /v1/events answers 200 even when items were refused, and each item carries its own code.
See Sending events.
| Code | What causes it |
|---|---|
event.not_found | No event with that key in this environment. |
event.inactive | The event exists but is switched off. |
event.unknown_property | A property that is not defined for this event. |
event.required_property_missing | A property the event requires had no value. |
event.property_type_mismatch | A property value is not the type it was defined as. |
event.property_value_out_of_range | A number outside what is stored: 20 digits and 8 decimal places. |
event.item_missing | An empty item in the array. |
The whole call is refused with event_batch.empty, event_batch.too_large (over 1,000 items), or
event_batch.concurrent_update (409, nothing was written, send it again).
Attributes
| Code | Status | What causes it |
|---|---|---|
player_attribute.not_found | 404 | No attribute with that key in this environment. |
player_attribute.inactive | 409 | The attribute exists but is switched off. |
player_attribute.type_mismatch | 400 | The value is not the type the attribute was defined as. |
player_attribute.value_too_long | 400 | Text longer than 2,000 characters. |
player_attribute.attributes_missing | 400 | The request set nothing. |
player_attribute.concurrent_update | 409 | Nothing was written. Send it again. |
A set is all or nothing: one bad key writes none of them.
Missions and streaks
| Code | Status | What causes it |
|---|---|---|
mission.not_found | 404 | No mission with that key, or one this player cannot see. |
mission.not_locked_in | 404 | The player was never entered into the mission, so there is nothing to accept. |
mission.window_closed | 409 | The mission is no longer open. |
mission_collection.not_found | 404 | No collection with that key, or it is switched off. |
streak.not_found | 404 | No streak with that key, or one this player cannot see. |
The shop
| Code | Status | What causes it |
|---|---|---|
marketplace_item.not_found | 404 | No listing with that key. |
marketplace_item.inactive | 409 | The listing is switched off. |
marketplace_item.not_available | 409 | The listing is not on sale right now. |
marketplace_item.not_eligible | 403 | This player is not allowed to buy it. |
marketplace_item.out_of_stock | 409 | Sold out. |
marketplace_item.purchase_limit_reached | 409 | This player has bought as many as they may. |
points.insufficient_balance | 409 | The player cannot afford it. |
player.not_found | 404 | Nothing has ever been sent for this player, so there is nobody to buy. |
Points
| Code | Status | What causes it |
|---|---|---|
points.insufficient_balance | 409 | A debit larger than the balance. A balance never goes below zero. |
points_adjustment.amount_zero | 400 | An adjustment of nothing. |
points_adjustment.reason_too_long | 400 | Longer than 500 characters. |
points.concurrent_update | 409 | Nothing was written. Send it again. |
Limits
| Code | Status | What causes it |
|---|---|---|
sandbox.monthly_active_players_exceeded | 409 | A Sandbox saw its 100th player this month. |
sandbox.daily_event_limit_exceeded | 409 | A Sandbox took its 10,000th event today. |
monthly_mau_limit_exceeded | 409 | A Live environment reached the cap set on the organization. |
Retrying
409withconcurrent_updatein the code. Nothing was written. Send the same request again.429. Nothing was written. Wait forRetry-Afterand send it again. Getting them steadily means sending fewer at once, not retrying faster.401,400,403and404. Nothing will change on its own. Fix the request, or fix the setup in the dashboard.500. Retry with backoff, and send an idempotency key where the endpoint takes one.