API details

Overview of the Merge Gateway API

Base API URL

All API endpoints in the reference documentation are relative to the following base URL:

https://api-gateway.merge.dev/v1

Authentication

For any request you make when communicating with Merge Gateway, you will need an API key to authenticate yourself as an authorized user.

Add your API Key with a “Bearer ” prefix as a header called Authorization to authorize your Merge API requests. This header must be included in every request in this format:

Authorization: Bearer <your_api_key>

You do not have to use the dashboard to get a key. The Management API mints, rotates, and revokes API keys programmatically, which is what you want if keys are provisioned per customer or checked into infrastructure-as-code.

Key endpoints

Gateway’s model-calling surface is centered around these endpoint groups:

  • GET /models: List models, filter by provider or vendor, or fetch a single model with ?model=<provider/model_id>
  • GET /vendors: List execution vendors and the models they currently serve, or fetch one with GET /vendors/{vendor_id}
  • POST /responses: Create an LLM response in Gateway’s native Responses shape. The response includes the vendor that ultimately served the request and the service_tier that actually served it.
  • POST /chat/completions: Create a chat completion in the OpenAI wire format
  • POST /messages: Create a message in the Anthropic wire format, with POST /messages/count_tokens to count input tokens without calling a model
  • POST /embeddings: Create embeddings, priced on input tokens
  • POST /decisions: Ask a decision model typed questions about some state, and get one typed answer per question back
  • Media endpoints: POST /images/generations for image generation, POST /audio/speech for speech synthesis, POST /audio/transcriptions for transcription, and POST /videos for video generation

