# Trillboards API Error Reference

Every external Trillboards API endpoint returns errors using the canonical
envelope below. Phase 1 of the API DX overhaul locks this contract; Phase 2+
migrates each remaining surface onto it.

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "screen_not_found",
    "message": "Screen 'scr_xxx' not found or access denied.",
    "param": "screen_id",
    "doc_url": "https://api.trillboards.com/docs/errors#screen_not_found",
    "request_id": "req_a1b2c3d4e5f6789012345678901234ab",
    "details": [
      { "param": "screens[0].orientation", "code": "invalid_enum", "message": "Must be 'landscape' or 'portrait'." }
    ],
    "help": {
      "summary": "Screen IDs are prefixed 'scr_'. List your screens at GET /v1/partner/screens.",
      "suggestions": ["scr_abc123", "scr_def456"],
      "portal_url": "https://trillboards.com/earner/screens",
      "try_next": "/api/discover?q=screen"
    }
  }
}
```

**Field rules**

- `type` is one of `invalid_request_error` (4xx client), `authentication_error` (401),
  `permission_error` (403), `rate_limit_error` (429), `idempotency_error` (409),
  `api_error` (5xx).
- `code` is stable and machine-readable; it never changes across versions.
- `details` is present only when multiple field-level violations exist (e.g. a
  Zod validation result with more than one issue).
- `help` is present only when we have structured remediation context.
- `request_id` matches the `X-Request-Id` response header and is always set.
- `doc_url` is always computable as `https://api.trillboards.com/docs/errors#<code>`.

**HTTP status codes never change as part of the body migration.** Callers can
opt back into the prior flat envelope `{ statusCode, message, code, param,
request_id }` by sending the header `X-Error-Shape: legacy`. This opt-in is
removed two weeks after Phase 1 ships.

---

## Authentication Errors

### AUTH_REQUIRED

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: Connect an authenticated native buying account.

### authentication_required

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: No API key or access token was supplied.

```json
{ "error": { "type": "authentication_error", "code": "authentication_required", "message": "Authentication required.", "param": null, "doc_url": "https://api.trillboards.com/docs/errors#authentication_required", "request_id": "req_…" } }
```

**How to fix**: include `Authorization: Bearer <key>` on the request.

### invalid_api_key

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: The API key was not recognised or is inactive.

**How to fix**: rotate the key in the portal and retry with the fresh value.

### invalid_api_key_format

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: The API key does not match the expected format.

**How to fix**: Partner keys begin with `trb_partner_`. Make sure you copied
the full key, including the prefix.

### api_key_expired

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: The API key has expired and must be rotated.

**How to fix**: rotate the key in the partner portal.

### api_key_revoked

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: The API key has been revoked and can no longer be used.

**How to fix**: issue a new key in the portal.

### auth_error

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: Generic authentication failure.

**How to fix**: verify credentials and retry. Contact support with the
`request_id` if the problem persists.

---

## Permission Errors

### CREATIVE_ADVERTISEMENT_NOT_OWNED

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The native advertisement is not owned by the resolved advertiser.

### creative_policy_forbidden

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The native creative policy belongs to another advertiser.

### insufficient_scope

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The API key is valid but lacks the scope required for this endpoint.

**How to fix**: inspect `help.required_scope` and issue a key with the needed
scopes.

### insufficient_permissions

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The caller does not have permission to perform this action.

**How to fix**: contact an account owner to grant access, or use a different key.

### partner_inactive

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The partner account is not currently active.

**How to fix**: contact support to reactivate the partner account.

### forbidden

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: Access denied.

**How to fix**: verify the resource belongs to the authenticated caller.

### tier_insufficient

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: This endpoint requires a higher data-access tier than the caller has.

**How to fix**: upgrade the data-access tier. `help.required_tier` indicates the
minimum tier needed.

---

## Validation & Request Errors

### INVALID_FEEDBACK_CREATIVE

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Supply exactly one valid HTTPS URL, owned creative ID or base64 image with the native creative fields.

### SOURCE_CREATIVE_NOT_FOUND

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: The source creative ID has no asset in this buyer library.

### UNSUPPORTED_CREATIVE_TYPE

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The actual creative bytes or declared media type are unsupported for this native import or serving path.

