# Errors

> BloomText API error responses, HTTP status codes, and machine-readable error codes.

Source: https://www.bloomtext.com/developers/api/errors/

BloomText uses standard HTTP status codes and returns [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details with `Content-Type: application/problem+json`. Branch on the `code` field, not the human-readable text.

```json filename="Response · 403 Forbidden"
{
  "type": "https://www.bloomtext.com/developers/api/errors/#conversation_membership_required",
  "title": "Forbidden",
  "status": 403,
  "code": "conversation_membership_required",
  "detail": "The app user is not a participant in this conversation.",
  "request_id": "0b6c2d1e-5f4a-4c3b-9a8d-7e6f5a4b3c2d",
  "field_errors": []
}
```

## The problem object

- `type` (URL, required): Link to the documentation for this error.

- `title` (string, required): Short, human-readable summary of the status.

- `status` (integer, required): The HTTP status code, repeated for convenience.

- `code` (string, required): Machine-readable error code. See [Error codes](#error-codes).

- `detail` (string): Explanation of this specific occurrence.

- `request_id` (UUID, required): Unique ID for the request. Include it when you contact support.

- `field_errors` (array of FieldError, required): Per-field problems for `422` responses. Empty otherwise.

  Child attributes:
- `path` (string, required): JSON pointer to the field, like `/body`, or the query parameter or header name.

- `code` (string, required): Machine-readable validation code, like `too_long` or `required`.

- `message` (string, required): Human-readable explanation.

## HTTP status codes

| Status | Meaning | Retry? |
| --- | --- | --- |
| `200`, `201`, `202`, `204` | Success. | No |
| `400` | The request is malformed. | No, fix the request. |
| `401` | The API key is missing, revoked, or invalid. | No, fix the key. |
| `403` | The key lacks the scope, or the app user isn't a participant. | No |
| `404` | The resource doesn't exist or isn't visible to this key. | No |
| `409` | An idempotency or state conflict. | Sometimes. See the code. |
| `422` | A field failed validation. | No, fix the fields. |
| `429` | Rate limited. | Yes, after `Retry-After` seconds. |
| `500`, `503` | A temporary problem on our side. | Yes, with backoff and the same `Idempotency-Key`. |

## Error codes

| Code | Status | What to do |
| --- | --- | --- |
| `invalid_request` | 400 | Check the JSON syntax and query string. |
| `unauthorized` | 401 | Check the `Authorization` header and that the key hasn't been revoked. |
| `insufficient_scope` | 403 | Ask an admin to add the scope listed on the endpoint's reference page. |
| `conversation_membership_required` | 403 | Add your app user to the conversation. |
| `not_found` | 404 | Check the ID. Resources outside your app user's conversations also return this. |
| `idempotency_key_reused` | 409 | Use a new `Idempotency-Key` for a different request. |
| `idempotency_request_in_progress` | 409 | The original request is still running. Retry in a moment. |
| `participant_not_in_organization` | 422 | Only members of your organization can be added to a conversation. |
| `validation_failed` | 422 | Read `field_errors` for each field to fix. |
| `rate_limited` | 429 | Wait `Retry-After` seconds, then retry. |
| `internal_error` | 500 | Retry with backoff. Contact support with the `request_id` if it persists. |
| `service_unavailable` | 503 | Retry with backoff. |

## Handle errors

```js
const response = await fetch(url, options)

if (!response.ok) {
  const problem = await response.json()
  switch (problem.code) {
    case 'conversation_membership_required':
      // Ask an admin to add the app user to this conversation.
      break
    case 'rate_limited':
      await new Promise((r) => setTimeout(r, Number(response.headers.get('Retry-After')) * 1000))
      break
    default:
      throw new Error(`${problem.status} ${problem.code}: ${problem.detail} (request ${problem.request_id})`)
  }
}
```

```python
response = session.post(url, json=payload, headers=headers)

if not response.ok:
    problem = response.json()
    if problem["code"] == "conversation_membership_required":
        ...  # Ask an admin to add the app user to this conversation.
    elif problem["code"] == "rate_limited":
        time.sleep(int(response.headers["Retry-After"]))
    else:
        raise RuntimeError(f"{problem['status']} {problem['code']}: {problem.get('detail')} (request {problem['request_id']})")
```

```go
type Problem struct {
	Status    int    `json:"status"`
	Code      string `json:"code"`
	Detail    string `json:"detail"`
	RequestID string `json:"request_id"`
}

if res.StatusCode >= 400 {
	var p Problem
	json.NewDecoder(res.Body).Decode(&p)
	switch p.Code {
	case "rate_limited":
		wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
		time.Sleep(time.Duration(wait) * time.Second)
	default:
		return fmt.Errorf("%d %s: %s (request %s)", p.Status, p.Code, p.Detail, p.RequestID)
	}
}
```

```ruby
unless response.is_a?(Net::HTTPSuccess)
  problem = JSON.parse(response.body)
  case problem["code"]
  when "rate_limited"
    sleep response["Retry-After"].to_i
  else
    raise "#{problem["status"]} #{problem["code"]}: #{problem["detail"]} (request #{problem["request_id"]})"
  end
end
```

> **Note:** Every response, success or error, includes an `X-Request-Id` header with the same value as `request_id`. Log it with your own request logs.