Gateway also exposes SDK-compatible surfaces under /v1/openai, /v1/anthropic, /v1/ai-sdk, and /v1/langchain, so an existing client reaches the same models with a base URL change. The OpenAI and Anthropic wire formats are additionally served at their vendor-default paths: POST /v1/chat/completions (OpenAI SDK pointed at https://api-gateway.merge.dev/v1) and POST /v1/messages plus /v1/messages/count_tokens (Anthropic SDK pointed at https://api-gateway.merge.dev). Both forms accept the same requests. See Get started.

/v1/responses is Gateway's native API, not OpenAI's Responses API

The two share a URL suffix and accept the same request body, but they return different shapes: the native endpoint streams response.stream / response.done snapshots, while OpenAI’s Responses API streams response.createdresponse.completed events. Clients that implement OpenAI’s Responses API — the OpenAI SDK’s client.responses.*, Codex, and similar harnesses — belong on the /v1/openai base URL. See Responses below for what happens when such a client reaches the native endpoint anyway.

Administering the org, rather than calling models, is a separate surface: see Management API below.

Management API

API keys, projects, routing policies, and usage are administered programmatically through the Management API, on the same host. It is authenticated by a management key (Authorization: Bearer mgmt_<your_management_key>) rather than a gateway API key, so the credential that provisions keys is never one that can make model calls. Create a management key in the dashboard under API keys → Management keys.

Endpoint groupWhat it covers
/v1/keysCreate, list, update, and revoke API keys, each with an optional spend limit and a limit_reset window of daily, weekly, or monthly
/v1/projectsCreate and manage projects, their routing policy, budgets, usage, and per-project prompt injection and DLP settings
/v1/routing-policiesCreate and update routing policies and set the org default
/v1/organization/usageOrg-wide and per-project spend rollups with per-model breakdowns

Full reference and scopes: Management API. Walkthroughs: API keys and Projects API.

Models API shape

GET /models returns canonical model identity at the top level and vendor-specific execution metadata under vendors.

Example:

{
"model": "anthropic/claude-opus-4-6",
"provider": "anthropic",
"display_name": "Claude Opus 4.6",
"vendors": {
"anthropic": {
"launch_date": "2025-05-14",
"context_window": 1000000,
"max_output_tokens": 32768,
"availability_status": "available",
"capabilities": {
"input": ["text", "image"],
"output": ["text", "tool_use"],
"supports_tool_calling": true,
"supports_tool_choice": true,
"supports_structured_outputs": true,
"streaming": true
},
"service_tiers": ["standard", "flex"],
"pricing": {
"input_per_million": 2.5,
"output_per_million": 10,
"currency": "USD",
"flex": { "input_per_million": 1.25, "output_per_million": 7.5 }
}
}
},
"availability_status": "available",
"created_at": "2025-05-14T00:00:00Z",
"updated_at": "2026-03-01T00:00:00Z"
}

Use GET /models?model=<provider/model_id> when you want one specific model object but prefer a query parameter over a path parameter. The slash is fine there because it is part of the query parameter value.

Models that need access enabled for your organization carry two extra fields and are still listed, so you can see what is available to request:

{
"model": "anthropic/claude-opus-4-6",
"access_required": true,
"access_reason": "vendor_access_required"
}

access_required is false and access_reason is null on every model you can already call. See Vendor access.

Vendors API shape

GET /vendors returns execution hosts, not canonical model owners.

Example:

{
"vendor": "bedrock",
"name": "AWS Bedrock",
"models": [
"anthropic/claude-opus-4-6",
"google/gemma-3-27b-it"
],
"supports_zdr": true,
"supports_byok": true,
"availability_status": "active"
}

Pagination

GET /models and GET /vendors return a paged list envelope:

{
"object": "list",
"data": [ ... ],
"has_more": true,
"next_cursor": "mistral/magistral-medium-2509-thinking"
}

limit sets the page size. It defaults to 50 and caps at 500. A higher value is rejected before the request reaches the catalog:

HTTP 422
{
"detail": [
{
"type": "less_than_equal",
"loc": ["query", "limit"],
"msg": "Input should be less than or equal to 500",
"input": "1000",
"ctx": { "le": 500 }
}
]
}

Do not size a single request to the catalog. The catalog grows as models are added, so a limit that fits today silently starts truncating later. Page instead: send the previous response’s next_cursor back as cursor and keep going while has_more is true. The next page starts after the cursor entry, so pages never overlap, and next_cursor is null on the last page. Treat the cursor as opaque, a value to pass back rather than one to construct.

Python
import requests
BASE = "https://api-gateway.merge.dev/v1"
headers = {"Authorization": "Bearer YOUR_API_KEY"}
models, cursor = [], None
while True:
params = {"limit": 500}
if cursor:
params["cursor"] = cursor
page = requests.get(f"{BASE}/models", headers=headers, params=params).json()
models.extend(page["data"])
if not page["has_more"]:
break
cursor = page["next_cursor"]
print(len(models))
cURL
# First page
curl "https://api-gateway.merge.dev/v1/models?limit=500" \
-H "Authorization: Bearer YOUR_API_KEY"
# Next page: pass the previous response's next_cursor
curl "https://api-gateway.merge.dev/v1/models?limit=500&cursor=mistral/magistral-medium-2509-thinking" \
-H "Authorization: Bearer YOUR_API_KEY"

Filters do not carry across pages on their own, so repeat provider or vendor on every request in the loop alongside cursor. Fetching one model with ?model=<provider/model_id> returns a bare model object rather than a list envelope, so it has no has_more or next_cursor.

The Management API paginates separately and caps its list endpoints at 100 per page: GET /v1/keys takes offset and limit and reports has_more, and GET /v1/projects takes cursor and limit.

Responses

POST /responses returns the canonical model that served the request and a top-level vendor field for the execution host that actually handled it.

Request parameters

Beyond the message input, the request accepts these optional fields:

FieldTypeDescription
service_tier"standard" | "flex" | "priority"Processing tier. Omit for standard. Only allowed on routes priced for the tier, otherwise the request fails closed with 400. See Service tiers for which routes price which tier.
service_tier_fallbackbooleanIf the provider throttles the requested tier (429 / 503), retry once at standard instead of erroring. Defaults to false.
logprobsbooleanReturn log probabilities for each output token. Also accepted on POST /openai/chat/completions.
top_logprobsintegerHow many alternative tokens to score per position, when logprobs is set

Log probabilities

logprobs and top_logprobs ride through to the provider and come back in OpenAI’s shape, on logprobs on the output message. On a stream, interim chunks carry only that chunk’s delta and the terminal chunk carries the full list, so accumulate rather than reading one frame.

Log probabilities are output metadata rather than a generation control, so a route that refuses them is served without them instead of failing: Gateway strips the two fields for that route and the completion is unchanged. There is no per-route logprobs capability yet, so a routing policy cannot select for it. Pin a route you know supports them when they are load-bearing.

Sampling parameters

temperature, top_p, top_k, and stop are accepted everywhere, and a few model families refuse them rather than ignoring them. On those routes Gateway strips the field and dispatches, so you get an answer at the model’s own fixed sampling instead of a provider 400:

FieldRoutes that refuse it
temperature, top_p, top_kopenai/gpt-5.6-sol and gpt-5.6-terra, moonshot/kimi-k3 and kimi-k2.5, and the newest Claude models
stopThe DeepSeek routes on Bedrock

The practical consequence is that temperature: 0 does not buy determinism on those routes. When a run has to be reproducible, pin a route that accepts the parameter.

Response headers

Non-streaming responses name the route that served them in headers, which is the cheapest way to see the decision without asking for the full routing block:

HeaderValue
x-merge-vendorThe vendor that executed the request
x-merge-modelThe canonical model that served it
x-merge-routing-policy-idThe policy that resolved, when one did
X-Request-IDThe Gateway request id, useful in support conversations

Streams carry X-Request-ID and the rate-limit and budget headers, but not x-merge-routing-policy-id, because headers commit before the policy resolves. Read the routing block off the terminal frame instead. See Streaming.

include_routing_metadata is settable as a request header as well as a body field, for proxies that cannot cheaply rewrite a JSON body. Gateway accepts include-routing-metadata, include_routing_metadata, and X-Merge-Include-Routing, all of them on-only: a header can turn routing metadata on, and never turns off a body field that asked for it. Prefer the hyphenated spelling, since nginx drops headers with underscores by default.

Guardrails metadata

Set include_guardrails_metadata: true and the response carries a top-level guardrails object beside routing, with the outcome of prompt injection protection and data loss prevention for that request. It is opt-in per request, like routing metadata, and also accepted as the include-guardrails-metadata header (on-only, same rules as above).

"guardrails": {
"prompt_injection": {
"mode": "alert",
"action": "observe",
"score": 0.81,
"block_threshold": 0.57,
"triggering_segment": { "id": "msg_1", "kind": "user_message", "score": 0.81 },
"segments": [
{ "id": "sys_0", "kind": "system_message", "trusted": true, "score": 0.02, "lexical_hit": false },
{ "id": "msg_1", "kind": "user_message", "trusted": true, "score": 0.81, "lexical_hit": true }
],
"indirect": { "mode": "off", "tainted": false, "would_block": false },
"output": { "credential_findings": 0, "redacted": false },
"policy_scope": "org",
"latency_ms": 212.4
},
"dlp": {
"status": "redacted",
"action": "redact",
"finding_count": 2,
"entity_counts": { "EMAIL_ADDRESS": 1, "US_SSN": 1 },
"action_counts": { "REDACT": 2 },
"rule_ids": ["6f1c2a8e-…"],
"policy_scope": "org",
"latency_ms": 41.0
}
}

Three things to know:

  • It never contains your prompt. Segments are identified by id and kind, never by text, and DLP reports entity types and counts, never the matched values. prompt_injection.score is the maximum direct-injection score across segments, on the same 0 to 1 scale as block_threshold; in alert mode a score at or above the threshold is what would have blocked in block mode.
  • It is on blocked responses too. A request rejected by either system returns 422 with guardrails beside error, so the score that caused the block is in the same body as the block. See Errors.
  • In alert mode it costs a little latency. Alert-mode prompt injection scoring normally runs after the response is sent, since it never changes the outcome. Asking for the object moves that scan back onto the request path for that one request, within the same bounded budget as block mode. It also shares the small per-instance pool of scan slots that alert-mode scoring already uses, so a burst of flagged requests cannot crowd out enforcement for other organizations on the same instance. If the time budget is exceeded, skipped_reason is alert_timeout; if every slot is busy, it is saturated. In both cases score is null and the request is served normally. Requests that do not set the flag are unaffected, so leave it off on the hot path and turn it on where you are measuring.

On streaming requests the object rides on the final response.done chunk. prompt_injection.output is omitted on streams, because the output-side credential scan runs on non-streaming responses only. The full schema is on POST /responses in the reference below.

OpenAI Responses API clients

POST /responses speaks Gateway’s native shape. OpenAI’s Responses API is served at POST /openai/responses, and that is where OpenAI-wire clients (the OpenAI SDK’s client.responses.*, Codex) should point. Three behaviors keep a client that reaches the native endpoint by mistake from failing silently:

  • Codex is served the OpenAI shape automatically. Codex tags every request with an originator: codex_* header; a POST /v1/responses carrying it is served by POST /v1/openai/responses, so Codex works whether its base URL ends in /v1 or /v1/openai. The documented base URL is still /v1/openai.
  • X-Merge-Wire-Format selects the shape explicitly. X-Merge-Wire-Format: openai on POST /v1/responses returns the OpenAI Responses shape for any caller; X-Merge-Wire-Format: native pins the native shape and overrides the Codex detection. Any other endpoint ignores the header.
  • OpenAI-only request fields are translated, not dropped. On the native endpoint, instructions becomes a leading system message, reasoning.effort is applied as the reasoning effort for the route that serves the request, text.format becomes response_format, and max_output_tokens is an alias of max_tokens. An explicit native field wins over its OpenAI counterpart (thinking over reasoning, response_format over text.format). parallel_tool_calls is accepted without effect. Any other OpenAI-only field (truncation, include, background, prompt_cache_key, …) is ignored and reported in the response’s warnings with code openai_fields_ignored, so a misrouted client can see which settings did not apply:
"warnings": [{
"code": "openai_fields_ignored",
"message": "Request fields with no native Responses API equivalent were ignored: truncation. Clients speaking the OpenAI Responses wire format (Codex, the OpenAI SDK) should call /v1/openai/responses, or send X-Merge-Wire-Format: openai.",
"detail": { "ignored_fields": ["truncation"], "openai_compat_path": "/v1/openai/responses" }
}]

On streaming requests the warning rides on the final response.done chunk. An unknown reasoning.effort level or a malformed text.format is rejected with 400, exactly as on POST /openai/responses.

OpenAI hosted tools on POST /openai/responses

OpenAI’s built-in tools run inside OpenAI’s own Responses API, which Gateway reaches only for an OpenAI model served through the direct openai vendor. Two of them are supported:

  • web_search and web_search_preview run on streaming and non-streaming requests. On a route that cannot reach OpenAI the tool is removed and the response carries an openai_web_search_dropped warning. Use {"type": "merge:web_search"} for search that works on every model.
  • tool_search with server execution (the default when execution is omitted) is OpenAI’s deferred tool loading: functions marked "defer_loading": true stay out of the model’s context until OpenAI’s search loads them. Gateway serves it on non-streaming requests to gpt-5.4 and later, exactly as OpenAI does. The response is OpenAI’s, with its tool_search_call and tool_search_output items and its response id, and the tools and items round-trip verbatim on the next turn.
{
"model": "openai/gpt-5.4",
"input": "What is the shipping ETA for order 42?",
"tools": [
{ "type": "tool_search" },
{
"type": "function",
"name": "get_shipping_eta",
"description": "ETA for an order",
"defer_loading": true,
"parameters": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] }
}
]
}