### INVALID_CREATIVE_URL

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The creative download URL must use HTTPS and resolve to a public destination.

### CREATIVE_TOO_LARGE

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The creative exceeds the existing native media byte budget.

### INVALID_FEEDBACK_REQUEST

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: A required native creative-feedback field is invalid.

### EXPERIMENT_NOT_FOUND

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: The native experiment is absent or belongs to another buyer.

### INVALID_FEEDBACK_OBJECTIVE

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Choose gaze_share, gaze_overlap_ms or dwell_ms.

### MEDIA_BUY_NOT_FOUND

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: The native media package is absent or belongs to another buyer.

### INVALID_POLICY_VERSION

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Supply an exact nonnegative decimal BIGINT policy version string.

### INVALID_CREATIVE_WEIGHTS

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Use unique registered creatives with finite weights 0–1 and at least one positive weight.

### INVALID_VARIANTS

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Use 1–100 unique native variant and creative identities.

### BRAND_PROFILE_NOT_FOUND

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: The native brand profile is absent or belongs to another buyer.

### INVALID_BRAND_RULES

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The brand rules do not satisfy the existing native BrandRulesSchema.

### invalid_creative_policy

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The native creative policy owner, mode, version or weight generation is invalid.

### validation_error

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Request data failed validation. See `details[]` for per-field violations.

```json
{ "error": { "type": "invalid_request_error", "code": "validation_error", "message": "2 validation errors occurred.", "param": "email", "details": [ { "param": "email", "code": "invalid_string", "message": "Invalid email" }, { "param": "age", "code": "too_small", "message": "Must be >= 18" } ], "doc_url": "https://api.trillboards.com/docs/errors#validation_error", "request_id": "req_…" } }
```

**How to fix**: inspect every entry in `details[]` and resubmit with corrected values.

### not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: The requested resource does not exist or is not visible to the caller.

**How to fix**: list resources first to find a valid ID.

### IDEMPOTENCY_CONFLICT

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The enrollment key belongs to different arguments.

### CREATIVE_POLICY_CONFLICT

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: Refresh the native policy: its version or owner changed.

### EXPERIMENT_COMPLETED

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The native creative experiment has stopped.

### CREATIVE_NOT_READY

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The native creative must be brand approved, moderated and linked to the funded advertisement.

### VARIANT_IDENTITY_CONFLICT

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: An existing variant keeps its creative bytes and model identity.

### OPERATION_IN_PROGRESS

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The metered native operation is still in progress; do not execute it again under another key.

### OPERATION_ALREADY_COMMITTED

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The metered native operation already committed; its existing usage receipt is returned in help.usage_receipt.

### BRAND_PROFILE_CONFLICT

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The supplied native brand profile belongs to another campaign or owner scope.

### CREATIVE_NOT_LINKED

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: Explicit brand approval did not link the creative into the enabled native campaign.

### creative_policy_conflict

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The native creative policy version, mode or integration owner changed.

### FEEDBACK_POLICY_REQUIRED

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: Use the versioned partner creative-feedback methods for this funded experiment.

### conflict

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The requested change conflicts with the current state of the resource.

**How to fix**: re-fetch the resource and retry with the latest version.

### missing_required_field

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: A required field was not supplied.

**How to fix**: inspect `param` and include the missing field in the request body.

### invalid_field_value

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: A field was supplied with an invalid value.

**How to fix**: consult the OpenAPI schema for the accepted set of values.

### invalid_field_type

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: A field had the wrong JSON type.

**How to fix**: ensure the field type matches the schema (string/number/boolean/array).

### preferences_must_be_object

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The `preferences` field must be a JSON object (got a non-object type).

**How to fix**: send `preferences` as a JSON object (`{ ... }`), not an array, string, or number.

### field_must_be_boolean

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The named field must be a boolean `true` or `false`.

**How to fix**: pass the field as a JSON boolean, not as `"true"`/`"false"` strings or `0`/`1`.

### field_must_be_array

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The named field must be an array.

**How to fix**: wrap the value in square brackets — `[value]` even for a single entry.

### array_items_must_be_strings

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Every element of the named array must be a string.

**How to fix**: cast numeric or object elements to strings before sending, or remove them.

