MIDNIGHT/ docs
PricingSign inStart free

API reference

These are the endpoints the widget itself uses. You do not need them to embed the widget — the script tag handles all of it — but they are here if you are building your own client or debugging one.

Scope
There is no general-purpose REST chat API yet. Everything below is the widget session flow: an origin-bound token, then calls carrying it. Dashboard-only endpoints are not documented here because they authenticate with your dashboard session and are not a stable public surface.

Authentication

Every call except the session handshake needs two things: a bearer token from POST /v1/widget/session, and an Origin header matching the one the token was issued to. The token is bound to that origin, so a token lifted from one site is useless on another. Tokens last 30 minutes.

Create a session

request
POST /v1/widget/session
Origin: https://your-site.example

{ "public_key": "your-agent-public-key" }
response
{
  "token": "<jwt>",
  "agent_display_config": {
    "agent_name": "Acme Support",
    "accent": "#6366F1",
    "initial_message": "Hi, I'm Acme Support's AI agent...",
    "suggested_questions": [],
    "branding": true,
    "locale": null
  }
}

The origin is checked against the agent's allow-list, which accepts bare hosts, wildcards such as *.example.com, and full origins. A request from an origin that is not allowed gets origin_not_allowed. An empty allow-list permits every origin.

Send a message

request
POST /v1/chat
Authorization: Bearer <token>
Origin: https://your-site.example

{ "message": "How do I cancel?", "conversation_id": "<uuid, optional>" }

The response is a server-sent event stream. Frames arrive as data: <json> and the stream ends with data: [DONE].

stream
data: {"type":"token","text":"You can cancel "}

data: {"type":"token","text":"from Settings."}

data: {"type":"done","message_id":"...","conversation_id":"...",
       "citations":[{"n":1,"id":"...","title":"Billing FAQ","url":"https://..."}],
       "credits_used":1}

data: [DONE]

Omit conversation_id on the first message and use the one that comes back on the done frame for the rest of the conversation.

When a human has taken the conversation over, the stream carries no tokens: it returns a single done frame with message_id set to human_handled and no credits used. The visitor's message is still recorded, and your reply reaches them through the updates endpoint below.

Errors during a stream

A failed message can still be an HTTP 200
Once the stream has opened, failures arrive as an error frame inside it rather than as an HTTP status. Running out of credits is the common case, and it is delivered as {"type":"error","error":{"code":"credits_exhausted",...}} on a 200 response — not as a 402. A client that only checks the status code will treat an unanswered message as answered.

Identify a visitor

request
POST /v1/identify
Authorization: Bearer <token>
Origin: https://your-site.example

{
  "external_id": "usr_8134",
  "hmac": "<64 hex characters>",
  "traits": { "email": "alice@example.com", "name": "Alice Chen" }
}
response
{ "verified": true, "contact_id": "<uuid>" }

See Identity verification for how to compute the signature. A rejection returns token_origin_mismatch whether the signature was wrong or no secret is configured — the two are deliberately indistinguishable.

Submit a lead

request
POST /v1/widget/lead
Authorization: Bearer <token>
Origin: https://your-site.example

{ "fields": { "email": "alice@example.com", "name": "Alice Chen" },
  "conversation_id": "<uuid, optional>" }

Returns { "ok": true, "contact_id": "<uuid>" }. This endpoint is exempt from the per-session rate limit — a visitor who hit the message limit can still leave their address — but the per-IP limit still applies.

Poll for human replies

request
GET /v1/conversation/<id>/updates?after=<message_id>
Authorization: Bearer <token>
Origin: https://your-site.example
response
{
  "handled_by": "human",
  "messages": [{ "id": "...", "content": "Hi, Alice — I can help.", "author_name": null }]
}

Returns up to 20 messages written by a human since after. This is how the widget shows a takeover without the visitor reloading.

Error envelope

Every error shares one shape:

json
{ "error": { "code": "rate_limited", "message": "Too many messages.", "retryable": true } }
CodeStatusRetryableMeans
origin_not_allowed403NoThe calling origin is not on the agent's allow-list.
token_origin_mismatch401NoMissing or invalid token, an origin that does not match it, or a refused identify call.
rate_limited429YesA rate limit was hit.
credits_exhausted402NoThe workspace is out of credits. In practice this arrives as a stream frame, not this status.
agent_offline503YesThe agent is unavailable.
validation_failed400NoThe request body did not validate.
internal_error500YesSomething failed on our side.

Two deviations worth coding against: credits_exhausted arrives inside the stream rather than as a 402, as described above, and the updates endpoint returns agent_offline with a 404 when the conversation does not exist, rather than the 503 the code normally implies.

A grounded “I don't know” is not an error and has no code. It is a normal answer with no citations.

Data-subject requests

Two endpoints on the dashboard app help you answer an access or erasure request from one of your visitors. They authenticate with your dashboard session and are restricted to owners and admins.

EndpointDoes
POST /api/gdpr/exportReturns everything held about one subject: contacts, conversations, messages, and copies that live outside the transcript.
POST /api/gdpr/eraseDeletes it. Without confirm: true it reports what would go without touching anything.

Name the subject with exactly one of external_id, email, end_user_id, or conversation_id — an anonymous visitor has no durable identifier, so a conversation id is the only way to identify them.

Embed guide
The script tag and JavaScript API.
Credits & limits
Costs, plans, and rate limits.