Two requests are rejected up front rather than served without the tool: a streaming request fails with 400 tool_search_stream_unsupported, and a request that cannot reach OpenAI (another provider’s model, default_routing, a vendor pin on another vendor, or an org policy that excludes OpenAI) fails with 400 openai_tool_search_requires_openai. If a route resolves away from OpenAI only after the request was accepted, the request still runs with every deferred tool loaded up front and the response carries an openai_tool_search_dropped warning.

tool_search with "execution": "client" is a different protocol, the one Codex uses to discover MCP tools: Gateway hands the model a callable tool, returns the model’s call as a tool_search_call item, and folds the tools you report back in tool_search_output into the next request. It works on every model and on streams.

Anthropic’s tool search tools (tool_search_tool_bm25_20251119, tool_search_tool_regex_20251119) are server tools of the Messages API and pass through unchanged on POST /v1/messages and /anthropic/v1/messages, including Bedrock-hosted Claude.

code_interpreter, file_search, mcp, and local_shell are not executed on any route: POST /openai/responses removes them with a tools_dropped warning, and the native endpoint rejects them with 400 unsupported_tool_type.

Served tier and billing

The response echoes a top-level service_tier: the tier that actually served the request and the rate you were billed at. On a throttle fallback this differs from the tier you sent (e.g. you requested flex but were served and billed at standard).

