API keys and the hooks API
Endpoint reference for triggering workflows, passing files, holding chat sessions, and reading results over signed HTTP calls
Overview
A project API key lets an external system drive a workflow over HTTP without a user session. Requests are signed rather than carrying a token, and every endpoint sits under /api/v1/hooks/{api_key_id}.
This page is the endpoint reference. For creating, rotating and revoking keys, see Role tiers. For how the API trigger fits with the other trigger sources, see Triggers and execution.
The path says hooks for backwards compatibility, but executions started this way are tagged with the api trigger type. This is the public external API.
It is not the same as an inbound webhook, which exists for a third-party sender whose payload you cannot change.
Signing a request
Every call carries two headers:
| Header | Value |
|---|---|
X-Timestamp | A Unix timestamp |
X-Signature | HMAC-SHA256(secret, "<timestamp>." + <body>), hex |
The key ID travels in the URL. The secret never goes over the wire; it only signs locally.
X-Timestamp must be within 5 minutes of server time. A stale or future timestamp is rejected before the signature is compared at all, which is what stops a captured request being replayed later.
If calls fail authentication while the signature looks correct, check the calling machine's clock first.
Endpoints that take no body sign the empty string. The payload is "<timestamp>." with nothing after the dot.
This applies to file upload, file download, export download, session reads, and reading a human task. Signing the query string or the multipart body instead is the most common reason a correct-looking request returns 404.
Every authentication failure returns 404, never 401 or 403. A wrong signature, an unknown key, a revoked key and a key whose secret cannot be read are indistinguishable, so a key ID cannot be discovered by probing.
If you get a 404 you did not expect, check the signature before you check the path.
Endpoints
Start a workflow
POST /api/v1/hooks/{api_key_id}Signs the request body.
{
"use_case_id": "<id or slug>",
"input": { "document": "<file_id>", "question": "..." }
}The response carries an execution_id, a poll_token and a poll_url.
| Behaviour | Detail |
|---|---|
| Version | Runs the use case's published version. Returns 404 if nothing is published. The version is pinned on the execution record. |
| Validation | The input is validated against the Start node's schema. Violations return 422 with a map of field errors. |
| Async | The call returns immediately. Poll for the result. |
| Sync | Add ?wait=true to block for up to about 60 seconds, a deployment setting. The status code says what happened: 200 if the run finished inside the window, 202 if it did not and you should keep polling. |
Poll for status
GET /api/v1/poll/{poll_token}No authentication. The token is the credential. Rate limited to 60 per minute.
A 200 from ?wait=true means the run finished, not that it succeeded. A run that finished by failing also returns 200, so read the body rather than the status.
When the execution is waiting on a human task, the response includes the pending task IDs. This covers sub-workflows too: if the root run is still going but a child is blocked on a task, polling the root token reports it with the child's task ID. Child executions have no poll token of their own, so you always poll the one root token.
Pass a file in
Files cannot go in the JSON body. Upload first, then reference the ID.
Upload
POST /api/v1/hooks/{api_key_id}/files?extract_text=true
Content-Type: multipart/form-dataSigns the empty body. Returns a file_id.
llm_config_id is required for a scanned PDF or an image, because reading one needs vision. It is ignored for a text document.
Reference it in the trigger input
{ "use_case_id": "...", "input": { "document": "<file_id>" } }The Start node must declare that field as a file or an image type. Files are scoped to the key's project.
Read a file back out
Two separate endpoints, because an uploaded input and a generated output are different things.
GET /api/v1/hooks/{api_key_id}/files/{file_id}Reads back a file uploaded over this API. The file must belong to the key's project, which is the same boundary the upload drew.
This exists to close an asymmetry. The ordinary download route needs a user session by construction, so an HMAC caller got a flat 401 from it. A client rendering its own chat history could not show an attachment it had uploaded itself minutes earlier.
GET /api/v1/hooks/{api_key_id}/exports?key=<storage_key>Downloads a file produced by this key's executions. The storage key comes back in the execution output.
| Files endpoint | Exports endpoint | |
|---|---|---|
| Serves | What you uploaded | What a run generated |
| Identified by | file_id | Storage key |
| Scope | The key's project | Proven to be produced by this key |
The exports endpoint is the tighter of the two, because an execution records which key produced its output. An upload records no per-key ownership, so the project is the boundary available to check against.
Answer a human task
GET /api/v1/hooks/{api_key_id}/human-tasks/{task_id}
POST /api/v1/hooks/{api_key_id}/human-tasks/{task_id}/respondThe GET signs an empty body and returns the task's questions, status and any answers. Use it after polling reports a pending task, to discover the question shape before answering.
The POST signs its body and is idempotent. Add ?wait=true to wait for the workflow to settle after answering.
A key may only read and answer tasks on executions it started. See Human task node.
Hold a chat session
When the target use case's interface type is chat or hybrid, the trigger endpoint runs a conversational path instead of fire-and-forget. The key gets a resumable, memory-bearing thread, while the execution still audits as an API trigger.
{ "use_case_id": "...", "session_id": "<optional>", "input": { "message": "hi" } }| Interface type | session_id | Result |
|---|---|---|
form | absent | Fire and forget |
form | present | 400, the use case does not support chat |
chat or hybrid | absent | A new session |
chat or hybrid | present | Resumes it, or 404 if it is not this key's |
The response echoes both conversation_id and session_id. Store the session ID and send it back to continue the thread.
Threads are owned by the key, not by the person who created it. Two keys never share threads, and a key's threads are a separate namespace from a real user's in-app conversations.
This means a key is a long-lived identity holding conversation history. Treat revoking a key as ending its threads.
Read endpoints, both signing an empty body:
GET /api/v1/hooks/{api_key_id}/sessions?use_case_id=<id or slug>
GET /api/v1/hooks/{api_key_id}/sessions/{session_id}/messages?limit=50The first returns summaries only, with no message bodies. The second returns a transcript: the newest limit messages, between 1 and 200, oldest-first within that window. Page back through a longer transcript with before_id set to the topmost message ID, until a page comes back empty.
Idempotency
Send an Idempotency-Key header, up to 64 characters, and a retry replays the prior turn's execution rather than starting a second one. The same session_id and execution_id come back.
Use it on every chat-mode trigger. A retry after a dropped connection is otherwise a second turn in the conversation, which is visible to the user and impossible to undo.
Per-key statistics
GET /api/v1/projects/{project_id}/api-keys/{id}/stats?days=7Session-authenticated, not signed. Returns an execution summary, a time series, the top use cases, and token and cost totals.
days accepts 0 to 90. Pass days=0 for all time.
Good practice
A key cannot be given narrower permissions than the project, so separation comes from having separate keys. One per integration means you can revoke one without taking the others down, and the per-key statistics tell you what each one is doing.
Child executions have no poll token. The root token reports a child's pending human task, so one polling loop covers a nested workflow.
Especially in chat mode, where a duplicate is a visible extra turn.
The trigger runs the published version and returns 404 when nothing is published.
Every authentication failure collapses to 404 on purpose. Check the timestamp, the payload you signed, and whether the endpoint signs an empty body.
Troubleshooting
Causes to check, in order:
- The signature is over the wrong payload. Endpoints with no body sign
"<timestamp>."and nothing more. - The timestamp is stale or in a different unit.
- The key was revoked.
- The key ID in the URL is wrong.
All four look identical from outside, by design.
Cause: The use case has no published version.
Fix: Publish it. See Versions and publishing.
Cause: The input does not satisfy the Start node's schema on the published version.
Fix: Compare your payload against the published schema, not the draft.
Cause: You sent a session_id to a form-interface use case.
Fix: Drop the session ID, or point at a chat or hybrid use case.
Causes to check: the Start node field is not declared as a file or image type, or you passed the filename rather than the returned file_id.
Cause: No llm_config_id was supplied, so there was no vision model to read it with.
Fix: Pass one on the upload. It is ignored for born-digital text, so it is safe to always send.
Cause: You used the ordinary download route, which needs a user session.
Fix: Use GET /hooks/{api_key_id}/files/{file_id} for an upload, or the exports endpoint for a generated file.
Causes to check: the key does not point at a generated output, or it was produced by a different API key. The exports endpoint requires proof that this key's execution produced it.
Cause: No Idempotency-Key header.
Fix: Send one. The same value replays the prior turn instead of running a new one.