Ensemble Docs

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

HeaderOnMeaning
X-Request-IdEvery answered requestQuote this when reporting a problem. It is the id in our logs.
X-Api-VersionEvery answered requestThe contract version that answered.
X-Api-ChannelDraft channel onlydraft, so a draft response is recognisable.
X-Target-VersionSuccessful callsThe tool or agent revision that ran.
X-Thread-IdAgent operationsThe conversation this call belongs to.
DeprecationDeprecated APIstrue.
SunsetDeprecated or sunset APIsWhen it stops answering, as an HTTP date.
Idempotent-ReplayA replayed idempotent calltrue. The target did not run again.
Allow405 responsesThe methods available on the path that matched.
Cache-ControlEvery responseprivate, 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" }'
SituationAnswer
First use of the keyRuns normally. The response is stored.
Same key, same request, first attempt finishedThe stored response, with Idempotent-Replay: true.
Same key, same request, first attempt still running409 IDEMPOTENCY_IN_PROGRESS
Same key, different request409 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.

StatusCodeCause
400VALIDATION_FAILEDAn input was missing, the wrong type, or a path segment was not valid encoding.
401UNAUTHORIZEDNo key or token was presented.
403FORBIDDENThe key has no grant on this API, or none on this operation.
404API_NOT_FOUNDNo such slug, contract version, or nothing published under it.
404OPERATION_NOT_FOUNDThe API exists; nothing matches that path.
405METHOD_NOT_ALLOWEDThe path exists on other methods. See Allow.
409IDEMPOTENCY_CONFLICTThat Idempotency-Key was used with a different request.
409IDEMPOTENCY_IN_PROGRESSAn earlier request with that key has not finished.
409DRAFT_AMBIGUOUSThe slug has several contract versions. Name one with x-api-version.
410API_SUNSETThis contract version has been retired.
413RESPONSE_TOO_LARGEThe operation's answer exceeded what this surface returns.
422TARGET_FAILEDThe operation ran and reported a failure. details carries what it said.
429RATE_LIMITEDSee below.
500TARGET_UNRESOLVEDThe definition points at something that could not be resolved. Not your fault.
500INTERNALSomething went wrong. Quote the request id.
504TIMEOUTThe operation did not finish within its time budget.

Two distinctions worth noting when writing a client:

  • 422 versus 500. TARGET_FAILED means the thing you called ran and said no, and details explains it. TARGET_UNRESOLVED and INTERNAL are ours; retrying may help, changing your request will not.
  • 404 versus 403. Reaching the API is checked before the path, so an unauthorised key gets 403 for every path, including ones that do not exist.

Rate limits

LimitDefaultKeyed on
Requests that authenticate120 per minuteThe key, and the API
Requests that fail to authenticate30 per minuteThe 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.

On this page