{
"model": "openai/gpt-5.4",
"vendor": "openai",
"service_tier": "flex",
"usage": { "input_tokens": 812, "output_tokens": 180, "total_tokens": 992, "cost": 0.00219 }
}

See Service tiers for the full flow, supported vendor routes, and fallback behavior.

Per-call cost

Every response carries usage.cost: the provider cost in USD that Gateway computed for that call, from the tokens above and the pricing of the route that actually served the request. No request parameter is needed.

"usage": { "input_tokens": 812, "output_tokens": 180, "total_tokens": 992, "cost": 0.00219 }

usage.cost is returned on:

EndpointNotes
POST /responsesStreaming responses carry usage only on the final response.done chunk, so that is where cost appears
POST /embeddingsPriced on input tokens
POST /openai/responses, POST /openai/chat/completions, POST /openai/embeddingsSame value on the OpenAI-compatible surfaces, including the final streaming chunk

Three details worth knowing:

  • cost is null, never 0, when the served route has no pricing on file. A false zero would be recorded as real spend by anything summing costs, so an unknown price is reported as unknown. Cost-tracking tools that read this field, such as Langfuse, skip a null instead of logging $0.
  • It is the same figure as routing.cost_usd, which is only returned when you set include_routing_metadata: true. Both come from the same calculation, so a request that asks for both sees them agree. Use usage.cost for everyday cost tracking and routing metadata when you also need the policy and vendor decisions behind the call.
  • It is provider cost, not your invoiced amount. The two can differ, and the invoice is the authority. Billing starts from this figure and then applies your organization’s plan rate, prices BYOK requests against the model’s list price rather than what your own provider account charged, and adds server-tool charges such as web search, which never appear in usage.cost. Logs shows both halves per request, what the tokens cost with Merge’s fee underneath, so a request that reads differently here and in the dashboard can be reconciled without opening a ticket. See Cost governance.
  • routing.merge_fee_usd is Merge’s fee on the request. Returned alongside routing.cost_usd when you set include_routing_metadata: true and the field is enabled for your organization (it is rolling out per organization; ask us to turn it on), at the markup rate that applies to your organization: your plan rate, or 0 when no markup applies (for example free-tier usage). cost_usd + merge_fee_usd is the request’s cost with markup, and it reads the same way on every key: on a BYOK key, cost_usd is what the tokens cost on your own provider account and merge_fee_usd is what Merge charges on top, so you can track your BYOK spend with and without Merge’s fee per request. Like cost_usd, it excludes server-tool charges, and it is null when cost_usd is null.

Cost reflects the tier that served the request, so a flex request that fell back to standard is priced at standard. For invoiced spend, budgets, and dashboards, see Cost governance and savings.