### lbar_settings_must_be_object

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The `lbar_settings` field must be a JSON object.

**How to fix**: send `lbar_settings` as a JSON object (`{ ... }`), or omit the field to use defaults.

### pip_settings_must_be_object

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The `pip_settings` field must be a JSON object.

**How to fix**: send `pip_settings` as a JSON object (`{ ... }`), or omit the field to use defaults.

### invalid_json

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The request body was not valid JSON.

**How to fix**: send a JSON body and set `Content-Type: application/json`.

### field_too_long

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: A string field exceeded its maximum length.

**How to fix**: shorten the offending field.

### array_too_large

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: An array field exceeded the maximum number of allowed items.

**How to fix**: split the request into multiple batches.

### invalid_array_items

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: One or more items in an array had an invalid type or value.

**How to fix**: inspect each item and ensure all match the schema.

### conflicting_lists

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The same value appeared in both an allow list and a block list.

**How to fix**: remove the duplicate entry from one of the lists.

### screen_not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Screen not found or access denied.

**How to fix**: call `GET /v1/partner/screens` to list valid screen IDs.

### invalid_screen_id

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Screen ID is not in the expected format.

**How to fix**: screen IDs are alphanumeric strings — verify you are not
prepending prefixes or trailing spaces.

### preset_not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Content preset not found.

**How to fix**: list available presets via `GET /v1/partner/content-presets`.

### preset_id_required

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `preset_id` is required.

**How to fix**: supply a valid `preset_id` in the request body.

### preferences_required

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Either `preferences` or `preset_id` is required.

**How to fix**: include one of the two fields in the request body.

### screen_ids_required

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `screen_ids` must be a non-empty array.

**How to fix**: pass at least one screen ID.

### batch_size_exceeded

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: More screens were sent in one request than the batch limit allows.

**How to fix**: split the request into smaller batches.

### invalid_preferences

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `preferences` must be an object.

**How to fix**: supply `preferences` as a JSON object.

### invalid_preference_level

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `preference_level` is not one of the allowed values.

**How to fix**: use one of the supported preference levels.

### invalid_age_rating

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `max_age_rating` is not one of the allowed values.

**How to fix**: use one of the supported age ratings.

### invalid_display_mode

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `display_mode` is not one of the allowed values.

**How to fix**: use one of the supported display modes.

### invalid_orientation

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `screen_orientation` is not one of the allowed values.

**How to fix**: use `landscape` or `portrait`.

### invalid_sizing_mode

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `advertisement_sizing` is not one of the allowed values.

**How to fix**: use one of the supported sizing modes.

### invalid_lbar_position

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `l_bar.position` is not one of the allowed values.

**How to fix**: choose one of the supported l-bar positions.

### invalid_pip_position

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `pip.position` is not one of the allowed values.

**How to fix**: choose one of the supported picture-in-picture positions.

### invalid_ad_surface_mode

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `ad_surface_mode` is not one of the allowed values.

**How to fix**: choose one of the supported ad-surface modes.

### ad_interval_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `ad_interval_seconds` is outside the supported range.

**How to fix**: clamp the interval to the documented min/max.

### lbar_size_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `l_bar.size_percent` is outside the supported range.

**How to fix**: clamp the size to the documented min/max.

### pip_size_ratio_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `pip.size_ratio` is outside the supported range.

**How to fix**: clamp the ratio to the documented min/max.

### pip_margin_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `pip.margin_px` is outside the supported range.

**How to fix**: clamp the margin to the documented min/max.

### invalid_lbar_settings

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `l_bar` must be an object.

**How to fix**: pass `l_bar` as a JSON object.

### invalid_pip_settings

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `pip` must be an object.

**How to fix**: pass `pip` as a JSON object.

### invalid_currency

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `currency` is not one of the allowed values.

**How to fix**: use a supported ISO-4217 currency code.

### invalid_provider

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `provider` is not one of the allowed values.

**How to fix**: choose one of the supported header-bidding providers.

### floor_price_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `floor_price_cpm` is outside the supported range.

**How to fix**: clamp `floor_price_cpm` to the documented min/max.

### allocation_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `programmatic_allocation_percent` is outside the supported range.

