# BazaarLink Documented Endpoint Reference

> Registry-backed English Markdown for all 40 public endpoints with dedicated BazaarLink reference pages. Internal, legacy, alias, and documentation-hold routes are intentionally not promoted as public API.

Canonical: https://bazaarlink.ai/docs/api-reference
OpenAPI JSON: https://bazaarlink.ai/docs/openapi.json
OpenAPI YAML: https://bazaarlink.ai/docs/openapi.yaml
Registry entries: 40

# Chat

## POST /api/v1/chat/completions

**Create a chat**

Requests a model response for the given chat conversation. Supports both streaming and non-streaming modes, with one OpenAI-compatible request/response shape across every model and provider.

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `model` | `string` | yes | The model to use, provider-qualified, e.g. "openai/gpt-4.1". Short ids from common model families (e.g. "gpt-4o") are automatically resolved to the full canonical id ("openai/gpt-4o"); values already in canonical form pass through unchanged. An unrecognized short id is not force-rewritten — model resolution surfaces a clear model-not-found error instead. minLength: 1 Example: "openai/gpt-4.1" |
| `messages` | `Message[]` | yes | Array of conversation messages, at least one. Each has a role (system/user/assistant/tool) and content. minLength: 1 |
| `models` | `string[]` | no | Fallback model list — tried in order, only used once the primary model's own candidate group is exhausted. |
| `stream` | `boolean` | no | true streams the response as SSE; omitted or false returns a single JSON response. |
| `temperature` | `number` | no | Sampling temperature — higher is more random, lower is more deterministic. 0–2 |
| `max_tokens` | `integer` | no | Maximum number of tokens the response may generate. |
| `max_completion_tokens` | `integer` | no | Alias of max_tokens with identical semantics. |
| `top_p` | `number` | no | Nucleus sampling threshold. 0–1 |
| `top_k` | `integer` | no | Limits sampling to the K highest-probability tokens. |
| `frequency_penalty` | `number` | no | Penalizes tokens based on how often they've already appeared. -2.0–2.0 |
| `presence_penalty` | `number` | no | Penalizes tokens based on whether they've appeared at all, regardless of count. -2.0–2.0 |
| `repetition_penalty` | `number` | no | Repetition penalty coefficient — a separate mechanism from frequency/presence penalty. |
| `min_p` | `number` | no | Minimum probability threshold relative to the most likely token. 0–1 |
| `top_a` | `number` | no | Another dynamic-cutoff sampling mechanism, scaling the threshold by the square of the top probability. |
| `seed` | `integer` | no | Deterministic sampling seed; not all providers guarantee reproducibility. |
| `n` | `integer` | no | Number of completions to generate. |
| `stop` | `string | string[]` | no | Sequences where generation stops, up to 4. |
| `tools` | `Tool[]` | no | List of tool/function definitions the model may call. |
| `tool_choice` | `string | object` | no | Controls whether/how the model is forced to call a tool. |
| `parallel_tool_calls` | `boolean` | no | Whether to allow parallel tool calls when tools are provided. Default true. OpenAI-family models only — silently stripped when the target model isn't OpenAI's own, not an error. |
| `response_format` | `object` | no | Constrains the output format, e.g. {type:"json_object"} or {type:"json_schema"} with a JSON Schema. |
| `logit_bias` | `object` | no | Maps token IDs to bias values [-100, 100] added before sampling. OpenAI-family models only — silently stripped when the target model isn't OpenAI's own, not an error. |
| `logprobs` | `boolean` | no | Return log probabilities of each output token. OpenAI-family models only — silently stripped when the target model isn't OpenAI's own, not an error. |
| `top_logprobs` | `integer` | no | Number of most-likely tokens to return per position (requires logprobs: true). OpenAI-family models only — silently stripped when the target model isn't OpenAI's own, not an error. 0–20 |
| `user` | `string` | no | End-user identifier for monitoring and abuse detection. Has no effect on billing. OpenAI-family models only — silently stripped when the target model isn't OpenAI's own, not an error. |
| `reasoning` | `object` | no | Reasoning-model thinking control object (e.g. {effort, max_tokens}). |
| `reasoning_effort` | `string` | no | Flat shorthand for reasoning.effort. Values: low, medium, high |
| `image_config` | `object` | no | Image generation/edit options (used when modalities includes image). |
| `modalities` | `string[]` | no | Requested output modalities. Including image routes through the image-generation dispatch path. Values: text, image |
| `plugins` | `object[]` | no | Array of plugins (e.g. {id:"web"} to enable web search). Supported only on some model routes, no effect elsewhere; the :online model variant auto-injects {id:"web"}, likewise only on supporting routes. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-4.1",
    "messages": [{"role": "user", "content": "Explain quantum computing in one paragraph."}]
  }'
```

### Response 200

Success. BazaarLink normalizes exactly two fields — model, and the removal of a provider field — and forwards the rest of the upstream response as-is, including fields like native_finish_reason, system_fingerprint, and reasoning that are only present when that specific provider populates them. usage.cost is the exception — always BazaarLink's own settled amount, not a value relayed from upstream.

```json
{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "created": 1740000000,
  "model": "openai/gpt-4o",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Quantum computing leverages quantum mechanics..."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 28,
    "completion_tokens": 74,
    "total_tokens": 102,
    "cost": 0.000648
  }
}
```

### Response 400

Invalid request. Common causes: malformed body, missing model/messages, or unsupported_modality — the messages include image input but every route for this model explicitly lacks image support (this check exists only on this endpoint; when the catalog has no modality data the request is passed through for the upstream to judge, so not every text-only model is guaranteed to return 400).

```json
{
  "error": {
    "message": "Model 'deepseek/deepseek-v4-flash' does not support image input.",
    "type": "invalid_request_error",
    "code": "unsupported_modality",
    "param": "messages"
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid API key",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "error": {
    "message": "Insufficient credits",
    "type": "invalid_request_error",
    "code": 402
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "error": {
    "message": "Rate limit exceeded",
    "type": "invalid_request_error",
    "code": 429
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or the service is draining for restart. Retry later.

```json
{
  "error": {
    "message": "Service temporarily unavailable — global spend limit reached. Try again shortly.",
    "type": "invalid_request_error",
    "code": 503
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model openai/gpt-3.5-turbo has been retired",
    "type": "model_not_available",
    "code": "model_retired"
  }
}
```

---

# Messages (Anthropic)

## POST /api/v1/messages

**Create a message**

Requests a model response using Anthropic's native /v1/messages shape; BazaarLink routes it to any supported model, not just Anthropic's own. Point the official Anthropic SDK's base_url at BazaarLink and existing code works unchanged. Accepts either Authorization: Bearer or the Anthropic SDK's conventional x-api-key header. If the resolved model isn't native Anthropic, BazaarLink transparently translates the request/response, preserving tool_use blocks and stop_reason semantics where possible.

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `model` | `string` | yes | The model to use, provider-qualified, e.g. "anthropic/claude-sonnet-4.6". minLength: 1 Example: "anthropic/claude-sonnet-4.6" |
| `messages` | `Message[]` | yes | Array of conversation messages, at least one. Each has a role (user/assistant) and content (a string, or an array of content blocks such as text/image/tool_use/tool_result). minLength: 1 |
| `max_tokens` | `integer` | yes | Maximum number of tokens the response may generate. Unlike the OpenAI-compatible endpoints, this field is required in the Anthropic shape. positive integer Example: 1024 |
| `system` | `string | ContentBlock[]` | no | System prompt — a separate top-level field, not a message inside the messages array (one of the main shape differences from the OpenAI format). |
| `stream` | `boolean` | no | true streams the response as SSE (message_start / content_block_delta / message_delta / message_stop events); omitted or false returns a single JSON response. |
| `temperature` | `number` | no | Sampling temperature — higher is more random, lower is more deterministic. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. 0–1 |
| `top_p` | `number` | no | Nucleus sampling threshold. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. 0–1 |
| `top_k` | `integer` | no | Limits sampling to the K highest-probability tokens. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. |
| `stop_sequences` | `string[]` | no | Array of strings where generation stops. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. |
| `tools` | `Tool[]` | no | List of tool definitions the model may call, Anthropic-shaped (name, description, input_schema). Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. |
| `tool_choice` | `object` | no | Controls whether/how the model is forced to call a tool, e.g. {type:"auto"}, {type:"any"}, {type:"tool", name:"..."}. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. |
| `thinking` | `object` | no | Extended-thinking control object, e.g. {type:"enabled", budget_tokens:2048}. Only meaningful on reasoning models. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. |
| `metadata` | `object` | no | Request metadata, e.g. {user_id:"..."}. Used for monitoring and abuse detection; has no effect on billing. Not validated by BazaarLink — forwarded as-is to the upstream. If the resolved model isn't native Anthropic, how this field is handled depends on BazaarLink's translation layer. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/messages \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "anthropic/claude-sonnet-4.6",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Hello"}]
  }'
```

### Response 200

Success. By default (or stream: false) returns the Anthropic-native Message object below; with stream: true, returns an SSE event stream instead (message_start / content_block_delta / message_delta / message_stop — see the deep guide for the protocol). model always echoes the name you requested (never the actually-resolved upstream model). usage keeps the native Anthropic fields (input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens) plus cost/bz_cost — BazaarLink's own settled amount, never a value relayed from upstream.

```json
{
  "id": "msg_01XFDUDYJgAACzvnptvVoYEL",
  "type": "message",
  "role": "assistant",
  "model": "anthropic/claude-sonnet-4.6",
  "content": [
    {
      "type": "text",
      "text": "Hello! How can I help you today?"
    }
  ],
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "usage": {
    "input_tokens": 10,
    "output_tokens": 12,
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 0,
    "cost": 0.000186,
    "bz_cost": 0.000186
  }
}
```

### Response 400

Invalid request — malformed JSON, missing required fields, or failed validation.

```json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "'max_tokens' is required and must be a positive integer."
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "type": "error",
  "error": {
    "type": "authentication_error",
    "message": "Invalid API key"
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "Insufficient credits"
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model anthropic/claude-2 has been retired",
    "type": "model_not_available",
    "code": "model_retired",
    "param": "model"
  }
}
```

### Response 413

Request body too large (10 MB limit).

```json
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "Request body too large (max 10 MB)."
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "type": "error",
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limit exceeded (free tier)."
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or the service is draining for restart. Retry later.

```json
{
  "type": "error",
  "error": {
    "type": "overloaded_error",
    "message": "Service temporarily unavailable — global spend limit reached. Try again shortly."
  }
}
```

---

# Responses API

## POST /api/v1/responses

**Create a response**

Requests a model response using the OpenAI Responses API request shape (input/instructions); internally converted to Chat Completions format and routed to any supported model — this is a translation layer, not a separate inference path. This is a stateless endpoint: passing store: true or a non-null previous_response_id returns 400 (see the 400 entry below for its distinct error shape) — every call must carry the full conversation in input. When streaming, reasoning is surfaced via response.reasoning_text.delta events and a type:"reasoning" output item.

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `model` | `string` | yes | The model to use, provider-qualified, e.g. "openai/gpt-5.2". minLength: 1 Example: "openai/gpt-5.2" |
| `input` | `string | InputItem[]` | yes | Conversation input — either a single string, or a structured array of items (message/function_call/function_call_output). Messages may use plain text or a content-block array including input_image. |
| `instructions` | `string` | no | System-level instructions, inserted as the first system message at the front of the converted messages array. |
| `stream` | `boolean` | no | true streams the response as Responses-API SSE events (response.created, response.output_item.added, response.output_text.delta, response.reasoning_text.delta, response.completed, and others); omitted or false returns a single JSON response. |
| `max_output_tokens` | `integer` | no | Maximum number of tokens the response may generate (converted to the upstream Chat Completions max_tokens). |
| `tools` | `Tool[]` | no | List of tool/function definitions the model may call. Accepts the Responses-spec flat shape {type:"function", name, description, parameters} (recommended), and also the Chat Completions nested shape {type:"function", function:{...}} (accepted for back-compat, passed through as-is). The response object's tools field echoes whatever was actually sent here. |
| `tool_choice` | `string | object` | no | Controls whether/how the model is forced to call a tool, e.g. {type:"function", name:"..."} (flat shape, recommended, auto-converted for upstream) or "auto". The response object's tool_choice field echoes whatever was sent here, defaulting to "auto" when omitted. |
| `parallel_tool_calls` | `boolean` | no | Whether to allow parallel tool calls when tools are provided. Defaults to true when omitted; the response object's parallel_tool_calls field echoes whatever was sent here. |
| `plugins` | `object[]` | no | Array of plugins (e.g. {id:"web"} to enable web search). Supported only on some model routes, no effect elsewhere; the :online model variant auto-injects {id:"web"} only when you didn't send any plugins, likewise only on supporting routes. |
| `store` | `boolean` | no | ⚠️ This endpoint is stateless — passing true returns 400 immediately, it is never accepted or silently ignored. Omitting it or passing false is the only valid usage. |
| `previous_response_id` | `string` | no | ⚠️ This endpoint is stateless — passing any non-null value returns 400 immediately, it is never accepted or silently ignored. To continue a conversation, send the full history in the input array instead. |
| `models` | `string[]` | no | Fallback model list — tried in order, only used once the primary model's own candidate group is exhausted. |
| `reasoning` | `object` | no | Reasoning-model thinking control object (e.g. {effort, max_tokens}), forwarded as-is upstream — no defaults are injected. |
| `reasoning_effort` | `string` | no | Flat shorthand for reasoning.effort. Values: low, medium, high |
| `thinking` | `object` | no | Claude extended-thinking's additive token budget (e.g. {type:"enabled", budget_tokens:2048}), on top of max_output_tokens — forwarded as-is, no defaults injected. |
| `enable_thinking` | `boolean` | no | Thinking on/off toggle for Qwen3/GLM-family models, forwarded as-is. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/responses \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-5.2",
    "input": "What is the capital of France?"
  }'
