# API reference The API lives on the api host, for example `https://api.artifactsandhandoffs.com`, and speaks JSON in and out. Start with [auth.md](auth.md), which walks through registering a key and the first publish; this file is the reference for every endpoint. **Authentication.** Every endpoint except `POST /system/register` needs the API key: ``` Authorization: Bearer ``` A key that has not been claimed by a human yet answers `403 key_pending`; a revoked key answers `401 key_revoked`; a missing or unknown key answers `401 unauthorized`. **Requests.** Send `Content-Type: application/json` with a JSON object body, except for the version upload, which is `multipart/form-data`. Query parameters work as well for simple fields. String lengths are counted in characters. **Responses.** `200` on success, `201` on create, `204` on delete. Lists answer `{"items": [...]}` and are not paginated. Timestamps are ISO 8601 with a timezone, for example `2026-09-12T14:03:05+00:00`. Every id is a UUID. **Errors.** `{"error": "code", "message": "text"}` with a matching HTTP status, plus extra keys for some codes (`field` on `validation`, `used`/`max` on `quota_exceeded`, `max_days` on `expiry_too_long`, `errors` on `invalid_schema`). **Rules that hold everywhere.** - Everything is bound to the account the key belongs to. An id belonging to another account answers `404 not_found`, never `403`. - An id that is not a well-formed UUID also answers `404 not_found`, except project references, which accept either a UUID or an account-scoped slug. - Exceeding the account's storage quota answers `413 quota_exceeded` with `used` and `max` in bytes. - Asking for a longer expiry than the account allows answers `422 expiry_too_long` with `max_days`. - Every write is recorded in the audit log with the key's name, and shows up for the humans in `/admin/`. - `POST /system/register` is rate limited per client address: `429 rate_limited`. - A `HEAD` request is answered like the `GET` on the same path, headers and all, but with no body. A path that has no `GET` route answers `405 method_not_allowed`. ## Endpoints ### POST /system/register Registers a new API key for an agent. Returns the key and a claim URL a human must open within 15 minutes. - **Body:** name (string, required, max 100), email (string, optional, max 200). - **Returns:** key, claim_url, expires_at. - **Errors:** 403 registration_disabled, 422 validation, 429 rate_limited. ### GET /v1/me Returns the account and API key behind the current bearer token. - **Returns:** account (id, slug, name, quota: {used, max}), key (id, name, status, scope, last_used_at). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending. "scope" says what this key may do: "full" publishes and changes artifacts and forms, "artifacts" leaves forms read-only, and "read" refuses every write with 403 key_scope. ### GET /v1/projects Lists the projects in the caller's account. - **Returns:** items (list of project). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending. ### POST /v1/projects Creates a project; the slug is derived from the name when not given. - **Body:** name (string, required, max 200), description (string, optional, max 2000). - **Returns:** the created project. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 409 slug_taken, 422 validation. ### GET /v1/projects/{id} Returns a single project by UUID or slug. - **Returns:** the project. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### PATCH /v1/projects/{id} Updates a project's name and/or description, addressed by UUID or slug. - **Body:** name (string, optional, max 200), description (string, optional, max 2000). - **Returns:** the updated project. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 422 validation. ### DELETE /v1/projects/{id} Deletes a project by UUID or slug and everything under it, including the files of its artifacts. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### GET /v1/groups Lists the groups in the caller's account. - **Returns:** items (list of group). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending. ### POST /v1/groups Creates a group of recipients, optionally scoped to a set of projects. - **Body:** name (string, required, max 200), description (string, optional, max 2000), webhook_url (string, optional, http/https URL that must resolve to a public address), members_hidden (bool, optional, default false), projects (list of project ids, optional), members (list of {name, email}, optional). - **Returns:** the created group. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found (unknown project id), 422 validation. ### GET /v1/groups/{id} Returns a single group. - **Returns:** the group. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### PATCH /v1/groups/{id} Updates a group's name, description, webhook URL or members_hidden flag. - **Body:** name, description, webhook_url, members_hidden — all optional. - **Returns:** the updated group. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 422 validation. ### DELETE /v1/groups/{id} Deletes a group. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### PUT /v1/groups/{id}/members Replaces a group's whole member list; disabled while the group hides its members. - **Body:** members (list of {name, email}), required. - **Returns:** the updated group. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 403 members_hidden, 404 not_found, 422 validation. ### GET /v1/artifacts Lists the artifacts in the caller's account, optionally scoped to one project (slug or id). - **Returns:** items (list of artifact). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found (unknown project). ### POST /v1/artifacts Creates an artifact under a project; no files yet, use POST .../versions for that. - **Body:** project (string, required: slug or id), title (string, required, max 200), visibility (enum public|private, default private), access (enum token|shared, default token), expires_in_days (int, optional), hits_enabled (bool, default true), info_enabled (bool, default true), votes (enum off|thumbs|1-5|0-10, default off). - **Returns:** the created artifact; adds shared_key (shown once) when access is shared. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found (unknown project), 422 validation, 422 expiry_too_long. ### GET /v1/artifacts/{id} Returns a single artifact. - **Returns:** the artifact. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### PATCH /v1/artifacts/{id} Updates an artifact's metadata; the project cannot change and this never touches files. - **Body:** title, visibility, access, expires_in_days, hits_enabled, info_enabled, votes — all optional. - **Returns:** the updated artifact; adds shared_key (shown once) when access just changed to shared. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 422 validation, 422 expiry_too_long. ### DELETE /v1/artifacts/{id} Deletes an artifact (hides it and removes its files). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### POST /v1/artifacts/{id}/versions Uploads a new version's files; any field name works, and each file's client-side name is its relative path inside the version (e.g. index.html, css/site.css). Must include index.html. - **Returns:** number, size_bytes, created_at, preview_url. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 413 quota_exceeded, 422 upload_failed, 422 file_not_allowed, 422 index_missing. ### POST /v1/artifacts/{id}/versions/{n}/finalize Finalizes a version: locks it and sends its recipients (from groups and/or a direct list) a personal link by mail, plus one webhook per group that has one. - **Body:** groups (list of group ids, optional), recipients (list of {name?, email}, optional). - **Returns:** version, url, links (list of {name, email, url}, shown once here), notified ({mails, failed, webhooks}). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 409 already_finalized, 422 validation, 422 group_not_in_project. ### GET /v1/artifacts/{id}/recipients Lists everyone a version of this artifact has been sent to, without tokens. - **Returns:** items (list of {id, name, email, group, version, first_opened_at, created_at}). A recipient who came from a group with members_hidden has name and email as null; the group name, the version and whether they opened it are still there. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### GET /v1/artifacts/{id}/votes Lists the vote distribution for an artifact, plus per-recipient votes when access is by personal link (token). - **Returns:** scale, min, max, total, counts, average, and per_recipient (list of {name, email, value, created_at}) when access = token. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 404 disabled (votes = off). ### GET /v1/forms Lists the forms in the caller's account, optionally scoped to one project (slug or id). - **Returns:** items (list of form). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found (unknown project). ### POST /v1/forms Creates a form under a project. - **Body:** project (string, required: slug or id), title (string, required, max 200), schema (object, required), artifact_id (string, optional: an artifact of the same account and project), multiple_allowed (bool, default false), anonymous_allowed (bool, default false), send_copy (bool, default false: mail a recipient their own answers after they submit), visibility (enum public|private, default private), access (enum token|shared, default token), expires_in_days (int, optional). - **Returns:** the created form; adds shared_key (shown once) when access is shared. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found (unknown project), 422 validation, 422 invalid_schema, 422 artifact_not_found, 422 expiry_too_long. ### GET /v1/forms/{id} Returns a single form. - **Returns:** the form. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### PATCH /v1/forms/{id} Updates a form's metadata; the project cannot change. - **Body:** title, schema, artifact_id, multiple_allowed, anonymous_allowed, send_copy, visibility, access, expires_in_days — all optional. - **Returns:** the updated form; adds shared_key (shown once) when access just changed to shared. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 409 schema_locked, 422 validation, 422 invalid_schema, 422 artifact_not_found, 422 expiry_too_long. ### DELETE /v1/forms/{id} Deletes a form (hides it; existing submissions are kept). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found. ### POST /v1/forms/{id}/publish Publishes a form: locks it and sends its recipients (from groups and/or a direct list) a personal link by mail, plus one webhook per group that has one. - **Body:** groups (list of group ids, optional), recipients (list of {name?, email}, optional). - **Returns:** url, links (list of {name, email, url}, shown once here), notified ({mails, failed, webhooks}). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 409 already_published, 422 validation, 422 group_not_in_project. ### GET /v1/forms/{id}/submissions.csv Same rows as `GET /v1/forms/{id}/submissions`, as a CSV file: submitted_at, recipient_name, recipient_email, then one column per schema field. - **Query:** since (ISO 8601 timestamp, optional). - **Returns:** a text/csv attachment named form-{id}.csv. An answer from a recipient in a group with members_hidden has empty recipient_name and recipient_email cells. - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 422 validation. ### GET /v1/forms/{id}/submissions Lists every answer to a form, oldest first: submission order. - **Query:** since (ISO 8601 timestamp, optional): only submissions strictly after it. - **Returns:** items (list of {id, submitted_at, recipient_name, recipient_email, data}). - **Errors:** 401 unauthorized, 401 key_revoked, 403 key_pending, 404 not_found, 422 validation. ## Artifact host endpoints Artifacts and default-rendered forms live on the artifact host, one subdomain per account: `https://{account}.artifactsandhandoffs.link/{artifact}/`. In path mode every path below is prefixed with `/{account}` on a single shared host instead. The pages a person opens are `/{artifact}/` (newest finalized version, in a frame with a version bar), `/{artifact}/v/{n}/` (one specific version), `/{artifact}/preview/{n}/{sig}/` (an unfinalized version through the signed `preview_url`, valid one hour, not counted) and `/f/{form}/` (the default form renderer). A personal link points at `/system/view/{token}`, which records the first open, sets the `ah_access` cookie and redirects to the artifact or form. An expired page answers `410`, a page reached without a personal link or the shared key answers `403`. The artifact's own HTML may call the endpoints below from inside the iframe. They share the artifact's origin, so no CORS is involved, they run the same access check as viewing the artifact, and they answer JSON with `Cache-Control: no-store`. ### GET /system/info/{artifact} The published versions of the artifact: `{version, versions, finalized_at, expires_at}`, where `version` is the newest finalized version number and `versions` lists every finalized one. Answers `404 disabled` when the artifact was created with `info_enabled: false`. ### GET /system/hit/{artifact} Increments the artifact's view counter and answers `{"hits": n}`, or a 1x1 transparent GIF when the client's `Accept` header prefers an image (so `` works as a page-view pixel). Answers `404 disabled` when `hits_enabled` is false. The same counter (`view_count` on the artifact) also goes up on every frame-page load, previews excluded, whether or not `hits_enabled` is set, so call this at most once per page load. ### GET, POST /system/vote/{artifact} `GET` answers the distribution plus the caller's own vote: `{scale, min, max, total, counts, average, own}`. `POST` takes `{"value": n}`, checked against the artifact's `votes` scale (`thumbs`, `1-5` or `0-10`), then answers the same shape. The voter is the recipient of the personal link when there is one, otherwise the `ah_visitor` cookie, which the endpoint mints on first use; one vote per identity, and voting again replaces it. Answers `404 disabled` when `votes` is `off`. No CSRF token is needed: same origin, `SameSite=Lax` cookie, idempotent per identity. ### GET /system/form/{form} `{csrf, schema}` for a custom-designed form: the schema the agent submitted, so the page can read the field names, and a CSRF token for the submission below. Answers `404 not_found` until the form is published. ### POST /system/form/{form} Submits a form. Send the answers as `{name: value, ..., "csrf": ""}` with `Content-Type: application/json` (or the token in an `X-CSRF-Token` header), and `Accept: application/json` to get JSON back: `{"ok": true}` on success, `422 {"error": "validation", "fields": {name: message}}` when a field is wrong. Other callers get a redirect to the artifact with `?submitted=1`, or a plain error page. Identity, the one-answer and anonymous rules, and the field validation are described in [the form schema guide](https://api.artifactsandhandoffs.com/docs/forms.md). ### GET /system/status `{"ok": true}`. For monitoring; no access check. In path mode this also works without an account prefix, directly at `/system/status`. ### GET /f/{form}/ The default renderer: one schema page at a time, plain HTML, `?page=` in the URL, partial answers kept in an `HttpOnly` cookie between pages. A form with `artifact_id` set redirects here to its artifact instead, which is where its custom design lives.