**How to fix**: clamp the value to the documented min/max.

### timeout_out_of_range

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: `timeout_ms` is outside the supported range.

**How to fix**: clamp `timeout_ms` to the documented min/max.

### blocked_advertisers_too_large

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Too many blocked advertisers.

**How to fix**: split the blocklist or remove stale entries.

### blocked_iab_categories_too_large

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Too many blocked IAB categories.

**How to fix**: split the blocklist or remove stale entries.

### webhook_not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Webhook endpoint not found.

**How to fix**: list webhooks and confirm the ID.

### invalid_webhook_url

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The webhook URL must be a valid HTTPS URL.

**How to fix**: supply an `https://` URL.

### webhook_url_unreachable

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The supplied webhook URL did not respond.

**How to fix**: confirm DNS, TLS, and that the target returns 2xx on a
health probe.

### invalid_webhook_events

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The supplied webhook event list contains unsupported values.

**How to fix**: consult the webhook documentation for supported event types.

### webhook_limit_exceeded

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The maximum number of webhooks for this partner has been reached.

**How to fix**: delete unused webhooks before creating new ones.

### webhook_secret_expired

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The webhook signing secret has expired.

**How to fix**: rotate the signing secret.

### device_not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Device not found or access denied.

**How to fix**: list devices first to find a valid ID.

### invalid_device_id

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Device ID is not in the expected format.

**How to fix**: confirm the device ID format with documentation.

### device_already_registered

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The device is already bound to another screen.

**How to fix**: unbind the device from its current screen before re-registering,
or register a new device.

### invalid_fingerprint

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Device fingerprint is not in the expected format.

**How to fix**: confirm the fingerprint format with documentation.

### stream_not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Stream not found or access denied.

**How to fix**: list streams first to find a valid ID.

### invalid_stream_type

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Stream type is not one of the allowed values.

**How to fix**: use a supported stream type.

### stream_content_required

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Stream content (video URL, images, etc.) must be provided.

**How to fix**: supply at least one content field.

### invalid_media_url

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Media URL is not a valid URL.

**How to fix**: supply a valid absolute URL.

### insufficient_data

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Not enough audience data has been collected for this screen yet.

**How to fix**: retry after the screen has accumulated more impressions.

### segment_not_found

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: Audience segment not found.

**How to fix**: list segments first to find a valid ID.

### segment_limit_exceeded

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: Maximum number of audience segments reached.

**How to fix**: delete unused segments before creating a new one.

### invalid_segment_rules

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: One or more segment rules are invalid.

**How to fix**: every rule must have `field`, `operator`, and `value`.

---

## Rate Limiting

### rate_limit_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: Too many requests in the current window. Retry after the advertised time.

Rate-limit responses also set the `Retry-After` header.

**How to fix**: back off for the seconds indicated by `Retry-After`, then retry.

### daily_quota_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: The caller has exhausted their daily API quota.

**How to fix**: wait until the next UTC day or upgrade the plan.

### concurrent_request_limit

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: Too many concurrent requests are in flight for this caller.

**How to fix**: reduce request concurrency.

---

## Concurrency & Idempotency

### resource_locked

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The resource is currently being modified by another request.

**How to fix**: retry after the in-flight request completes.

### stale_data

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: The resource has been modified since it was last fetched.

**How to fix**: re-fetch the resource and resend the update with the new version.

### idempotency_key_in_use

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: A request with this idempotency key is already in flight.

**How to fix**: retry with the same key once the previous request completes,
or use a fresh key.

### idempotency_key_mismatch

- **Type**: `idempotency_error`
- **HTTP status**: 422
- **Summary**: The idempotency key was reused with a different request body.

**How to fix**: either match the original body or use a fresh idempotency key.

### idempotency_error

- **Type**: `idempotency_error`
- **HTTP status**: 409
- **Summary**: Generic idempotency failure.

**How to fix**: consult the more specific idempotency_* codes and retry.

---

## API Errors

### internal_error

- **Type**: `api_error`
- **HTTP status**: 500
- **Summary**: An unexpected server error occurred. See `request_id` for the log correlation token.

The message never leaks server internals. Always include the `request_id` when
filing a support ticket.

**How to fix**: retry after a backoff. If the issue persists, contact support
with the `request_id`.

