Technical docs
Private API
Overview
Support reference for the organization-scoped private API under `/api/private`.
Auth
- Current auth is the organization private API key.
- The middleware accepts either `Authorization: Bearer <token>` or the raw key in the `api-key` header.
- Example:
```bash curl -H "Authorization: Bearer <TOKEN>" "https://api.alloy.cx/api/private/storage/" ```
Organization status and API access
When an organization's `status` is `inactive`, the private API blocks all mutating methods:
- `POST`, `PUT`, `PATCH`, and `DELETE` return `403` with `{ error: "Organization is inactive", code: "ORGANIZATION_INACTIVE" }`.
- Side-effecting `GET` endpoints (e.g. `GET /api/private/stargate/?system=...`) are also blocked.
- Read-only `GET` endpoints (storage listing, metadata, downloads) continue to work.
Current route groups
- `/api/private/storage`
- `/api/private/trigger`
- `/api/private/stargate`
- `/api/private/employee`
Storage routes
Base URL: `{api_url}/api/private/storage`
These routes expose full organization storage access.
Call these routes from a trusted external service or narrow gateway. Do not inject the private API key into a structured workflow's `Run Code` or tool parameters without user's approval.
| Method | Path | Query / Body | Notes |
|---|---|---|---|
| `GET` | `/` | `?path=` optional | Lists direct children of a folder. `path` defaults to `/`. Returns `404` with `{ success: false, error: "Storage path not found" }` when the folder does not exist or the path resolves to a file. A valid empty folder returns an empty list. |
| `POST` | `/upload` | `multipart/form-data` with optional `path` and required `file` part | Max file size is `10 MB`. The stored filename comes from the uploaded file's original filename. Omitting `path` uploads to storage root. Existing files are overwritten. |
| `GET` | `/meta` | `?path=` required | File-only metadata lookup. Returns `path`, `name`, `size`, `created_at`, `modified_at`, `hash`, and `mime_type`. Returns `400` when the path is a folder and `404` when the file is missing. |
| `DELETE` | `/file` | `?path=` required | Deletes a file or folder. Returns `400` for root deletion attempts. |
| `GET` | `/download` | `?path=` required | File-only download. Returns `400` when the path is a folder. |
| `POST` | `/folder` | JSON `{ name, path? }` | Creates a folder and returns its canonical relative path. |
| `POST` | `/rename` | JSON `{ path, new_name }` | Renames a file or folder in place. |
| `POST` | `/move` | JSON `{ source_path, dest_path }` | Moves a file or folder into the destination directory. `dest_path` is a directory path, not a full target filename. |
Storage response notes
- Successful storage mutations return `success: true` and usually a `data.path` field with the canonical relative path.
- Upload validation failures return `400`.
- Uploads above `10 MB` return `413`.
- Upload has no replace/skip flag. If the target file already exists, the server truncates and rewrites it, returns `200`, and emits `storage_file.updated`.
- Upload has no conflict status for existing files, no ETag / `If-Match` precondition, and no optimistic concurrency control. Clients should treat duplicate uploads as last-write-wins.
- `GET /storage/meta` prefers a cached document hash when one already exists in `organization_documents.file_hash`; otherwise it computes the hash from the current file contents on demand.
- `POST /storage/rename` and `POST /storage/move` return `409` on target-path conflicts.
- The current private storage API does not expose `copy`, `stats`, or folder-sharing endpoints.
Trigger route
Base URL: `{api_url}/api/private/trigger`
| Method | Path | Body | Notes |
|---|---|---|---|
| `POST` | `/{workflow_id}` | Arbitrary JSON payload | Starts the workflow from its configured starting step and returns the created run ID plus the immediate queue result. |
Public trigger URL format
- The public trigger route accepts `{api_url}/api/public/trigger/{publicApiKey}/{workflowId}`.
- The backend URL-encodes both dynamic path segments when generating those URLs.
- Newly generated organization `public_api_key` values are 32-character lowercase hex strings, which are safe to use as URL path segments.
Inbound trigger security
- The private trigger authenticates the caller with the organization private API key.
- The public trigger uses the key in its URL to identify the organization. Do not treat the URL key as proof that a request came from an expected third-party provider.
- Neither trigger provides generic verification for vendor signatures, request timestamps, nonces, or replayed events.
- Public and private trigger routes accept the parsed request body without enforcing the workflow's declared payload schema.
- When a provider signs its webhooks, verify the raw request body, signature, and timestamp in an ingress service or gateway. Forward a normalized payload to Alloy only after verification succeeds.
- Include the provider's stable event ID in the forwarded payload. Use it as a deduplication or idempotency key before causing side effects.
- Reject events outside the provider's allowed timestamp window. Keep signing secrets in the gateway or approved secret configuration, not in workflow payloads or launcher instructions.
- Immediate public and private trigger calls process the workflow synchronously before responding. A debounced public trigger is the exception because it records the event for later execution.
- For webhook providers with short acknowledgement deadlines, validate and durably queue the event in a gateway. Return the provider's expected success response before a worker invokes Alloy.
Trigger response notes
- Returns `404` when the workflow does not exist in the authenticated organization.
- Returns `404` when the workflow exists but has no `flow` or no `starting_step_id`.
- On success returns:
- `success: true`
- `data.runId`
- `data.result`
Employee message routes
Base URL: `{api_url}/api/private/employee`
These routes let a server-side integration connect a custom chat system to an Alloy AI teammate. For the full external developer guide, see `references/custom-channel-integration.md`.
| Method | Path | Query / Body | Notes |
|---|---|---|---|
| `POST` | `/{employee_id}/messages` | JSON body with required `message` and `channel`; optional all-or-nothing `conversation` and `contact`; optional `attachments[]` with base64 file data; optional `is_async` | Sends one user message to an AI teammate. Synchronous execution is the default; `is_async: true` queues the run for polling. With `conversation` and `contact`, the chat is saved and can continue later. Without them, the request is one-off and no chat history is saved. |
| `GET` | `/{employee_id}/messages` | `conversation_id`, `channel`, optional `page`, optional `limit` up to `50` | Lists saved messages for a custom chat conversation. Use the same `channel` and `conversation_id` values that were used when sending messages. |
| `PUT` | `/{employee_id}/messages/{message_id}/feedback` | JSON with required `channel`, `contact_id`, and nullable `reaction`; optional `feedback_comment` | Sets or clears feedback on a saved AI message. `reaction` is `like`, `dislike`, or `null`; `feedback_comment` is limited to 500 characters. |
Employee message request notes
- The `contact` object accepts optional `id`, `first_name`, `last_name`, `email`, and `phone` fields. Supplied fields update the contact channel profile when the saved chat is created or continued.
- `conversation` and `contact` remain all-or-nothing: sending only one returns `400`.
- `attachments` defaults to `[]` when omitted. Only image attachments are forwarded into workflow input data; all attachments stay on the saved message.
- `is_async` is optional and defaults to `false`. Set it to `true` when the integration should receive a run ID immediately and poll the private workflow-run endpoint instead of waiting for completion.
- Attachment items require `size` to be an integer of at least `1`. The server validates decoded base64 content size against the configured chat attachment limits (`CHAT_MAX_ATTACHMENTS`, `CHAT_MAX_ATTACHMENT_SIZE_MB`, `CHAT_MAX_TOTAL_ATTACHMENT_SIZE_MB`; defaults `10`, `10`, `30`).
- When attachments exceed those limits, `POST` returns `422` with error code `ATTACHMENT_LIMIT_EXCEEDED`, a `reason` of `MAX_ATTACHMENT_COUNT`, `MAX_ATTACHMENT_SIZE`, or `MAX_TOTAL_ATTACHMENT_SIZE`, the offending `files`, and the `limits` object.
Employee message response notes
- `POST /employee/{employee_id}/messages` returns `success: true`, `data.runId`, `data.message`, `data.messageId`, `data.assistantMessageId`, and `data.attachments`.
- With synchronous execution, `data.message` contains the immediate workflow result and `data.attachments` contains generated workflow output attachments. Each item is `{ name, mime_type, size, data }` with base64-encoded file content, including encrypted attachments.
- With `is_async: true`, `data.message` is `null` and `data.attachments` is `[]`; use `data.runId` with `GET /api/private/workflow/run/{run_id}` to retrieve status and the completed output.
- `data.messageId` is `null` for one-off requests where `conversation` and `contact` are omitted. A saved asynchronous chat still returns the persisted incoming message ID.
- `data.assistantMessageId` identifies the saved AI reply for a synchronous saved chat. It is `null` for asynchronous and one-off requests.
- `POST` returns `404` when the AI teammate does not exist in the authenticated organization.
- `POST` returns `422` when the AI teammate is not ready to process messages or when attachments exceed the configured chat attachment limits.
- `GET` returns message objects plus pagination metadata.
- `GET` returns `404` when no saved chat exists for the supplied `channel` and `conversation_id`.
- The feedback route verifies the message inside the requested AI teammate, channel conversation, contact, and participant scope. A missing or out-of-scope message returns `404` with `{ "error": "Message not found" }`.
Workflow-run polling route
Base URL: `{api_url}/api/private/workflow`
| Method | Path | Notes |
|---|---|---|
| `GET` | `/run/{run_id}` | Retrieves a workflow run in the authenticated organization. Returns `data.runId`, current `data.status`, saved incoming `data.messageId`, and saved AI reply `data.assistantMessageId`. While unfinished, `data.result` is `null`; when finished, `data.result` contains the run's last output and `data.attachments` contains generated output files. |
- A missing or cross-organization run returns `404` with `{ "error": "Workflow run not found" }`.
- Finished output attachments use the same `{ name, mime_type, size, data }` base64 shape as synchronous employee-message responses.
Stargate routes
Base URL: `{api_url}/api/private/stargate`
Stargate lets a gateway process poll Alloy for work routed to a named internal system, execute it against that internal system, and report the result back to Alloy. For the architecture and setup model, see `stargate.md`.
| Method | Path | Query / Body | Notes |
|---|---|---|---|
| `GET` | `/` | `?system=` required | Long-polls for up to `60` seconds for pending tasks for the named internal system. Returns early when tasks are available; otherwise returns `success: true, data: []`. |
| `POST` | `/{task_id}` | Any request body | Records the posted body as the task response and flips the task from `requested` to `responded`. The current public Stargate gateway posts a JSON envelope with `ok`, `status`, `body`, and optional `error`, but the Alloy route itself does not enforce a response schema. |
Stargate response notes
- The gateway uses the same `api-key` authentication as the rest of the private API.
- `system` is a routing key. A gateway should only receive tasks for the internal system name it passes in the poll request.
- Returned tasks include `stargate_task_id`, `organization_id`, `system`, `method`, `uri`, `body`, `response`, `status`, `created_at`, and `updated_at`.
- Task statuses currently move through `created`, `requested`, `responded`, `completed`, or `failed`.
- The `GET` route checks for new work every `5` seconds during the long-poll window.
- The `POST` route currently accepts JSON, plain text, raw bytes, or empty bodies and stores the payload as text.
- `POST /{task_id}` returns `404` when the task is missing.
- `POST /{task_id}` returns `400` when the task is not currently in `requested` status.