```

### Response 200

Success. By default (or stream: false) returns the Response object below; with stream: true, returns the Responses API's SSE event stream instead. model always echoes the name you requested. output[] is composed of whatever was actually produced: a type:"reasoning" item first when reasoning content was present, followed by a type:"message" text item (its content's annotations array may carry citation sources, e.g. url_citation from a web search hit), or a type:"function_call" item. usage is converted from the underlying Chat Completions usage (input_tokens/output_tokens/total_tokens/cost), with an additional output_tokens_details.reasoning_tokens when reasoning tokens were used. tool_choice/tools/parallel_tool_calls echo whatever you actually sent in the request.

```json
{
  "id": "resp_6f2a1c9d8e7b4a3f9c1d2e3f",
  "object": "response",
  "created_at": 1753500000,
  "completed_at": 1753500002,
  "status": "completed",
  "model": "openai/gpt-5.2",
  "output": [
    {
      "type": "reasoning",
      "id": "rs_1a2b3c4d5e6f7a8b9c0d1e2f",
      "status": "completed",
      "summary": [],
      "content": [
        {
          "type": "reasoning_text",
          "text": "The user is asking a simple geography fact..."
        }
      ]
    },
    {
      "type": "message",
      "id": "msg_9f8e7d6c5b4a3f2e1d0c9b8a",
      "role": "assistant",
      "status": "completed",
      "content": [
        {
          "type": "output_text",
          "text": "The capital of France is Paris.",
          "annotations": []
        }
      ]
    }
  ],
  "usage": {
    "input_tokens": 14,
    "output_tokens": 32,
    "total_tokens": 46,
    "cost": 0.00021,
    "output_tokens_details": {
      "reasoning_tokens": 18
    }
  },
  "error": null,
  "incomplete_details": null,
  "tool_choice": "auto",
  "tools": [],
  "truncation": "auto",
  "parallel_tool_calls": true,
  "metadata": {},
  "store": false
}
```

### Response 400

Invalid request. Most causes (malformed JSON, missing required fields, input converted to zero messages, context exceeding the model's limit) use the standard {error:{message,type,code}} shape. ⚠️ One exception: passing store: true or a non-null previous_response_id uses {error:{code:"invalid_prompt",message,type},metadata:null} instead — a STRING code, not a number, plus an extra top-level metadata field, as shown in the example below.

```json
{
  "error": {
    "code": "invalid_prompt",
    "message": "This API is stateless: 'previous_response_id' is not supported. Pass the full conversation history in the 'input' array instead.",
    "type": "invalid_request_error"
  },
  "metadata": null
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid API key",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "error": {
    "message": "Insufficient credits",
    "type": "invalid_request_error",
    "code": 402
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model openai/gpt-4-vision has been retired",
    "type": "model_not_available",
    "code": "model_retired",
    "param": "model"
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "error": {
    "message": "Rate limit exceeded (free tier). Please slow down.",
    "type": "invalid_request_error",
    "code": 429
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or this tier was disabled by an admin. Retry later.

```json
{
  "error": {
    "message": "Service temporarily unavailable. Please retry shortly.",
    "type": "api_error",
    "code": 502
  }
}
```

---

# Images

## POST /api/v1/images/generations

**Generate an image**

Generates an image from a text prompt. Returns OpenAI-compatible sync JSON by default (works with client.images.generate()); pass stream: true to get an SSE event stream instead. Image-to-image is supported via inputImages / input_references.

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `model` | `string` | yes | The image generation model to use, provider-qualified, e.g. "openai/gpt-image-2". minLength: 1 Example: "openai/gpt-image-2" |
| `prompt` | `string` | yes | Text description of the desired image. minLength: 1 Example: "a red cat on a sofa" |
| `n` | `integer` | no | Number of images to generate, 1–10. 1–10 Example: 1 |
| `size` | `string` | no | Image size — explicit pixels ("2048x2048") or a tier ("2K", "4K"). Forwarded to the provider as-is; supported sizes vary by model. Example: "1024x1024" |
| `stream` | `boolean` | no | Selects the response shape: omitted or false = OpenAI-compatible sync JSON (the default), true = SSE event stream. |
| `input_references` | `string[]` | no | Source images for image-to-image (data: or https: URLs), up to 8. Alias of inputImages (industry-conventional field name); both pass content moderation first. Returns 400 — not a warning — when the model's route does not support i2i. ≤ 8 images |
| `aspect_ratio` | `enum<string>` | no | Aspect ratio of the generated image. Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). Values: 1:1, 1:2, 1:4, 1:8, 2:1, 2:3, 3:2, 3:4, 4:1, 4:3, 4:5, 5:4, 8:1, 9:16, 16:9, 9:19.5, 19.5:9, 9:20, 20:9, 9:21, 21:9, auto Example: "16:9" |
| `resolution` | `enum<string>` | no | Resolution tier; concrete pixels are derived per provider. Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). Values: 512, 1K, 2K, 4K Example: "2K" |
| `quality` | `enum<string>` | no | Rendering quality. Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). Values: auto, low, medium, high |
| `background` | `enum<string>` | no | Background treatment; transparent requires an alpha-capable output_format (png/webp). Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). Values: auto, transparent, opaque |
| `output_format` | `enum<string>` | no | Encoding of the returned image bytes. Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). Values: png, jpeg, webp, svg Example: "png" |
| `output_compression` | `integer` | no | Compression level (0–100) for webp/jpeg output; ignored for png. Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). 0–100 Example: 100 |
| `seed` | `integer` | no | Deterministic sampling seed; identical seed + parameters should reproduce the result, though not all providers guarantee it. Natively supported on some model routes — check supported_parameters in the GET /api/v1/images/models response for each model; unsupported routes ignore the field and note it in the response's warnings[] (the request still succeeds). |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/images/generations \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-image-2",
    "prompt": "a red cat on a sofa",
    "size": "1024x1024"
  }'