### database_error

- **Type**: `api_error`
- **HTTP status**: 500
- **Summary**: A database error occurred.

**How to fix**: retry after a backoff.

### service_unavailable

- **Type**: `api_error`
- **HTTP status**: 503
- **Summary**: A downstream service is temporarily unavailable.

**How to fix**: retry after a short backoff.

### CREATIVE_MODERATION_UNAVAILABLE

- **Type**: `api_error`
- **HTTP status**: 502
- **Summary**: The image bytes were staged but native moderation publication failed; the API allowance is released. Retry the bytes with a new operation key.

### POLICY_NOT_AVAILABLE

- **Type**: `api_error`
- **HTTP status**: 503
- **Summary**: The native campaign policy is unavailable.

### BRAND_CONFIGURATION_FAILED

- **Type**: `api_error`
- **HTTP status**: 503
- **Summary**: The native brand profile could not enable this funded reactive advertisement.

### device_offline

- **Type**: `api_error`
- **HTTP status**: 503
- **Summary**: The device is currently offline and cannot answer the request.

**How to fix**: retry once the device reconnects, or send the command via a
queueable endpoint.

### analytics_unavailable

- **Type**: `api_error`
- **HTTP status**: 503
- **Summary**: The audience analytics service is temporarily unavailable.

**How to fix**: retry after a short backoff.

### timeout

- **Type**: `api_error`
- **HTTP status**: 504
- **Summary**: The upstream operation exceeded its time budget.

**How to fix**: retry. If the problem persists, contact support with the
`request_id`.

### error

- **Type**: `api_error`
- **HTTP status**: 500
- **Summary**: Unspecified error. Callers should inspect `message` and retry if appropriate.

**How to fix**: fall back to retry with backoff; escalate with `request_id` if
the issue is persistent.

### device_identity_underivable

- **Type**: `api_error`
- **HTTP status**: 400
- **Summary**: `POST /v1/partner/device` (V2 registration) could not derive a
  canonical device identity — make, model, and OS — from the supplied `ua`
  string and `hardware` overrides.

**How to fix**: check `param` (a comma-separated list of the fields that could
not be resolved, e.g. `make,model`), then either send a real browser/WebView
User-Agent string in `ua`, or supply `hardware.make` / `hardware.model` /
`hardware.os` explicitly to bypass UA parsing entirely.

---

## Data API

Errors specific to the `/v1/data/*` surface (Phase 2 migration).

### screen_not_in_scope

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The requested screen is outside the API key's `scoped_screen_ids` allow-list.

**How to fix**: use a screen whose ID is listed in your API key's
`scoped_screen_ids`, or issue a new key without screen-level scoping. `GET
/v1/data/screens` returns the full set of screens the current key can see.

### dataset_not_scopable

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The FEIN DSL dataset does not support screen-level scoping.

**How to fix**: the dataset (for example `creative_venue_performance`) is
aggregated at venue granularity and cannot be restricted by screen. Either
remove screen-level scoping from the key or query a dataset with a
`screen_mongo_id` column.

### invalid_dataset

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The `dataset` body field is not one of the allowed FEIN DSL datasets.

**How to fix**: read `help.allowed` on the error payload for the list of
valid dataset names.

### invalid_dimensions

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: One or more requested dimensions are not defined on the target dataset.

**How to fix**: `help.allowed` lists the dimensions defined on the dataset;
use only those.

### invalid_measures

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: One or more requested measures are not defined on the target dataset.

**How to fix**: `help.allowed` lists the measures defined on the dataset;
use only those.

### invalid_order_by

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The `order_by` field is not in the selected dimensions/measures.

**How to fix**: every `order_by` entry must reference a field that is also
listed in `dimensions` or `measures`.

---

## SDK API

Errors specific to the `/v2/sdk/*` measurement surface (Phase 4 migration).

### client_ip_required

- **Type**: `invalid_request_error`
- **HTTP status**: 400
- **Summary**: The request did not carry a usable client IP.

**How to fix**: the SDK derives household identity and geolocation from the
client IP. Set `X-Forwarded-For` (via an upstream proxy) or send the request
from a direct connection so `getClientIP(req)` can resolve a valid value.

