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.

404 application/problem+json
{
  "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"
}
FieldWhat it holds
statusThe HTTP status, repeated in the body.
titleThe standard phrase for that status. It says nothing about your request.
detailThe first error's message, written for somebody reading a log.
instanceThe request path, with no query string and no host.
codeThe first error's code. Branch on this.
errorsEvery error behind the response, each with code, message and meta.
traceIdQuote 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.

400: the body arrived without playerId
{
  "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

CodeStatusWhat causes it
auth.api_key_invalid401Missing, malformed, unknown or revoked key. See Authentication.
billing_required402A Live key with no active billing.
billing_payment_overdue402A Live key after a failed payment.
project_environment.not_found404The environment behind the key did not load.
player.external_id_missing400No player id in the request.
player.external_id_too_long400Longer than 200 characters.
player.time_zone_invalid400Not an IANA zone, spelled as IANA spells it.
tenant.too_many_concurrent_requests429Too many of your requests at once. See below.
server.unexpected500An 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.

CodeWhat causes it
event.not_foundNo event with that key in this environment.
event.inactiveThe event exists but is switched off.
event.unknown_propertyA property that is not defined for this event.
event.required_property_missingA property the event requires had no value.
event.property_type_mismatchA property value is not the type it was defined as.
event.property_value_out_of_rangeA number outside what is stored: 20 digits and 8 decimal places.
event.item_missingAn 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

CodeStatusWhat causes it
player_attribute.not_found404No attribute with that key in this environment.
player_attribute.inactive409The attribute exists but is switched off.
player_attribute.type_mismatch400The value is not the type the attribute was defined as.
player_attribute.value_too_long400Text longer than 2,000 characters.
player_attribute.attributes_missing400The request set nothing.
player_attribute.concurrent_update409Nothing was written. Send it again.

A set is all or nothing: one bad key writes none of them.

Missions and streaks

CodeStatusWhat causes it
mission.not_found404No mission with that key, or one this player cannot see.
mission.not_locked_in404The player was never entered into the mission, so there is nothing to accept.
mission.window_closed409The mission is no longer open.
mission_collection.not_found404No collection with that key, or it is switched off.
streak.not_found404No streak with that key, or one this player cannot see.

The shop

CodeStatusWhat causes it
marketplace_item.not_found404No listing with that key.
marketplace_item.inactive409The listing is switched off.
marketplace_item.not_available409The listing is not on sale right now.
marketplace_item.not_eligible403This player is not allowed to buy it.
marketplace_item.out_of_stock409Sold out.
marketplace_item.purchase_limit_reached409This player has bought as many as they may.
points.insufficient_balance409The player cannot afford it.
player.not_found404Nothing has ever been sent for this player, so there is nobody to buy.

Points

CodeStatusWhat causes it
points.insufficient_balance409A debit larger than the balance. A balance never goes below zero.
points_adjustment.amount_zero400An adjustment of nothing.
points_adjustment.reason_too_long400Longer than 500 characters.
points.concurrent_update409Nothing was written. Send it again.

Limits

CodeStatusWhat causes it
sandbox.monthly_active_players_exceeded409A Sandbox saw its 100th player this month.
sandbox.daily_event_limit_exceeded409A Sandbox took its 10,000th event today.
monthly_mau_limit_exceeded409A Live environment reached the cap set on the organization.

Retrying

  • 409 with concurrent_update in the code. Nothing was written. Send the same request again.
  • 429. Nothing was written. Wait for Retry-After and send it again. Getting them steadily means sending fewer at once, not retrying faster.
  • 401, 400, 403 and 404. 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.

On this page