```

### Response 200

Success. By default (or stream: false) returns the sync JSON below; with stream: true, returns an SSE event stream (see the deep guide for the event protocol). Parameters ignored by the route appear in top-level warnings[] (informational only).

```json
{
  "created": 1753500000,
  "data": [
    {
      "url": "https://bazaarlink.ai/api/v1/images/proxy/eyJhbGciOi..."
    }
  ],
  "warnings": [
    {
      "param": "aspect_ratio",
      "action": "dropped",
      "reason": "'aspect_ratio' is not yet mapped for this model's upstream and was ignored; the image was still generated using the model's default."
    }
  ]
}
```

### Response 400

Invalid request — malformed JSON, missing required fields, or failed validation.

```json
{
  "error": {
    "message": "'model' is required.",
    "type": "invalid_request_error",
    "code": 400
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid API key",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "error": {
    "message": "Insufficient credits",
    "type": "invalid_request_error",
    "code": 402
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "error": {
    "message": "Rate limit exceeded",
    "type": "invalid_request_error",
    "code": 429
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or the service is draining for restart. Retry later.

```json
{
  "error": {
    "message": "Service temporarily unavailable — global spend limit reached. Try again shortly.",
    "type": "invalid_request_error",
    "code": 503
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model openai/dall-e-2 has been retired",
    "type": "model_not_available",
    "code": "model_retired"
  }
}
```

### Response 413

Request body too large (10 MB limit).

```json
{
  "error": {
    "message": "Request body too large (max 10 MB).",
    "type": "invalid_request_error",
    "code": 413
  }
}
```

---

## POST /api/v1/images/edits

**Edit an image**

Uploads source images as multipart/form-data and edits them with a text prompt — compatible with OpenAI client.images.edit(). Reuses the image-to-image pipeline internally; the response is always sync JSON. mask is not supported yet, and response_format only supports URLs (b64_json is rejected).

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `image` | `file` | yes | Source image file(s) to edit. Repeat the image field (or use image[]) for multiple files — up to 8, 10 MB each, MIME must be image/*. ≤ 8 files, ≤ 10 MB each, image/* |
| `prompt` | `string` | yes | Text description of the edit to apply. minLength: 1 Example: "change the background to a night city" |
| `model` | `string` | no | The image edit model. When omitted, the server default applies (currently gpt-5.4-image-2). Example: "openai/gpt-5.4-image-2" |
| `n` | `integer` | no | Number of results to generate, 1–10. 1–10 Example: 1 |
| `size` | `string` | no | Output size, must be a "<width>x<height>" pixel string (e.g. 1024x1024); values in any other format are silently ignored. format: <width>x<height> Example: "1024x1024" |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/images/edits \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -F model="openai/gpt-5.4-image-2" \
  -F image=@cat.png \
  -F prompt="change the background to a night city"
```

### Response 200

Success. Always returns sync JSON (the SSE stream is drained internally) — compatible with client.images.edit().

```json
{
  "created": 1753500000,
  "data": [
    {
      "url": "https://bazaarlink.ai/api/v1/images/proxy/eyJhbGciOi..."
    }
  ]
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid API key",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "error": {
    "message": "Insufficient credits",
    "type": "invalid_request_error",
    "code": 402
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "error": {
    "message": "Rate limit exceeded",
    "type": "invalid_request_error",
    "code": 429
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or the service is draining for restart. Retry later.

```json
{
  "error": {
    "message": "Service temporarily unavailable — global spend limit reached. Try again shortly.",
    "type": "invalid_request_error",
    "code": 503
  }
}
```

### Response 400

Invalid request — malformed JSON, missing required fields, or failed validation.

```json
{
  "error": {
    "message": "mask is not supported yet",
    "type": "invalid_request_error",
    "code": "mask_not_supported"
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model openai/dall-e-2 has been retired",
    "type": "model_not_available",
    "code": "model_retired"
  }
}
```

### Response 413

Request body too large (10 MB limit).

```json
{
  "error": {
    "message": "Request body too large (max 10 MB).",
    "type": "invalid_request_error",
    "code": 413
  }
}
```

---

## GET /api/v1/images/proxy/:token

**Fetch a cached image**

Returns the raw bytes of a generated image. The image-cached events and data[].url values in generation responses point here; the token itself is the access credential (no API key needed) and expires after 1 hour, after which the endpoint returns 404.

Authentication: Public

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `token` | `string` | yes | The image token issued in the generation response. Only UUID v4 is accepted; anything else returns 404. UUID v4 |

### cURL

```bash
curl -o result.png \
  https://bazaarlink.ai/api/v1/images/proxy/9f1c2d3e-4a5b-4c6d-8e7f-0a1b2c3d4e5f
```

### Response 200

Success. Returns the raw image bytes (not JSON) with the image's MIME as Content-Type (SVG is neutralized to application/octet-stream to prevent same-origin script execution) and a 1-hour immutable cache header.

```text
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 428381
Cache-Control: public, max-age=3600, immutable
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox

<binary image bytes>
```

### Response 404

The token is not a UUID v4, or the image has expired (1-hour TTL) or never existed. The response body is plain text, not JSON.

```text
Image expired or not found
```

### Response 503

The cache storage is temporarily unavailable. The response body is plain text, not JSON.

```text
Storage unavailable
```

---

## GET /api/v1/images/models

**List image generation models**

Lists every available image generation model, each with supported_parameters — the authoritative list of what that model's route actually accepts (e.g. whether aspect_ratio is honored natively, whether input_references enables image-to-image). No auth required; with an organization API key, the response is filtered by the org's model allowlist.

Authentication: Public

### cURL

```bash
curl https://bazaarlink.ai/api/v1/images/models
```

### Response 200

Success. Returns the model list; cached for 5 minutes (private, uncached with an organization key). supported_parameters is the authoritative per-route support table.

```json
{
  "object": "list",
  "data": [
    {
      "id": "openai/gpt-image-2",
      "object": "model",
      "created": 1753500000,
      "owned_by": "openai",
      "name": "GPT Image 2",
      "description": "…",
      "pricing": {
        "prompt": "0.000005",
        "completion": "0.00004",
        "image": "0.04"
      },
      "architecture": {
        "modality": "text+image->image",
        "input_modalities": [
          "text",
          "image"
        ],
        "output_modalities": [
          "image"
        ]
      },
      "supported_parameters": [
        "model",
        "prompt",
        "n",
        "size",
        "stream",
        "aspect_ratio",
        "resolution",
        "quality",
        "background",
        "output_format",
        "output_compression",
        "seed",
        "input_references"
      ]
    }
  ]
}
```

### Response 500

The server temporarily failed to fetch the model list. Retry later.

```json
{
  "error": {
    "message": "Failed to fetch image models",
    "type": "server_error",
    "code": 500
  }
}
```

---

# Embeddings

## POST /api/v1/embeddings

**Create embeddings**

Converts text (or, for models that support it, multimodal content) into vector embeddings. Request and response shapes match OpenAI's embeddings API, so it works directly with client.embeddings.create(). input accepts a single string or an array of strings.

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `model` | `string` | yes | The embeddings model to use, provider-qualified, e.g. "openai/text-embedding-3-small". minLength: 1 Example: "openai/text-embedding-3-small" |
| `input` | `string | string[]` | yes | The text to embed. Accepts a single string, or an array of strings to embed multiple inputs in one request. Some models additionally accept structured multimodal content items, forwarded to the upstream as-is; actual support depends on the model. Example: "The quick brown fox" |
| `encoding_format` | `string` | no | Encoding format, forwarded to the upstream as-is with no local validation; supported values depend on the upstream. Example: "float" |
| `dimensions` | `integer` | no | The number of dimensions for the output vector, forwarded to the upstream as-is with no local validation; only some models support custom dimensions, and the upstream decides the behavior (typically ignore or error) when unsupported. |
| `provider` | `object` | no | Provider routing preferences (order, only, ignore, allow_fallbacks, sort, …). Only forwarded and honored on certain model routes; unsupported routes drop it silently and never send it upstream. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/embeddings \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/text-embedding-3-small",
    "input": "The quick brown fox"
  }'
```

### Response 200

Success. Returns the OpenAI-compatible shape; data[] corresponds to each input in order, and usage.cost is the amount actually billed for this request.

```json
{
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": [
        0.0023064255,
        -0.009327292,
        0.015797347
      ]
    }
  ],
  "model": "openai/text-embedding-3-small",
  "usage": {
    "prompt_tokens": 5,
    "total_tokens": 5,
    "cost": 1e-7
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid API key",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "error": {
    "message": "Insufficient credits",
    "type": "invalid_request_error",
    "code": 402
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "error": {
    "message": "Rate limit exceeded",
    "type": "invalid_request_error",
    "code": 429
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or the service is draining for restart. Retry later.

```json
{
  "error": {
    "message": "Service temporarily unavailable — global spend limit reached. Try again shortly.",
    "type": "invalid_request_error",
    "code": 503
  }
}
```

### Response 400

Invalid request — malformed JSON, missing required fields, or failed validation.

```json
{
  "error": {
    "message": "No route found for model 'unknown/model-name'.",
    "type": "invalid_request_error",
    "code": "unknown_model",
    "param": "model"
  }
}
```

### Response 403

A Management API Key was used to call this endpoint, or the account is suspended/not found. Management Keys can only call key-management endpoints, not model requests.

```json
{
  "error": {
    "message": "Management keys cannot make model calls. Use a standard API key.",
    "type": "invalid_request_error",
    "code": 403
  }
}
```

### Response 404

The model exists in the catalog but currently has no configured, priced route available (a zero-price model).

```json
{
  "error": {
    "message": "Model has no priced route configured",
    "type": "model_not_available",
    "code": "model_not_configured"
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model openai/text-embedding-ada-002 has been retired",
    "type": "model_not_available",
    "code": "model_retired"
  }
}
```

### Response 502

All available upstream providers failed, or the upstream response could not be parsed. Retry shortly.

```json
{
  "error": {
    "message": "Service temporarily unavailable. Please retry shortly.",
    "type": "invalid_request_error",
    "code": 502
  }
}
```

---

## GET /api/v1/models

**List embedding models**

Returns available models, filterable via output_modalities. This is not a dedicated embeddings endpoint — it is the shared model-listing endpoint (GET /api/v1/models); passing output_modalities=embeddings narrows it to embedding models only. When the parameter is omitted, the default returns text models only, not everything.

Authentication: Public

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `output_modalities` | `string` | no | A single comma-separated string (not repeated same-name query keys), e.g. embeddings or embeddings,image. Pass all to get every modality. Defaults to text only when omitted. Unrecognized values return 400. comma-separated, single value Example: "embeddings" |

### cURL

```bash
curl "https://bazaarlink.ai/api/v1/models?output_modalities=embeddings"
```

### Response 200

Success. data[] contains only models matching the filter; each entry includes pricing (USD per million tokens), context_length, architecture.output_modalities, and related fields.

```json
{
  "object": "list",
  "data": [
    {
      "id": "text-embedding-3-small",
      "object": "model",
      "owned_by": "openai",
      "name": "OpenAI: text-embedding-3-small",
      "context_length": 8191,
      "aliases": [
        "openai/text-embedding-3-small"
      ],
      "pricing": {
        "prompt": "0.00000002",
        "completion": "0"
      },
      "architecture": {
        "modality": "text->embedding",
        "input_modalities": [
          "text"
        ],
        "output_modalities": [
          "embeddings"
        ]
      }
    }
  ]
}
```

### Response 400

Malformed output_modalities — not a single comma-separated string, contains an unrecognized modality value, or exceeds the length limit.

```json
{
  "error": {
    "message": "output_modalities must be a single comma-separated string",
    "type": "invalid_request_error",
    "code": "invalid_output_modalities"
  }
}
```

### Response 500

An unexpected error occurred while fetching the model catalog.

```json
{
  "error": {
    "message": "Internal error fetching models",
    "code": 500
  }
}
```

---

# Videos

## POST /api/v1/videos

**Submit a video generation request**

Submits an asynchronous video generation job. Returns 202 immediately with a job id (prefixed vjob_); poll GET /api/v1/videos/{id} until status is completed, then download via unsigned_urls. Billing: credits are reserved at submit from duration × resolution, and settled on the actual delivered seconds (usage.cost in the poll response).

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `model` | `string` | yes | The video generation model ID (provider-qualified). Example: "alibaba/wan2.7-t2v" |
| `prompt` | `string` | yes | The text prompt describing the video to generate. |
| `duration` | `integer` | no | Video length in seconds. Defaults to 2. Each model has tighter bounds — e.g. reference-to-video (r2v) allows 2–10s; out-of-range values return 400 before any credits are reserved. 1–600 (per-model bounds are tighter; reference-to-video: 2–10) Example: 5 |
| `resolution` | `string` | no | Output quality tier, default "720p". Supported tiers vary per model; an unsupported tier returns 400 before any credits are reserved, and the error lists the tiers the model does support. Values: 480p, 720p, 1080p Example: "720p" |
| `aspect_ratio` | `string` | no | Aspect ratio. Common values: "16:9", "9:16", "1:1", "4:3", "3:4"; the exact set depends on the model (some also take wide ratios like "21:9"). Unsupported combinations return 400 before credits are reserved; modes that take an input frame derive the ratio from the frame and ignore this field. Values: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9, 9:21 Example: "16:9" |
| `frame_images` | `array` | no | First/last keyframe images (image-to-video, keyframe-to-video). Accepts bare URL strings, or objects { type: "image", image_url: { url }, frame_type: "first" \| "last" }. |
| `input_references` | `object[] | string[]` | no | Reference assets (reference-to-video): guide generation with reference items — prefer object form { type: "image", image_url: { url } } (bare URL strings are also accepted and treated as image references). Image references are the most widely supported; audio/video reference objects are forwarded as-is and only take effect on models that support them — other models use the image references and ignore the rest. Only models with an r2v mode accept this field. |
| `input_video` | `string | object` | no | Source video (video-edit models only; a BazaarLink extension field). Accepts a URL string, { video_url: { url } }, or { url }. Ignored by non-video-edit models. Editing bills on source seconds + delivered output seconds. |
| `generate_audio` | `boolean` | no | Whether to also generate audio. Only effective on audio-capable models; audio seconds bill at a higher per-second rate. |
| `watermark` | `boolean` | no | Whether to watermark the output. Defaults to false — BazaarLink actively disables upstream default watermarks unless you explicitly pass true. |
| `seed` | `number` | no | Sampling seed. Reproducibility guarantees vary by model. |
| `callback_url` | `string` | no | A webhook URL called on the job's terminal state, so you don't have to poll. HTTPS only, and SSRF-checked (private/internal addresses rejected) before any credits are reserved. Fires exactly once, only from the request that wins the terminal-state determination; delivery is retried on failure (2 attempts, 2s/8s backoff), and exhausting retries never affects billing or the job's real status. No signature in v1 — treat the callback as a hint, and confirm status/amount by calling GET /v1/videos/{id} with your API key before acting on it. ⚠️ unsigned_urls in the webhook payload are absolute URLs, unlike the relative paths returned by the poll endpoint. https:// only, ≤2048 chars |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/videos \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "alibaba/wan2.7-t2v",
    "prompt": "a corgi surfing a wave at sunset",
    "duration": 5,
    "resolution": "720p",
    "aspect_ratio": "16:9"
  }'
```

### Response 202

Job accepted (asynchronous). Poll the returned polling_url (i.e. GET /api/v1/videos/{id}) for status; a ≥5s interval is recommended.

```json
{
  "id": "vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk",
  "status": "pending",
  "polling_url": "/api/v1/videos/vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk"
}
```

### Response 400

Invalid request — malformed JSON, missing required fields, or failed validation.

```json
{
  "error": {
    "message": "'model' is required.",
    "type": "invalid_request_error",
    "code": 400
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid API key",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 402

Insufficient credits, or your weekly/monthly budget cap has been reached.

```json
{
  "error": {
    "message": "Insufficient credits",
    "type": "invalid_request_error",
    "code": 402
  }
}
```

### Response 429

Rate limit exceeded (RPM depends on your account tier), or an account-level spend brake has tripped. Retry later.

```json
{
  "error": {
    "message": "Rate limit exceeded",
    "type": "invalid_request_error",
    "code": 429
  }
}
```

### Response 503

Service temporarily unavailable — all upstream providers are circuit-broken, the global spend limit tripped, or the service is draining for restart. Retry later.

```json
{
  "error": {
    "message": "Service temporarily unavailable — global spend limit reached. Try again shortly.",
    "type": "invalid_request_error",
    "code": 503
  }
}
```

### Response 404

The requested resource was not found — the ID does not exist or does not belong to this key.

```json
{
  "error": {
    "message": "Unknown model: alibaba/wan99-t2v",
    "type": "invalid_request_error",
    "code": 404
  }
}
```

### Response 410

The model has been retired from the catalog — switch to a newer model ID.

```json
{
  "error": {
    "message": "Model retired.",
    "type": "model_not_available",
    "code": 410
  }
}
```

---

## GET /api/v1/videos/:id

**Get video generation status**

Polls a video job. The only terminal states are completed / failed; while running you will usually see pending / processing, but upstream in-flight labels (e.g. in_progress) may pass through — write your loop as "until completed or failed" rather than enumerating intermediate states. Upstream cancellations/expiries (cancelled / expired) surface as failed with the reserved credits refunded. On completed, unsigned_urls lists downloadable content paths and usage.cost is the final charge (billed on actual delivered seconds); on failed, error explains why. Upstream checks are throttled to one per 8s server-side; poll at a ≥5s interval.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The job id returned at submit (prefixed vjob_). Only jobs owned by the same account are visible. Example: "vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk" |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/videos/vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. The example shows a completed job; pending / processing responses omit unsigned_urls and usage, and failed adds an error field. unsigned_urls are relative paths — GET them with the same API key to download.

```json
{
  "id": "vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk",
  "status": "completed",
  "polling_url": "/api/v1/videos/vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk",
  "unsigned_urls": [
    "/api/v1/videos/vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk/content?index=0"
  ],
  "usage": {
    "cost": 0.5,
    "is_byok": false
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid or disabled API key.",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 404

The requested resource was not found — the ID does not exist or does not belong to this key.

```json
{
  "error": {
    "message": "Job not found.",
    "type": "invalid_request_error",
    "code": 404
  }
}
```

---

## GET /api/v1/videos/:id/content

**Download the generated video**

Streams the generated video bytes (typically video/mp4). The unsigned_urls in the poll response point here. Supports HTTP Range (Accept-Ranges: bytes, 206 partial content) — mobile-browser <video> playback requires it. Content is served from the platform's durable copy; upstream URLs expire, so always download through this endpoint.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The job id (prefixed vjob_). Only the owning account can access it. Example: "vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk" |

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `index` | `integer` | no | Selects which output when a job produced multiple videos (0-based). Out of range returns 400. default 0 Example: 0 |

### cURL

```bash
curl "https://bazaarlink.ai/api/v1/videos/vjob_Xk3fJ9aQ2mB7cD1eF5gH6iJk/content?index=0" \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  --output out.mp4
```

### Response 200

Success — the response body is the video bytes (Content-Type typically video/mp4, with Accept-Ranges: bytes). Requests with a Range header receive 206 partial content instead.

```text
<binary video/mp4 bytes>
```

### Response 400

index out of range — the error message states how many outputs this job actually has.

```json
{
  "error": {
    "message": "Content index 3 out of range (have 1).",
    "type": "invalid_request_error",
    "code": 400
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid or disabled API key.",
    "type": "invalid_request_error",
    "code": 401
  }
}
```

### Response 403

The job exists but belongs to a different account than this key.

```json
{
  "error": {
    "message": "Forbidden.",
    "type": "invalid_request_error",
    "code": 403
  }
}
```

### Response 404

The requested resource was not found — the ID does not exist or does not belong to this key.

```json
{
  "error": {
    "message": "Job not found.",
    "type": "invalid_request_error",
    "code": 404
  }
}
```

### Response 409

The job is not completed yet — keep polling GET /api/v1/videos/{id} and download once status is completed.

```json
{
  "error": {
    "message": "Job not completed yet.",
    "type": "invalid_request_error",
    "code": 409
  }
}
```

---

# Account

## GET /api/v1/insights

**Get usage insights**

Returns everything the /insights page needs in one round-trip: trend series for all 3 metrics (tokens/spend/requests), top 3 models per metric, prompt-cache hit stats, token breakdown, a year heatmap (with streak), credit/free-model usage, and filter-panel options. Cached for 5 minutes per (user, period, heatmap_metric, filters) combination.

Authentication: Bearer API key required

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `period` | `string` | no | Drives the whole page's time window, defaults to 7d. An unrecognized value returns 400. Values: 7d, 30d, ytd, all Example: "30d" |
| `heatmap_metric` | `string` | no | Which metric the year heatmap displays (the heatmap is always a full calendar year, independent of period), defaults to spend. An unrecognized value returns 400. Values: spend, tokens, requests Example: "tokens" |
| `model` | `string` | no | Page-wide filter — only count usage for this model. Omitted means unfiltered. |
| `api_key_id` | `string` | no | Page-wide filter — only count usage for this API key. Omitted means unfiltered. |
| `app` | `string` | no | Page-wide filter — only count usage tagged with this app name (x-title / HTTP-Referer). Omitted means unfiltered. |

### cURL

```bash
curl "https://bazaarlink.ai/api/v1/insights?period=30d&heatmap_metric=tokens" \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. trend/topModels/heatmap always return the full data for all three metrics (tokens, spend, requests) so the frontend toggle needs no refetch; caching and breakdown are bucketed by period (daily ≤120 days, weekly ≤730 days, otherwise monthly). The example below is truncated — the real buckets/heatmap.days arrays hold many entries depending on the period length.

```json
{
  "period": "30d",
  "bucketUnit": "day",
  "trend": {
    "tokens": {
      "total": 152000,
      "prevTotal": 98000,
      "changePct": 55.1,
      "buckets": [
        {
          "date": "2026-07-01",
          "segs": [
            {
              "model": "openai/gpt-4.1",
              "value": 5200
            }
          ]
        }
      ]
    },
    "spend": {
      "total": 4.82,
      "prevTotal": 3.1,
      "changePct": 55.5,
      "buckets": [
        {
          "date": "2026-07-01",
          "segs": [
            {
              "model": "openai/gpt-4.1",
              "value": 0.16
            }
          ]
        }
      ]
    },
    "requests": {
      "total": 340,
      "prevTotal": 210,
      "changePct": 61.9,
      "buckets": [
        {
          "date": "2026-07-01",
          "segs": [
            {
              "model": "openai/gpt-4.1",
              "value": 12
            }
          ]
        }
      ]
    }
  },
  "topModels": {
    "tokens": [
      {
        "model": "openai/gpt-4.1",
        "provider": "openai",
        "value": 152000,
        "unit": "tokens"
      }
    ],
    "spend": [
      {
        "model": "openai/gpt-4.1",
        "provider": "openai",
        "value": 4.82,
        "unit": "spend"
      }
    ],
    "requests": [
      {
        "model": "openai/gpt-4.1",
        "provider": "openai",
        "value": 340,
        "unit": "requests"
      }
    ]
  },
  "caching": {
    "buckets": [
      {
        "date": "2026-07-01",
        "hit": 3200,
        "write": 800
      }
    ],
    "totalHit": 96000,
    "totalWrite": 24000
  },
  "breakdown": {
    "buckets": [
      {
        "date": "2026-07-01",
        "prompt": 4000,
        "completion": 1200,
        "reasoning": 0,
        "cached": 3200
      }
    ],
    "totals": {
      "prompt": 120000,
      "completion": 32000,
      "reasoning": 0,
      "cached": 96000
    }
  },
  "heatmap": {
    "spend": {
      "days": [
        {
          "date": "2026-01-01",
          "value": 0.02
        }
      ],
      "streak": {
        "current": 5,
        "longest": 21
      },
      "avgDay": 0.16,
      "avgWeek": 1.12,
      "total": 58.4
    },
    "tokens": {
      "days": [
        {
          "date": "2026-01-01",
          "value": 640
        }
      ],
      "streak": {
        "current": 5,
        "longest": 21
      },
      "avgDay": 5066,
      "avgWeek": 35462,
      "total": 1850000
    },
    "requests": {
      "days": [
        {
          "date": "2026-01-01",
          "value": 4
        }
      ],
      "streak": {
        "current": 5,
        "longest": 21
      },
      "avgDay": 11.3,
      "avgWeek": 79,
      "total": 4130
    }
  },
  "credit": {
    "today": 0.16,
    "thisWeek": 1.12,
    "thisMonth": 4.82
  },
  "freeModel": {
    "today": 0,
    "thisWeek": 0,
    "thisMonth": 0
  },
  "facets": {
    "models": [
      "openai/gpt-4.1"
    ],
    "apiKeys": [
      {
        "id": "key_abc123",
        "name": "default"
      }
    ],
    "apps": [
      "my-app"
    ]
  }
}
```

### Response 400

period or heatmap_metric was set to an unrecognized value.

```json
{
  "error": "Invalid period"
}
```

### Response 401

No valid session, or the Bearer key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

---

## GET /api/v1/credits

**Get credits**

Returns the current balance and lifetime usage. Unlike /v1/insights and /v1/usage, this endpoint only accepts a Bearer API key — no session-cookie fallback.

Authentication: Bearer API key required

### cURL

```bash
curl https://bazaarlink.ai/api/v1/credits \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. total_credits is the current available balance, total_usage is lifetime historical spend (both in USD).

```json
{
  "data": {
    "total_credits": 42.5,
    "total_usage": 7.83
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid or disabled API key.",
    "code": 401
  }
}
```

### Response 403

The account is suspended or does not exist.

```json
{
  "error": {
    "message": "Account suspended or not found.",
    "code": 403
  }
}
```

---

## GET /api/v1/usage

**Get usage**

Returns spend/requests/token totals for a given window, broken down by model, by API key, and by app name, plus a daily time series. Unlike /v1/insights, this endpoint has no top-models ranking, cache stats, or year heatmap — it's the lighter-weight aggregate endpoint, and it supports a custom date range (period=custom).

Authentication: Bearer API key required

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `period` | `string` | no | The time window, defaults to month. Three families: legacy calendar words (day/week/month/year), rolling windows ending now (15m…1y), calendar-aligned windows (today…prev_year), or custom (requires from/to). An unrecognized value returns 400. Values: day, week, month, year, 15m, 30m, 1h, 3h, 1d, 2d, 1w, 1mo, 1y, today, yesterday, this_week, prev_week, this_month, prev_month, this_year, prev_year, custom Example: "month" |
| `from` | `string` | no | Start of the custom window (ISO 8601). Only used with period=custom. ISO 8601, required with period=custom Example: "2026-07-01T00:00:00.000Z" |
| `to` | `string` | no | End of the custom window (ISO 8601). Only used with period=custom. ISO 8601, required with period=custom Example: "2026-07-27T00:00:00.000Z" |

### cURL

```bash
curl "https://bazaarlink.ai/api/v1/usage?period=month" \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. totals is the window aggregate; byModel/byKey/byApp are three independent breakdowns (not a nested relationship); timeSeries is a daily (per-model-segmented) series of spend/tokens/requests. The example below is truncated.

```json
{
  "period": "month",
  "since": "2026-07-01T00:00:00.000Z",
  "until": "2026-07-27T12:00:00.000Z",
  "credits": 42.5,
  "totals": {
    "spend": 4.82,
    "requests": 340,
    "tokens": 152000,
    "promptTokens": 120000,
    "completionTokens": 32000
  },
  "byModel": [
    {
      "model": "openai/gpt-4.1",
      "spend": 4.82,
      "tokens": 152000,
      "requests": 340
    }
  ],
  "byKey": [
    {
      "keyId": "key_abc123",
      "keyName": "default",
      "spend": 4.82,
      "tokens": 152000,
      "requests": 340,
      "spendLimitUsd": null,
      "spendLimitPeriod": null
    }
  ],
  "byApp": [
    {
      "appName": "my-app",
      "spend": 4.82,
      "tokens": 152000,
      "requests": 340
    }
  ],
  "timeSeries": [
    {
      "date": "2026-07-01",
      "model": "openai/gpt-4.1",
      "cost": 0.16,
      "tokens": 5200,
      "requests": 12
    }
  ]
}
```

### Response 400

period is unrecognized, or with period=custom the from/to timestamps are missing, malformed, or failed to resolve.

```json
{
  "error": "Invalid period: quarter"
}
```

### Response 401

No valid session, or the Bearer key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

### Response 403

Authenticated via Bearer key, but the account is suspended or does not exist.

```json
{
  "error": "Account not found."
}
```

---

## GET /api/v1/generation

**Get a generation**

Looks up usage and billing detail for a single request by its generation id (the id field from any compatible endpoint's response). Scoped to the API key's own user — another user's generation id returns 404, never leaking their data. The response's provider field always returns the fixed value "bazaarlink", not the actual upstream provider that was routed to.

Authentication: Bearer API key required

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The generation id to look up, taken from the id field of any compatible endpoint's successful response. |

### cURL

```bash
curl "https://bazaarlink.ai/api/v1/generation?id=gen-abc123" \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. usage uses the same token-field shape as the chat endpoints (prompt_tokens/completion_tokens/total_tokens); cost_breakdown additionally splits out the subtotal and the cache discount.

```json
{
  "data": {
    "id": "gen-abc123",
    "model": "openai/gpt-4.1",
    "provider": "bazaarlink",
    "created_at": "2026-07-27T02:00:00.000Z",
    "app_name": "my-app",
    "finish_reason": "stop",
    "status": 200,
    "duration_ms": 1840,
    "first_token_ms": 320,
    "throughput": 42.5,
    "usage": {
      "prompt_tokens": 128,
      "completion_tokens": 256,
      "total_tokens": 384,
      "prompt_tokens_details": {
        "cached_tokens": 0
      },
      "completion_tokens_details": {
        "reasoning_tokens": 0
      },
      "cost": 0.0034
    },
    "cost_breakdown": {
      "subtotal": 0.0034,
      "cache_discount": 0,
      "total": 0.0034
    }
  }
}
```

### Response 400

The required id query parameter is missing.

```json
{
  "error": {
    "message": "Missing 'id' query parameter",
    "code": 400
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid or disabled API key",
    "code": 401
  }
}
```

### Response 404

The generation was not found — the id does not exist, or does not belong to this key's user.

```json
{
  "error": {
    "message": "Generation not found",
    "code": 404
  }
}
```

---

# API Keys

## GET /api/v1/key

**Get current API key info**

Returns the status of the key itself, taken from the Authorization header: its spend limit and remaining amount, expiry, key type, cumulative/daily/weekly/monthly spend and request counts, and its rate limit. limit/limit_remaining are only non-null when this specific key has its own per-key spend limit configured; otherwise the account wallet balance is returned instead.

Authentication: Bearer API key required

### cURL

```bash
curl https://bazaarlink.ai/api/v1/key \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. is_management_key and is_provisioning_key are two names for the same underlying flag (matching both this API's own naming and the more common industry term), not two independent capabilities.

```json
{
  "data": {
    "label": "default",
    "limit": 50,
    "limit_remaining": 42.5,
    "limit_reset": "month",
    "expires_at": null,
    "is_free_tier": false,
    "is_management_key": false,
    "is_provisioning_key": false,
    "usage": 128.4,
    "usage_daily": 1.2,
    "usage_weekly": 8.6,
    "usage_monthly": 27.9,
    "requests": 940,
    "requests_daily": 12,
    "requests_weekly": 88,
    "requests_monthly": 340,
    "rate_limit": {
      "requests": 200,
      "interval": "1m",
      "note": "Paid-tier rate limit."
    }
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid or disabled API key",
    "code": 401
  }
}
```

### Response 404

The key is valid, but the underlying user account no longer exists.

```json
{
  "error": {
    "message": "User not found",
    "code": 404
  }
}
```

---

## GET /api/v1/auth/key

**Check API key**

A lightweight check that the key in the Authorization header is valid, consuming no credits. This is a separate handler from GET /v1/key: it returns fewer fields (no usage_daily/weekly/monthly breakdown, no expires_at, is_management_key, or is_provisioning_key), but limit/limit_remaining now reflect this key's real per-key spend limit.

Authentication: Bearer API key required

### cURL

```bash
curl https://bazaarlink.ai/api/v1/auth/key \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. usage is lifetime historical spend; limit is this key's spend cap (null = unlimited), and limit_remaining is the remaining amount for the current reset period.

```json
{
  "data": {
    "label": "default",
    "usage": 128.4,
    "limit": 50,
    "limit_remaining": 37.7,
    "is_free_tier": false,
    "rate_limit": {
      "requests": 200,
      "interval": "1m"
    }
  }
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": {
    "message": "Invalid or disabled API key.",
    "code": 401
  }
}
```

### Response 403

The account is suspended or does not exist.

```json
{
  "error": {
    "message": "Account suspended or not found.",
    "code": 403
  }
}
```

---

# Management Keys

## GET /api/v1/keys

**List API keys**

Lists the standard API keys owned by the current user (or a specified organization). This is a management endpoint: if you authenticate with a Bearer token, it must be a Management API Key — a regular sk-bl- key cannot call this. A logged-in session cookie also works (used by the web dashboard). The response never includes the raw key value, only its prefix/suffix.

Authentication: Bearer API key required

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | no | Lists keys for the whole organization instead of just the caller's own. The caller must be an org_admin or billing_viewer in that organization, or the request returns 403. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/keys \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. keys[] is ordered newest-first and each entry includes usage stats, spend limit, org scope, and subscription plan (if any) — never the raw key value itself. limitRemainingUsd is pre-computed against the key's own reset period (null when unlimited); usageDailyUsd/usageWeeklyUsd/usageMonthlyUsd are fixed-calendar breakdowns independent of spendLimitPeriod.

```json
{
  "keys": [
    {
      "id": "key_abc123",
      "name": "production",
      "keyPrefix": "sk-bl-",
      "keySuffix": "hDS5",
      "enabled": true,
      "keyType": "standard",
      "requestCount": 1204,
      "totalTokens": 892340,
      "lastUsed": "2026-07-27T03:12:00.000Z",
      "createdAt": "2026-05-01T00:00:00.000Z",
      "spendLimitUsd": 50,
      "spendLimitPeriod": "month",
      "allowedModels": null,
      "expiresAt": null,
      "subscriptionPlanId": null,
      "subscriptionStatus": null,
      "subscriptionStartedAt": null,
      "organizationId": null,
      "teamId": null,
      "orgMemberId": null,
      "subscriptionPlan": null,
      "periodSpendUsd": 4.82,
      "limitRemainingUsd": 45.18,
      "usageDailyUsd": 0.4,
      "usageWeeklyUsd": 2.1,
      "usageMonthlyUsd": 4.82
    }
  ]
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

### Response 403

The Bearer token is not a Management API Key; or orgId was passed but the caller is not an org_admin / billing_viewer in that organization.

```json
{
  "error": "Management API key required"
}
```

---

## POST /api/v1/keys

**Create an API key**

Creates a new standard API key (or another management key). A management endpoint — if you authenticate with a Bearer token, it must be a Management API Key, not a regular sk-bl- key; a logged-in session cookie also works. The key field in the response is the only time the raw key value is ever shown — only its hash is stored afterward, and it cannot be retrieved again.

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `name` | `string` | yes | Display name for the key; leading/trailing whitespace is trimmed and the result cannot be empty. ≤200 chars, trimmed non-empty |
| `keyType` | `string` | no | Key type, defaults to standard; any value other than "management" is treated as standard. Values: standard, management Example: "standard" |
| `limit` | `number | null` | no | Spend limit in USD, stored as spendLimitUsd. null or omitted means no limit. When set, limit_reset must also be provided. GET /v1/key reports this same value back as limit. 0–1,000,000 |
| `limit_reset` | `string | null` | no | Reset period for the spend limit, stored as spendLimitPeriod. Accepts either the OR-compatible aliases (daily/weekly/monthly) or the internal names (day/week/month) — both forms are equivalent. Values: daily, weekly, monthly, day, week, month Example: "monthly" |
| `expires_at` | `string | null` | no | Expiration time for the key, an ISO date string that must be in the future. null or omitted means it never expires. Example: "2027-01-01T00:00:00Z" |
| `organizationId` | `string | null` | no | Binds the key to the given organization instead of the personal account. The caller must pass the authorization check for that organization/team/member scope, or the request returns 400. |
| `teamId` | `string | null` | no | Further scopes the key to a specific team within the organization. Used together with organizationId. |
| `orgMemberId` | `string | null` | no | Further scopes the key to a specific member within the organization. Used together with organizationId. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/keys \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "production", "limit": 50, "limit_reset": "monthly"}'
```

### Response 201

Created. The key field is the full raw value, shown only in this one response — store it immediately, as it can never be retrieved again, even by the caller.

```json
{
  "id": "key_abc123",
  "name": "production",
  "key": "sk-bl-AbCdEfGh...",
  "keyType": "standard",
  "prefix": "sk-bl-",
  "enabled": true,
  "spendLimitUsd": 50,
  "spendLimitPeriod": "month",
  "expiresAt": null,
  "createdAt": "2026-07-27T00:00:00.000Z",
  "organizationId": null,
  "teamId": null,
  "orgMemberId": null
}
```

### Response 400

name is empty or whitespace-only, or expires_at is not a valid ISO date string, or expires_at is not in the future.

```json
{
  "error": "Key name cannot be empty or whitespace-only."
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

### Response 403

The Bearer token used to authenticate is not a Management API Key.

```json
{
  "error": "Management API key required"
}
```

---

## PATCH /api/v1/keys/:id

**Update an API key**

Enables/disables a key, or sets/clears its spend limit — each call does exactly one of these, never both at once. A management endpoint with the same rule as key creation: the Bearer token must be a Management API Key, or use a logged-in session.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The ID of the key to update. |

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `enabled` | `boolean` | no | Enable (true) or disable (false) this key. Any manual toggle clears a prior spend-limit auto-disable flag, so the key is never silently auto-re-enabled later against your intent. |
| `spendLimitUsd` | `number | null` | no | Sets the spend limit in USD; pass null to clear the limit. Mutually exclusive with enabled — a single request can only send one or the other. positive, ≤1,000,000 |
| `spendLimitPeriod` | `string | null` | no | Reset period for the spend limit. Required whenever spendLimitUsd is set, and only accepts the raw day/week/month values — unlike the create-key endpoint, the daily/weekly/monthly aliases are NOT accepted here. Values: day, week, month |

### cURL

```bash
curl -X PATCH https://bazaarlink.ai/api/v1/keys/key_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'
```

### Response 200

Updated. The response is just a confirmation flag, not the full key record — call list keys again to see the current state.

```json
{
  "updated": true
}
```

### Response 400

Neither enabled (boolean) nor spendLimitUsd was provided, so there is nothing to update; or spendLimitUsd is set but spendLimitPeriod is not one of "day", "week", "month".

```json
{
  "error": "spendLimitPeriod must be 'day', 'week', or 'month'"
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

### Response 403

The Bearer token used is not a Management API Key; or the caller no longer has authorization for the organization/team/member scope this key is bound to.

```json
{
  "error": "Management API key required"
}
```

### Response 404

The specified key was not found — the ID does not exist, or does not belong to the caller (non-admins can only modify their own keys).

```json
{
  "error": "Key not found"
}
```

---

## DELETE /api/v1/keys/:id

**Delete an API key**

Permanently deletes an API key — the database row is removed outright, not disabled. This cannot be undone; unlike PATCH's enabled: false, once deleted a key is gone for good. The auth cache is invalidated immediately and the change propagates, so the key stops working everywhere within seconds. A management endpoint with the same rule as creating/updating a key: the Bearer token must be a Management API Key, or use a logged-in session.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The ID of the key to delete. |

### cURL

```bash
curl -X DELETE https://bazaarlink.ai/api/v1/keys/key_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 204

Deleted successfully, no response body.

```text
(no body)
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

### Response 403

The Bearer token used is not a Management API Key; or the caller no longer has authorization for the organization/team/member scope this key is bound to.

```json
{
  "error": "Management API key required"
}
```

### Response 404

The specified key was not found — the ID does not exist, or does not belong to the caller (non-admins can only delete their own keys).

```json
{
  "error": "Key not found"
}
```

---

## GET /api/v1/management/byok-keys

**List BYOK keys**

Lists all BYOK upstream keys of the calling organization. Authentication requires a Management API Key bound to that organization; the upstream key appears only in masked form (keyMask) — the full value is never retrievable after creation.

Authentication: Bearer API key required

### cURL

```bash
curl https://bazaarlink.ai/api/v1/management/byok-keys \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. Each element of keys carries the masked key, protocol (openai/anthropic), fallback mode (seamless/strict), the model mapping list (models), scoped platform keys (scopedApiKeyIds — empty array means it applies to every key of the org), and health fields (lastVerifiedAt/lastErrorAt/errorCount1h).

```json
{
  "keys": [
    {
      "id": "byok_abc123",
      "ownerType": "org",
      "userId": null,
      "organizationId": "org_abc123",
      "name": "team-upstream",
      "baseUrl": "https://api.example-upstream.com/v1",
      "keyMask": "sk-abc…wxyz",
      "protocol": "openai",
      "enabled": true,
      "fallbackMode": "seamless",
      "modelsStale": false,
      "lastVerifiedAt": "2026-07-29T00:00:00.000Z",
      "lastErrorAt": null,
      "lastErrorCode": null,
      "errorCount1h": 0,
      "createdAt": "2026-07-29T00:00:00.000Z",
      "updatedAt": "2026-07-29T00:00:00.000Z",
      "models": [
        {
          "canonicalId": "openai/gpt-4o",
          "upstreamModelId": "gpt-4o",
          "enabled": true
        }
      ],
      "scopedApiKeyIds": []
    }
  ]
}
```

### Response 401

Missing Authorization header, not a Bearer sk-bl- token, or the key is invalid/disabled.

```json
{
  "error": "Management API key required"
}
```

### Response 403

The key is not a Management API Key, or it is a management key without an organization binding — BYOK management endpoints only accept org-scoped management keys.

```json
{
  "error": "Management key must be org-scoped to manage BYOK keys"
}
```

---

## POST /api/v1/management/byok-keys

**Create a BYOK key**

Creates a BYOK upstream key for the organization and immediately syncs the model catalog. The upstream apiKey appears in plaintext only in this request — it is encrypted at rest and only ever returned masked (keyMask). On success answers 201 with the key object and the model sync result (sync).

Authentication: Bearer API key required

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `name` | `string` | yes | Display name for the key. 1-200 chars |
| `baseUrl` | `string` | yes | The upstream provider's API base URL. Private or local addresses are rejected (400). 1-2000 chars, public HTTPS endpoint |
| `apiKey` | `string` | yes | The upstream provider's API key in plaintext. Appears only in this request; encrypted at rest, only keyMask is returned afterwards. 1-2000 chars |
| `protocol` | `string` | no | The upstream's API interface protocol; defaults to openai. Values: openai, anthropic Example: "openai" |
| `fallbackMode` | `string` | no | Behavior when the upstream fails: seamless (default) falls back to platform routing; strict does not fall back and returns the error. Values: seamless, strict Example: "seamless" |
| `disabledCanonicalIds` | `string[]` | no | Models (canonical ids) to disable at creation; after sync these models will not route through this key. ≤2000 items |
| `scopedApiKeyIds` | `string[]` | no | Restricts this BYOK key to specific platform API keys (by key id). Empty/omitted = applies to every key of the org. Including a key id you don't own is rejected (400). ≤200 items; must be your own keys |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/management/byok-keys \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "team-upstream",
    "baseUrl": "https://api.example-upstream.com/v1",
    "apiKey": "'$UPSTREAM_API_KEY'",
    "protocol": "openai",
    "fallbackMode": "seamless"
  }'
```

### Response 201

Created. key is the key object (keyMask only); sync reports the model catalog sync: matched (models mapped to the platform catalog), unmatched (upstream model ids absent from the platform catalog), stale (whether a later re-sync is needed).

```json
{
  "key": {
    "id": "byok_abc123",
    "ownerType": "org",
    "userId": null,
    "organizationId": "org_abc123",
    "name": "team-upstream",
    "baseUrl": "https://api.example-upstream.com/v1",
    "keyMask": "sk-abc…wxyz",
    "protocol": "openai",
    "enabled": true,
    "fallbackMode": "seamless",
    "modelsStale": false,
    "lastVerifiedAt": "2026-07-29T00:00:00.000Z",
    "lastErrorAt": null,
    "lastErrorCode": null,
    "errorCount1h": 0,
    "createdAt": "2026-07-29T00:00:00.000Z",
    "updatedAt": "2026-07-29T00:00:00.000Z",
    "models": [
      {
        "canonicalId": "openai/gpt-4o",
        "upstreamModelId": "gpt-4o",
        "enabled": true
      }
    ],
    "scopedApiKeyIds": []
  },
  "sync": {
    "matched": 12,
    "unmatched": [
      "internal-model-x"
    ],
    "stale": false
  }
}
```

### Response 400

Validation failure: body fields violate the schema, baseUrl is a private/local address (SSRF guard), scopedApiKeyIds contains a key you don't own, or apiKey is a BazaarLink API key (sk-bl- prefix) instead of an upstream provider's own key.

```json
{
  "error": "'baseUrl': private or local addresses are not allowed"
}
```

### Response 401

Missing Authorization header, not a Bearer sk-bl- token, or the key is invalid/disabled.

```json
{
  "error": "Management API key required"
}
```

### Response 403

The key is not a Management API Key, or it is a management key without an organization binding — BYOK management endpoints only accept org-scoped management keys.

```json
{
  "error": "Management API key required"
}
```

---

## GET /api/v1/management/byok-keys/:id

**Get a BYOK key**

Fetches one of the organization's BYOK keys by id, including the model mapping list and health fields. The upstream key appears only masked (keyMask).

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The BYOK key id (from create or list). |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/management/byok-keys/byok_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success — returns { key }.

```json
{
  "key": {
    "id": "byok_abc123",
    "ownerType": "org",
    "userId": null,
    "organizationId": "org_abc123",
    "name": "team-upstream",
    "baseUrl": "https://api.example-upstream.com/v1",
    "keyMask": "sk-abc…wxyz",
    "protocol": "openai",
    "enabled": true,
    "fallbackMode": "seamless",
    "modelsStale": false,
    "lastVerifiedAt": "2026-07-29T00:00:00.000Z",
    "lastErrorAt": null,
    "lastErrorCode": null,
    "errorCount1h": 0,
    "createdAt": "2026-07-29T00:00:00.000Z",
    "updatedAt": "2026-07-29T00:00:00.000Z",
    "models": [
      {
        "canonicalId": "openai/gpt-4o",
        "upstreamModelId": "gpt-4o",
        "enabled": true
      }
    ],
    "scopedApiKeyIds": []
  }
}
```

### Response 401

Missing Authorization header, not a Bearer sk-bl- token, or the key is invalid/disabled.

```json
{
  "error": "Management API key required"
}
```

### Response 403

The key is not a Management API Key, or it is a management key without an organization binding — BYOK management endpoints only accept org-scoped management keys.

```json
{
  "error": "Management key must be org-scoped to manage BYOK keys"
}
```

### Response 404

The BYOK key does not exist, or it does not belong to your organization (existence is never leaked — both cases answer 404).

```json
{
  "error": "Not found"
}
```

---

## PATCH /api/v1/management/byok-keys/:id

**Update a BYOK key**

Updates one BYOK key: enable/disable, rename, switch fallback mode, per-model toggles (modelToggles), and reset the applicable scope (scopedApiKeyIds). All fields are optional; the upstream key itself cannot be replaced — delete and recreate to rotate it.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The BYOK key id. |

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `enabled` | `boolean` | no | Master switch for the whole key. |
| `fallbackMode` | `string` | no | Whether to fall back to platform routing when the upstream fails. Values: seamless, strict |
| `name` | `string` | no | New display name. 1-200 chars |
| `modelToggles` | `{canonicalId, enabled}[]` | no | Per-model switches: each element names a canonicalId and enabled. Only listed models change; the rest keep their state. ≤2000 items |
| `scopedApiKeyIds` | `string[]` | no | Replaces the whole scope list: an empty array restores org-wide applicability. Including a key id you don't own answers 400. ≤200 items; must be your own keys |

### cURL

```bash
curl -X PATCH https://bazaarlink.ai/api/v1/management/byok-keys/byok_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fallbackMode": "strict", "modelToggles": [{"canonicalId": "openai/gpt-4o", "enabled": false}]}'
```

### Response 200

Updated — returns the updated { key }.

```json
{
  "key": {
    "id": "byok_abc123",
    "ownerType": "org",
    "userId": null,
    "organizationId": "org_abc123",
    "name": "team-upstream",
    "baseUrl": "https://api.example-upstream.com/v1",
    "keyMask": "sk-abc…wxyz",
    "protocol": "openai",
    "enabled": true,
    "fallbackMode": "strict",
    "modelsStale": false,
    "lastVerifiedAt": "2026-07-29T00:00:00.000Z",
    "lastErrorAt": null,
    "lastErrorCode": null,
    "errorCount1h": 0,
    "createdAt": "2026-07-29T00:00:00.000Z",
    "updatedAt": "2026-07-29T00:00:00.000Z",
    "models": [
      {
        "canonicalId": "openai/gpt-4o",
        "upstreamModelId": "gpt-4o",
        "enabled": true
      }
    ],
    "scopedApiKeyIds": []
  }
}
```

### Response 400

Body fields violate the schema, or scopedApiKeyIds contains a key you don't own.

```json
{
  "error": "'scopedApiKeyIds': contains an API key you do not own"
}
```

### Response 401

Missing Authorization header, not a Bearer sk-bl- token, or the key is invalid/disabled.

```json
{
  "error": "Management API key required"
}
```

### Response 403

The key is not a Management API Key, or it is a management key without an organization binding — BYOK management endpoints only accept org-scoped management keys.

```json
{
  "error": "Management API key required"
}
```

### Response 404

The BYOK key does not exist, or it does not belong to your organization (existence is never leaked — both cases answer 404).

```json
{
  "error": "Not found"
}
```

---

## DELETE /api/v1/management/byok-keys/:id

**Delete a BYOK key**

Permanently deletes a BYOK key and all of its model mappings (cascading). Irreversible; traffic that used this key returns to platform routing afterwards.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `id` | `string` | yes | The BYOK key id. |

### cURL

```bash
curl -X DELETE https://bazaarlink.ai/api/v1/management/byok-keys/byok_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Deleted.

```json
{
  "ok": true
}
```

### Response 401

Missing Authorization header, not a Bearer sk-bl- token, or the key is invalid/disabled.

```json
{
  "error": "Management API key required"
}
```

### Response 403

The key is not a Management API Key, or it is a management key without an organization binding — BYOK management endpoints only accept org-scoped management keys.

```json
{
  "error": "Management key must be org-scoped to manage BYOK keys"
}
```

### Response 404

The BYOK key does not exist, or it does not belong to your organization (existence is never leaked — both cases answer 404).

```json
{
  "error": "Not found"
}
```

---

# Agents

## POST /api/v1/agents/register

**Agent Registration**

Self-service registration for AI agents (bots, autonomous systems). Returns an API key with trial credits and a claim token for account upgrade. This endpoint itself requires no Authorization header — that's the point: it's how an agent that doesn't have a key yet gets its first one. Rate-limited; exceeding the limit returns 429.

Authentication: Public

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `name` | `string` | yes | Agent name (non-empty, trimmed, max 100 chars). non-empty, trimmed, max 100 chars |
| `description` | `string` | no | Optional agent description. Currently accepted but not surfaced in the response or anywhere else. |
| `referral_code` | `string` | no | Optional referral code (the referral_code another agent received on its own registration). An invalid or non-existent code is silently ignored — it does not fail the request. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/agents/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "research-agent",
    "description": "Long-running research assistant"
  }'
```

### Response 201

Successfully created a shadow account, trial API key, and referral code. credits depends on the current agent-trial-credit configuration and may be 0 if trial credits are disabled by the admin.

```json
{
  "api_key": "sk-bl-xxxxx...",
  "credits": 0.1,
  "credits_usd": "$0.1000",
  "claim_token": "abc...xyz",
  "claim_expires": "2026-08-03T10:00:00.000Z",
  "upgrade_url": "https://bazaarlink.ai/claim?token=abc...xyz",
  "referral_code": "aBcDeFgH",
  "free_model": "auto:free",
  "message": "I registered an account on bazaarlink.ai with $0.1000 USD trial credits. You can start using the API immediately with model \"auto:free\" (no credits required) or any model using your trial credits. Claim your account at https://bazaarlink.ai/claim?token=abc...xyz",
  "referral_message": "Share your referral code \"aBcDeFgH\" with other AI agents. When they register with it, you both earn bonus credits.",
  "base_url": "https://bazaarlink.ai/api/v1",
  "docs": "https://bazaarlink.ai/llms.txt"
}
```

### Response 400

JSON body failed to parse, or the required name field is missing.

```json
{
  "error": "name is required"
}
```

### Response 429

Rate limit exceeded, or the request was rejected by anti-abuse checks. Response may include a Retry-After header.

```json
{
  "error": "Rate limit exceeded. One agent registration per IP per 24 hours."
}
```

### Response 500

An unexpected error occurred while creating the account, key, or registration record.

```json
{
  "error": "Internal server error"
}
```

---

# Models

## GET /api/v1/models

**List available models**

Returns all available models, filterable via output_modalities (see List embedding models — the same endpoint with output_modalities=embeddings applied). Without an Authorization header, returns the full public list with public caching; with a Bearer key belonging to an organization, the response is filtered by that org's model allowlist and switches to private, short-lived caching.

Authentication: Public

### Query parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `output_modalities` | `string` | no | A single comma-separated string (not repeated same-name query keys), e.g. image or text,image. Pass all to get every modality. Defaults to text only when omitted. Unrecognized values return 400. Common uses: for text models, keep the default or pass text explicitly; image models pass image; embeddings pass embeddings; text-to-speech pass speech and speech-to-text pass transcription; audio, video, and rerank are available the same way (some categories currently have few or no listed models); to get the union across every category at once (not a sum — a single model can belong to more than one) pass all. Values: text, image, embeddings, audio, video, rerank, speech, transcription, all comma-separated, single value Example: "text,image" |

### cURL

```bash
curl "https://bazaarlink.ai/api/v1/models?output_modalities=all"
```

### Response 200

Success. id is the short form (e.g. "gpt-4o") when the name is unambiguous across providers, otherwise (or when the model is media-billed) the canonical form (e.g. "openai/gpt-4o") — the canonical id is always additionally listed in aliases. Free models get an extra entry using the canonical id plus :free (zero pricing; a discovery signal, not a second real model). When the filter covers text, the list also includes the auto and auto:free virtual routing entries (owned_by "bazaarlink", not backed by any single real model); auto's pricing is "-1"/"-1" (meaning the actual routed model decides the cost) — only auto:free is genuinely zero-cost.

```json
{
  "object": "list",
  "data": [
    {
      "id": "gpt-4o",
      "object": "model",
      "owned_by": "openai",
      "name": "OpenAI: GPT-4o",
      "description": "OpenAI's multimodal flagship model.",
      "context_length": 128000,
      "aliases": [
        "openai/gpt-4o"
      ],
      "pricing": {
        "prompt": "0.0000025",
        "completion": "0.00001"
      },
      "architecture": {
        "modality": "text+image->text",
        "input_modalities": [
          "text",
          "image"
        ],
        "output_modalities": [
          "text"
        ]
      },
      "knowledge_cutoff": "2023-10",
      "max_completion_tokens": 16384
    },
    {
      "id": "openai/gpt-4o:free",
      "object": "model",
      "owned_by": "openai",
      "name": "OpenAI: GPT-4o (free)",
      "description": "Rate-limited free tier. OpenAI's multimodal flagship model.",
      "context_length": 128000,
      "aliases": [],
      "pricing": {
        "prompt": "0",
        "completion": "0"
      },
      "architecture": {
        "modality": "text+image->text",
        "input_modalities": [
          "text",
          "image"
        ],
        "output_modalities": [
          "text"
        ]
      }
    },
    {
      "id": "auto",
      "object": "model",
      "owned_by": "bazaarlink",
      "name": "Auto Router",
      "description": "Automatically selects the best model for the request.",
      "aliases": [],
      "pricing": {
        "prompt": "-1",
        "completion": "-1"
      },
      "architecture": {
        "modality": "text->text",
        "input_modalities": [
          "text"
        ],
        "output_modalities": [
          "text"
        ]
      }
    }
  ]
}
```

### Response 400

Malformed output_modalities — not a single comma-separated string, contains an unrecognized modality value, or exceeds the length limit.

```json
{
  "error": {
    "message": "output_modalities must be a single comma-separated string",
    "type": "invalid_request_error",
    "code": "invalid_output_modalities"
  }
}
```

### Response 500

An unexpected error occurred while fetching the model catalog.

```json
{
  "error": {
    "message": "Internal error fetching models",
    "code": 500
  }
}
```

---

## GET /api/v1/models/:slug

**Get a model**

Look up a single model's detail by slug (see List available models — the response shape is exactly one entry from that endpoint's data[] array, just not wrapped in the list envelope). Works for any public model when Authorization is omitted; with a Bearer key belonging to an organization, a model outside that org's model allowlist answers 404 (not 403) so the response doesn't leak whether the model exists at all.

Authentication: Public

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `slug` | `string` | yes | The model's URL slug — the canonical id with "/" replaced by "-" (e.g. openai/gpt-4o → openai-gpt-4o). Same format used by the model detail page (bazaarlink.ai/models/:slug), so a URL copied from that page works directly here. canonical model id with "/" replaced by "-" Example: "openai-gpt-4o" |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/models/openai-gpt-4o
```

### Response 200

Success. Same object shape as one List available models data[] entry (identical id short/canonical rule, aliases, pricing, architecture, etc.) — the only difference is this is a bare single object, not wrapped in the {object:"list", data:[...]} envelope.

```json
{
  "id": "gpt-4o",
  "object": "model",
  "owned_by": "openai",
  "name": "OpenAI: GPT-4o",
  "description": "OpenAI's multimodal flagship model.",
  "context_length": 128000,
  "aliases": [
    "openai/gpt-4o"
  ],
  "pricing": {
    "prompt": "0.0000025",
    "completion": "0.00001"
  },
  "architecture": {
    "modality": "text+image->text",
    "input_modalities": [
      "text",
      "image"
    ],
    "output_modalities": [
      "text"
    ]
  },
  "knowledge_cutoff": "2023-10",
  "max_completion_tokens": 16384
}
```

### Response 404

No model matches this slug — either it doesn't exist, or (when an organization Bearer key is used) it's outside that org's model allowlist.

```json
{
  "error": {
    "message": "Model 'nonexistent-model' not found.",
    "type": "invalid_request_error",
    "code": "model_not_found",
    "param": "slug"
  }
}
```

### Response 410

The model has been retired from the catalog — reuses the model_retired error convention already established on other endpoints (e.g. speech synthesis), not a format invented for this endpoint.

```json
{
  "error": {
    "message": "Model 'openai-gpt-4o' has been retired by the upstream provider and is no longer available.",
    "type": "model_not_available",
    "code": "model_retired",
    "param": "model"
  }
}
```

### Response 500

An unexpected error occurred while fetching this model.

```json
{
  "error": {
    "message": "Internal error fetching model",
    "code": 500
  }
}
```

---

# Organizations

## GET /api/v1/orgs/:orgId/teams

**List teams**

Lists all teams within the given organization (sorted by name), each with a member count. orgId comes from the id field of an organization object returned by GET /orgs — call that endpoint first to see which organizations you have access to. Only an org_admin of that organization may call this.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/orgs/org_abc123/teams \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. teams[] is sorted by name, and _count.members is that team's current member count.

```json
{
  "teams": [
    {
      "id": "team_abc123",
      "name": "Engineering",
      "costCenterCode": "ENG-01",
      "monthlyBudget": 500,
      "createdAt": "2026-06-01T00:00:00.000Z",
      "_count": {
        "members": 6
      }
    }
  ]
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key (a standard sk-bl- key fails closed here — it does not fall through to session auth); or the caller is authenticated but is not an org_admin in this organization (billing_viewer is not sufficient here).

```json
{
  "error": "Forbidden"
}
```

---

## POST /api/v1/orgs/:orgId/teams

**Create a team**

Creates a new team within the given organization. Only an org_admin of that organization may call this. Team names must be unique within the organization (an exact, case-sensitive match) — a duplicate returns 409.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `name` | `string` | yes | Team name; leading/trailing whitespace is trimmed. |
| `costCenterCode` | `string | null` | no | Optional cost-center code, a plain string field with no format validation. |
| `monthlyBudget` | `number | null` | no | Optional monthly budget figure in USD, with no range validation. |

### cURL

```bash
curl -X POST https://bazaarlink.ai/api/v1/orgs/org_abc123/teams \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Engineering", "monthlyBudget": 500}'
```

### Response 201

Created.

```json
{
  "team": {
    "id": "team_abc123",
    "name": "Engineering",
    "costCenterCode": null,
    "monthlyBudget": 500,
    "createdAt": "2026-07-27T00:00:00.000Z"
  }
}
```

### Response 400

The request body is not valid JSON, or name is empty after trimming whitespace.

```json
{
  "error": "name is required"
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 409

A team with this exact name (case-sensitive) already exists in this organization.

```json
{
  "error": "Team name already exists"
}
```

---

## PATCH /api/v1/orgs/:orgId/teams/:teamId

**Update a team**

Partially updates a team — only fields explicitly present in the body are changed (an explicit null clears that field; omitting it leaves the existing value unchanged). Only an org_admin of that organization may call this. Renaming does not re-check for a collision with another team's name — it is possible, in principle, to end up with a duplicate name this way; this is verified current behavior, not a documentation simplification.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |
| `teamId` | `string` | yes | The team ID, from a list-teams or create-a-team response. |

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `name` | `string` | no | New team name; leading/trailing whitespace is trimmed. |
| `costCenterCode` | `string | null` | no | Cost-center code; pass null to clear it. |
| `monthlyBudget` | `number | null` | no | Monthly budget in USD; pass null to clear it. |

### cURL

```bash
curl -X PATCH https://bazaarlink.ai/api/v1/orgs/org_abc123/teams/team_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monthlyBudget": 750}'
```

### Response 200

Updated. Returns the team's current state.

```json
{
  "team": {
    "id": "team_abc123",
    "name": "Engineering",
    "costCenterCode": "ENG-01",
    "monthlyBudget": 750,
    "createdAt": "2026-06-01T00:00:00.000Z"
  }
}
```

### Response 400

The request body is not valid JSON.

```json
{
  "error": "Invalid body"
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 404

The specified team was not found — the ID does not exist, or does not belong to this orgId.

```json
{
  "error": "Team not found"
}
```

---

## DELETE /api/v1/orgs/:orgId/teams/:teamId

**Delete a team**

Permanently deletes a team — the database row is removed outright. This cannot be undone, and unlike API keys, teams have no in-between "disabled" state to fall back on. Only an org_admin of that organization may call this.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |
| `teamId` | `string` | yes | The team ID, from a list-teams or create-a-team response. |

### cURL

```bash
curl -X DELETE https://bazaarlink.ai/api/v1/orgs/org_abc123/teams/team_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 204

Deleted successfully, no response body.

```text
(no body)
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 404

The specified team was not found — the ID does not exist, or does not belong to this orgId.

```json
{
  "error": "Team not found"
}
```

---

## GET /api/v1/orgs

**List organizations**

Lists every organization the caller belongs to along with their role in each, sorted by join date. Authentication here is looser than every other organization endpoint — any valid sk-bl- key works (standard or management), unlike everything downstream of an orgId (organization detail, teams, members), which all require a Management API Key. The id field on each returned organization is the orgId used by those endpoints.

Authentication: Bearer API key required

### cURL

```bash
curl https://bazaarlink.ai/api/v1/orgs \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. orgs[] is sorted by join date, and each entry includes the caller's role and join date in that organization.

```json
{
  "orgs": [
    {
      "id": "org_abc123",
      "name": "Acme Corp",
      "slug": "acme-corp",
      "plan": "enterprise",
      "billingModel": "invoice",
      "credits": 1250.5,
      "createdAt": "2026-03-01T00:00:00.000Z",
      "role": "org_admin",
      "joinedAt": "2026-03-01T00:00:00.000Z"
    }
  ]
}
```

### Response 401

Missing Authorization header, or the API key is invalid or disabled.

```json
{
  "error": "Unauthorized"
}
```

---

## GET /api/v1/orgs/:orgId

**Get an organization**

Returns details for the given organization, including billing-related fields (credits, credit limit, payment terms in days, billing cycle day) and team/member counts. Same as the teams and members endpoints, only an org_admin of that organization may call this — there is no reduced view for lower-privilege roles; billing fields sit at the same access tier as everything else.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/orgs/org_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. _count is the organization's current team and member counts.

```json
{
  "org": {
    "id": "org_abc123",
    "name": "Acme Corp",
    "slug": "acme-corp",
    "plan": "enterprise",
    "billingModel": "invoice",
    "credits": 1250.5,
    "creditLimitUsd": 5000,
    "paymentTermsDays": 30,
    "billingCycleDay": 1,
    "allowedDomains": [
      "acme.com"
    ],
    "createdAt": "2026-03-01T00:00:00.000Z",
    "_count": {
      "teams": 3,
      "members": 12
    }
  }
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 404

The specified organization was not found — the ID does not exist.

```json
{
  "error": "Not found"
}
```

---

## GET /api/v1/orgs/:orgId/members

**List members**

Lists all members of the given organization (sorted by join date), each with basic user info and their assigned team, if any. Only an org_admin of that organization may call this.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |

### cURL

```bash
curl https://bazaarlink.ai/api/v1/orgs/org_abc123/members \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 200

Success. members[] is sorted by join date; team is null when the member has no assigned team.

```json
{
  "members": [
    {
      "id": "orgmem_abc123",
      "role": "member",
      "monthlyBudget": 100,
      "joinedAt": "2026-04-01T00:00:00.000Z",
      "teamId": "team_abc123",
      "user": {
        "id": "usr_xyz789",
        "name": "Jane Doe",
        "email": "jane@acme.com"
      },
      "team": {
        "id": "team_abc123",
        "name": "Engineering"
      }
    }
  ]
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

---

## POST /api/v1/orgs/:orgId/members

**Add a member**

Adds an existing BazaarLink user (looked up by email) to the given organization. This endpoint **does not create a new account or send an invitation email** — the person must already have a BazaarLink account, or the request returns 404. role defaults to member when omitted; valid values are org_admin, billing_viewer, team_admin, and member. Only an org_admin of that organization may call this.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `email` | `string` | yes | The email of the user to add, matched case-insensitively with whitespace trimmed. |
| `role` | `string` | no | The member's role, defaults to member when omitted. Values: org_admin, billing_viewer, team_admin, member Example: "member" |
| `teamId` | `string | null` | no | Assigns the member to a given team (from a list-teams response); omitted or null means no team assignment. |
| `monthlyBudget` | `number | null` | no | Optional monthly budget figure in USD, with no range validation. |

### cURL

```bash
curl -X POST https://bazaarlink.ai/api/v1/orgs/org_abc123/members \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@acme.com", "role": "member", "teamId": "team_abc123"}'
```

### Response 201

Added.

```json
{
  "member": {
    "id": "orgmem_abc123",
    "role": "member",
    "monthlyBudget": null,
    "joinedAt": "2026-07-27T00:00:00.000Z",
    "teamId": "team_abc123",
    "user": {
      "id": "usr_xyz789",
      "name": "Jane Doe",
      "email": "jane@acme.com"
    },
    "team": {
      "id": "team_abc123",
      "name": "Engineering"
    }
  }
}
```

### Response 400

The request body is not valid JSON; email is empty after trimming; or role is not one of the valid values (org_admin, billing_viewer, team_admin, member).

```json
{
  "error": "email is required"
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 404

The email does not match any registered BazaarLink user — this endpoint does not create a new account or send an invitation email; the person must already have an account.

```json
{
  "error": "User not found"
}
```

### Response 409

This user is already a member of this organization.

```json
{
  "error": "User is already a member"
}
```

---

## PATCH /api/v1/orgs/:orgId/members/:memberId

**Update a member**

Partially updates a member's role, team assignment, or monthly budget — only fields explicitly present in the body are changed. Only an org_admin of that organization may call this. This endpoint has **no** "cannot demote the last org_admin" safeguard (that protection exists only on delete) — it is possible, in principle, to demote every org_admin this way, leaving the organization with none. This is verified current behavior, not a documentation simplification.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |
| `memberId` | `string` | yes | The member ID, from a list-members or add-a-member response. |

### Request body

| Name | Type | Required | Description |
|---|---|---:|---|
| `role` | `string` | no | The member's new role. Values: org_admin, billing_viewer, team_admin, member |
| `teamId` | `string | null` | no | Reassigns the member to a given team; pass null to unassign from any team. |
| `monthlyBudget` | `number | null` | no | Monthly budget in USD; pass null to clear it. |

### cURL

```bash
curl -X PATCH https://bazaarlink.ai/api/v1/orgs/org_abc123/members/orgmem_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"role": "team_admin"}'
```

### Response 200

Updated. Returns the member's current state.

```json
{
  "member": {
    "id": "orgmem_abc123",
    "role": "team_admin",
    "monthlyBudget": 100,
    "joinedAt": "2026-04-01T00:00:00.000Z",
    "teamId": "team_abc123",
    "user": {
      "id": "usr_xyz789",
      "name": "Jane Doe",
      "email": "jane@acme.com"
    },
    "team": {
      "id": "team_abc123",
      "name": "Engineering"
    }
  }
}
```

### Response 400

The request body is not valid JSON, or role is not one of the valid values (org_admin, billing_viewer, team_admin, member).

```json
{
  "error": "role must be one of: org_admin, billing_viewer, team_admin, member"
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 404

The specified member was not found — the ID does not exist, or does not belong to this orgId.

```json
{
  "error": "Member not found"
}
```

---

## DELETE /api/v1/orgs/:orgId/members/:memberId

**Remove a member**

Removes a member from the organization (only the org membership — the person's underlying BazaarLink account is not deleted). Only an org_admin of that organization may call this. There is one safety guard: removing a member who is the organization's only remaining org_admin is blocked with 400, so the organization can never end up with no admin.

Authentication: Bearer API key required

### Path parameters

| Name | Type | Required | Description |
|---|---|---:|---|
| `orgId` | `string` | yes | The organization ID, from a GET /orgs response. |
| `memberId` | `string` | yes | The member ID, from a list-members or add-a-member response. |

### cURL

```bash
curl -X DELETE https://bazaarlink.ai/api/v1/orgs/org_abc123/members/orgmem_abc123 \
  -H "Authorization: Bearer $BAZAARLINK_API_KEY"
```

### Response 204

Removed successfully, no response body.

```text
(no body)
```

### Response 400

This member is the organization's only remaining org_admin and cannot be removed — promote at least one other member to org_admin first.

```json
{
  "error": "Cannot remove last org_admin"
}
```

### Response 403

Failed the organization access check. Possible causes: no Bearer token and no logged-in session; a Bearer token that is not a Management API Key; or the caller is authenticated but is not an org_admin in this organization.

```json
{
  "error": "Forbidden"
}
```

### Response 404

The specified member was not found — the ID does not exist, or does not belong to this orgId.

```json
{
  "error": "Member not found"
}
```

---