### api_key_owner_missing

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: The authenticated API key has no associated partner/owner.

**How to fix**: rotate the key in the portal. Missing owner IDs usually
indicate a test key that was created without a partner row and should be
replaced with a production key.

### sdk_enrich_rate_limit_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: `/v2/sdk/enrich` exceeded 300 requests per minute for this API key.

**How to fix**: batch enrichment client-side and back off for `Retry-After`
seconds. Cache the returned segments locally for the household lifetime of
the request IP (24h TTL server-side).

### sdk_qr_scan_rate_limit_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: `/v2/sdk/qr/scan` exceeded 100 requests per minute for this API key.

**How to fix**: a QR campaign should not produce more than ~100 scans per
minute per publisher — if you are seeing this frequently, investigate
whether the same QR is being mis-scanned by a crawler and coalesce client
side before posting.

### sdk_qr_beacon_rate_limit_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: `/v2/sdk/qr/beacon` exceeded 200 requests per minute for this API key.

**How to fix**: beacons should fire once per scan. Back off for `Retry-After`
seconds; if the limit is hit at steady state, request a quota lift via the
portal.

### sdk_impression_rate_limit_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: `/v2/sdk/impression` exceeded 1000 requests per minute for this API key.

**How to fix**: coalesce impressions client-side or split traffic across
multiple API keys with per-publisher scoping.

---

## Device API

Errors specific to the `/v2/device/*` managed-device control surface (Phase 4 migration).

### unknown_agent_type

- **Type**: `invalid_request_error`
- **HTTP status**: 404
- **Summary**: The requested device agent type has no OTA manifest entry.

**How to fix**: omit the `agent` query parameter to receive the full
manifest bundle, or supply one of the documented agent types
(`agent-core`, `agent-core-lite`, `tablet-agent`, `android-tv-agent`).

---

## Sensing API

Errors specific to the `/v2/sensing/*` paired-training surface (Phase 4 migration).

### paired_training_rate_limit_exceeded

- **Type**: `rate_limit_error`
- **HTTP status**: 429
- **Summary**: `/v2/sensing/paired-training-samples` exceeded 30 uploads per minute per device.

**How to fix**: the collector should batch ~500 samples per call and space
uploads across minutes. Back off for `Retry-After` seconds before the next
attempt.

### device_identity_missing

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: Device identity is missing after `requireDeviceRequestAuth` passed.

**How to fix**: ensure the device-token middleware is configured correctly.
If `deviceAuth.deviceId` is null, the resolved device has no canonical
identity and cannot be attributed — contact support with the `request_id`.

### paired_training_insert_failed

- **Type**: `api_error`
- **HTTP status**: 500
- **Summary**: Persisting the paired-training sample batch failed server-side.

**How to fix**: retry with backoff — the endpoint is idempotent on `sample_id`
so duplicate uploads from a previously partially-written batch are silently
deduped.

## OpenRTB / DSP

### openrtb_auth_failed

- **Type**: `authentication_error`
- **HTTP status**: 401
- **Summary**: The OpenRTB API key is missing or invalid.

**How to fix**: supply a valid API key via the `x-api-key` header or
`Authorization: Bearer <key>`. Keys are provisioned at DSP onboarding.

### openrtb_config_missing

- **Type**: `api_error`
- **HTTP status**: 500
- **Summary**: The server's OPENRTB_API_KEY environment variable is not configured.

**How to fix**: this is a server configuration issue. Contact support with the
`request_id` from the response.

### unsupported_media_type

- **Type**: `invalid_request_error`
- **HTTP status**: 415
- **Summary**: POST requests must set `Content-Type: application/json`.

**How to fix**: add the `Content-Type: application/json` header to your request.

### dsp_access_denied

- **Type**: `permission_error`
- **HTTP status**: 403
- **Summary**: The DSP account has been suspended or revoked.

**How to fix**: contact support@trillboards.com to resolve your account status.

### 204 No Content (no-fill)

The OpenRTB surface returns **HTTP 204 No Content** (empty body) when no ad
fill is available. This is canonical OpenRTB behavior and is NOT an error.
Callers must not treat 204 as a failure — it means no matching inventory was
available for the bid request parameters at the time of the request.
