Calling an endpoint
The caller's reference. Authentication, the OpenAPI document, response headers, idempotency, the error envelope and rate limits.
This is the page to hand an integrator. Everything on it is observable from outside.
Authentication
Either an API key, sent as a header:
curl https://service.ensembleapp.dev/external/v1/benefits/coverage/E-4417 \
-H 'x-api-key: sk_...'Or an OAuth 2.0 client credentials access token, for a caller who would rather not hold a long-lived key:
curl https://service.ensembleapp.dev/external/v1/benefits/coverage/E-4417 \
-H "Authorization: Bearer $TOKEN"A token authenticates as the API key its client was created against, so it reaches exactly what that key reaches and there are no separate permissions to grant. Create clients under Credentials, on the key they act as; the token endpoint covers the exchange, and Authentication covers API keys themselves.
The tenant comes from the key, so x-tenant-id is not needed. It is still accepted, and is
still required for keys issued before keys could be found on their own.
A key also needs a grant on the API. Without one the answer is 403, before any path
matching, so the surface cannot be used to find out which operations exist.
Reading the contract
curl https://service.ensembleapp.dev/external/v1/benefits/_openapi.json \
-H 'x-api-key: sk_...'The document is generated per key and filtered to that key's grant: a narrowed grant describes only the operations it names, and a read-only grant does not describe the unsafe ones. Advertising an operation the key would be refused is worse than not describing it.
This surface carries no CORS headers, by design: it is key-authenticated and server-to-server, and a browser that could call it would be a browser holding a key. Fetch the document from your own backend.
Response headers
| Header | On | Meaning |
|---|---|---|
X-Request-Id | Every answered request | Quote this when reporting a problem. It is the id in our logs. |
X-Api-Version | Every answered request | The contract version that answered. |
X-Api-Channel | Draft channel only | draft, so a draft response is recognisable. |
X-Target-Version | Successful calls | The tool or agent revision that ran. |
X-Thread-Id | Agent operations | The conversation this call belongs to. |
Deprecation | Deprecated APIs | true. |
Sunset | Deprecated or sunset APIs | When it stops answering, as an HTTP date. |
Idempotent-Replay | A replayed idempotent call | true. The target did not run again. |
Allow | 405 responses | The methods available on the path that matched. |
Cache-Control | Every response | private, no-store. |
Cache-Control is not negotiable: the same URL answers differently per tenant, so a shared
cache keyed on the URL alone would cross tenants.
Idempotency
Send Idempotency-Key with any operation that is not safe to make a retry safe:
curl -X POST https://service.ensembleapp.dev/external/v1/benefits/enrol \
-H 'x-api-key: sk_...' \
-H 'Idempotency-Key: enrol-E4417-2026-03-01' \
-H 'Content-Type: application/json' \
-d '{ "employeeId": "E-4417", "plan": "PPO" }'| Situation | Answer |
|---|---|
| First use of the key | Runs normally. The response is stored. |
| Same key, same request, first attempt finished | The stored response, with Idempotent-Replay: true. |
| Same key, same request, first attempt still running | 409 IDEMPOTENCY_IN_PROGRESS |
| Same key, different request | 409 IDEMPOTENCY_CONFLICT |
The key is scoped to the API. It is ignored on safe operations, where replaying a read
would cost a stored row for no benefit; a caller sending one on a GET has misunderstood
rather than asked for something.
The error envelope
Every failure has the same shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request did not match the operation contract",
"details": [{ "field": "employeeId", "message": "expected a number, got \"abc\"" }],
"requestId": "req-9f3c1d..."
}
}details is present when there is something specific to say. requestId matches the
X-Request-Id header.
| Status | Code | Cause |
|---|---|---|
400 | VALIDATION_FAILED | An input was missing, the wrong type, or a path segment was not valid encoding. |
401 | UNAUTHORIZED | No key or token was presented. |
403 | FORBIDDEN | The key has no grant on this API, or none on this operation. |
404 | API_NOT_FOUND | No such slug, contract version, or nothing published under it. |
404 | OPERATION_NOT_FOUND | The API exists; nothing matches that path. |
405 | METHOD_NOT_ALLOWED | The path exists on other methods. See Allow. |
409 | IDEMPOTENCY_CONFLICT | That Idempotency-Key was used with a different request. |
409 | IDEMPOTENCY_IN_PROGRESS | An earlier request with that key has not finished. |
409 | DRAFT_AMBIGUOUS | The slug has several contract versions. Name one with x-api-version. |
410 | API_SUNSET | This contract version has been retired. |
413 | RESPONSE_TOO_LARGE | The operation's answer exceeded what this surface returns. |
422 | TARGET_FAILED | The operation ran and reported a failure. details carries what it said. |
429 | RATE_LIMITED | See below. |
500 | TARGET_UNRESOLVED | The definition points at something that could not be resolved. Not your fault. |
500 | INTERNAL | Something went wrong. Quote the request id. |
504 | TIMEOUT | The operation did not finish within its time budget. |
Two distinctions worth noting when writing a client:
422versus500.TARGET_FAILEDmeans the thing you called ran and said no, anddetailsexplains it.TARGET_UNRESOLVEDandINTERNALare ours; retrying may help, changing your request will not.404versus403. Reaching the API is checked before the path, so an unauthorised key gets403for every path, including ones that do not exist.
Rate limits
| Limit | Default | Keyed on |
|---|---|---|
| Requests that authenticate | 120 per minute | The key, and the API |
| Requests that fail to authenticate | 30 per minute | The calling IP |
Standard RateLimit-* headers are returned, so a client can back off without guessing.
The first is per key and per API, so a busy endpoint cannot starve a quiet one held by the same key. An integrator behind one NAT does not share a budget with everyone else there, because a key is the thing that can be held responsible.
The second exists because the first never sees a request that failed authentication. Only
a 401 counts against it, so sending malformed bodies all day costs nothing.
Two consequences of the failed-authentication budget. Callers behind one address share it. And once it is spent, it runs before authentication, so every request from that address is refused for the rest of the minute, including ones with a good key.
Both limits are in-memory per server instance, so with several instances a caller gets roughly that many times the number. A shared store is the fix; a loose limit beats none on a surface that executes tools.
Agent conversations
An agent operation starts a fresh conversation unless you continue one:
curl -X POST https://service.ensembleapp.dev/external/v1/support/ask \
-H 'x-api-key: sk_...' \
-H 'x-thread-id: external-7Kd91mZq' \
-H 'Content-Type: application/json' \
-d '{ "message": "And what about dental?" }'Take the value from a previous response's X-Thread-Id rather than inventing one. Only
ids this surface issues are accepted; anything else is ignored and a fresh conversation
starts, which is what keeps a guessed id from addressing a chat conversation elsewhere in
the platform.