> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ventry.es/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Error shape, code catalogue and what to do with each one.

Every error shares the same shape:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "ticket_type_not_allowed",
    "message": "Esta API key no tiene acceso al tipo de entrada indicado.",
    "param": "ticket_type",
    "request_id": "req_9f2c1a4e7b304d51"
  }
}
```

| Field        | Use                                                            |
| ------------ | -------------------------------------------------------------- |
| `type`       | Broad category. Use it to decide whether retrying makes sense. |
| `code`       | **Stable**. This is what you should branch on.                 |
| `message`    | Human-readable text. May be reworded without notice.           |
| `param`      | Request field that caused the error, where applicable.         |
| `request_id` | Request identifier. Also sent in `X-Request-Id`.               |

<Warning>
  Never branch on `message`. Rewording is not considered a breaking change;
  changing a `code` is.
</Warning>

<Note>
  Error messages are currently returned in Spanish, regardless of this page's
  language. They are meant for your logs and for support, not for showing to end
  users — build your user-facing text from `code`.
</Note>

## Types and HTTP status

| `type`                  | HTTP      | Retry?                    |
| ----------------------- | --------- | ------------------------- |
| `authentication_error`  | 401       | No. Fix the key.          |
| `permission_error`      | 403       | No. Missing scopes.       |
| `invalid_request_error` | 400 / 422 | No. Fix the request.      |
| `not_found_error`       | 404       | No.                       |
| `conflict_error`        | 409       | Depends. Read the `code`. |
| `rate_limit_error`      | 429       | Yes, after `retry-after`. |
| `api_error`             | 500       | Yes, with backoff.        |

## Code catalogue

### Authentication and permissions

| Code                     | HTTP | Meaning                                            |
| ------------------------ | ---- | -------------------------------------------------- |
| `missing_api_key`        | 401  | The `Authorization` header is missing or malformed |
| `invalid_api_key`        | 401  | The key does not exist                             |
| `revoked_api_key`        | 401  | The key has been revoked                           |
| `expired_api_key`        | 401  | The key has expired                                |
| `key_pending_activation` | 403  | Pending manual activation                          |
| `ip_not_allowed`         | 403  | Origin not authorised                              |
| `insufficient_scope`     | 403  | Missing the scope the endpoint requires            |

### Request

| Code               | HTTP      | Meaning                                     |
| ------------------ | --------- | ------------------------------------------- |
| `validation_error` | 400       | Body or parameters do not match the schema  |
| `invalid_limit`    | 400       | `limit` out of range (1–200)                |
| `invalid_cursor`   | 400       | `starting_after` is not a valid cursor      |
| `invalid_date`     | 400       | A date is not ISO 8601                      |
| `invalid_id`       | 400 / 422 | An identifier is not in the expected format |
| `unknown_endpoint` | 404       | The route does not exist                    |
| `empty_batch`      | 400       | A batch was sent with no tickets            |

### Key scope

| Code                      | HTTP | Meaning                                             |
| ------------------------- | ---- | --------------------------------------------------- |
| `ticket_type_not_allowed` | 422  | The ticket type is outside your key's scope         |
| `day_not_allowed`         | 422  | The day is outside your key's scope                 |
| `day_required`            | 422  | Your key is limited to specific days: say which one |

### Resources

| Code                    | HTTP | Meaning                                                 |
| ----------------------- | ---- | ------------------------------------------------------- |
| `ticket_not_found`      | 404  | The ticket does not exist, **or is outside your scope** |
| `wristband_not_found`   | 404  | The wristband does not exist, or is outside your scope  |
| `ticket_type_not_found` | 422  | The ticket type does not exist                          |
| `day_not_found`         | 422  | The day does not exist                                  |
| `zone_not_found`        | 422  | The zone does not exist                                 |

<Note>
  A resource outside your key's scope returns `404`, not `403`. This is deliberate:
  telling them apart would confirm that a given ticket code exists, which is
  exactly what someone enumerating codes would be after.
</Note>

### Conflicts

| Code                      | HTTP | Meaning                                                      |
| ------------------------- | ---- | ------------------------------------------------------------ |
| `resource_already_exists` | 409  | A ticket code in the batch already exists                    |
| `ticket_already_claimed`  | 409  | The ticket has already been accredited                       |
| `wristband_in_use`        | 409  | That wristband is already assigned to another attendee       |
| `idempotency_in_flight`   | 409  | The same `Idempotency-Key` is being processed right now      |
| `api_key_device_missing`  | 409  | The key has no device attached: ask for it to be reactivated |

### Writes

| Code                      | HTTP | Meaning                                                        |
| ------------------------- | ---- | -------------------------------------------------------------- |
| `missing_idempotency_key` | 400  | The `Idempotency-Key` header is missing                        |
| `invalid_idempotency_key` | 400  | The idempotency key is not of a reasonable length              |
| `idempotency_key_reused`  | 422  | That key was already used with a different body                |
| `checkin_failed`          | 422  | The accreditation could not be completed                       |
| `topup_failed`            | 422  | The wristband does not accept top-ups (blocked, under review…) |

### Other

| Code                  | HTTP | Meaning                                                                           |
| --------------------- | ---- | --------------------------------------------------------------------------------- |
| `rate_limit_exceeded` | 429  | Quota exhausted. Wait for what `retry-after` says.                                |
| `internal_error`      | 500  | Server failure. Retry and, if it persists, get in touch quoting the `request_id`. |

## Recommended retries

<CodeGroup>
  ```js Node.js theme={null}
  async function callVentry(path, options = {}, attempt = 0) {
    const response = await fetch(`https://api.ventry.es/v1${path}`, {
      ...options,
      headers: { Authorization: `Bearer ${process.env.VENTRY_KEY}`, ...options.headers },
    });

    if (response.ok) return response.json();

    const { error } = await response.json();

    // 429: the server tells you exactly how long to wait.
    if (response.status === 429 && attempt < 5) {
      const wait = Number(response.headers.get("retry-after") ?? 1) * 1000;
      await new Promise((r) => setTimeout(r, wait));
      return callVentry(path, options, attempt + 1);
    }

    // 5xx: exponential backoff. With an Idempotency-Key, retrying is safe.
    if (response.status >= 500 && attempt < 3) {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
      return callVentry(path, options, attempt + 1);
    }

    // The rest are your errors: retrying gives exactly the same result.
    throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
  }
  ```

  ```python Python theme={null}
  import os, time, requests

  BASE = "https://api.ventry.es/v1"

  def call_ventry(method, path, attempt=0, **kwargs):
      headers = {
          "Authorization": f"Bearer {os.environ['VENTRY_KEY']}",
          **kwargs.pop("headers", {}),
      }
      response = requests.request(method, f"{BASE}{path}", headers=headers, timeout=30, **kwargs)

      if response.ok:
          return response.json()

      error = response.json()["error"]

      # 429: the server tells you exactly how long to wait.
      if response.status_code == 429 and attempt < 5:
          time.sleep(int(response.headers.get("retry-after", 1)))
          return call_ventry(method, path, attempt + 1, headers=headers, **kwargs)

      # 5xx: exponential backoff. With an Idempotency-Key, retrying is safe.
      if response.status_code >= 500 and attempt < 3:
          time.sleep(2 ** attempt)
          return call_ventry(method, path, attempt + 1, headers=headers, **kwargs)

      # The rest are your errors: retrying gives exactly the same result.
      raise RuntimeError(f"{error['code']}: {error['message']} ({error['request_id']})")
  ```

  ```php PHP theme={null}
  <?php
  function callVentry(string $method, string $path, array $options = [], int $attempt = 0): array
  {
      $ch = curl_init("https://api.ventry.es/v1$path");
      curl_setopt_array($ch, [
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_HEADER => true,
          CURLOPT_CUSTOMREQUEST => $method,
          CURLOPT_HTTPHEADER => array_merge(
              ["Authorization: Bearer " . getenv("VENTRY_KEY")],
              $options["headers"] ?? []
          ),
      ] + (isset($options["body"]) ? [CURLOPT_POSTFIELDS => $options["body"]] : []));

      $raw = curl_exec($ch);
      $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
      $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
      $rawHeaders = substr($raw, 0, $headerSize);
      $body = json_decode(substr($raw, $headerSize), true);
      curl_close($ch);

      if ($status < 400) {
          return $body;
      }

      // 429: the server tells you exactly how long to wait.
      if ($status === 429 && $attempt < 5) {
          preg_match('/retry-after:\s*(\d+)/i', $rawHeaders, $m);
          sleep((int) ($m[1] ?? 1));
          return callVentry($method, $path, $options, $attempt + 1);
      }

      // 5xx: exponential backoff. With an Idempotency-Key, retrying is safe.
      if ($status >= 500 && $attempt < 3) {
          sleep(2 ** $attempt);
          return callVentry($method, $path, $options, $attempt + 1);
      }

      // The rest are your errors: retrying gives exactly the same result.
      throw new RuntimeException(sprintf(
          "%s: %s (%s)",
          $body["error"]["code"],
          $body["error"]["message"],
          $body["error"]["request_id"]
      ));
  }
  ```
</CodeGroup>
