# The Alty API — integration guide

This guide is written to be handed, whole, to a developer or to an AI coding agent such as
Claude. It is the complete contract: if something is not in here or in the OpenAPI
document, the API does not do it. Do not guess endpoints, fields or scopes; section 9 lists
what the API cannot do.

- **Guide (this file):** `https://www.alty.one/developers/guide.md`, and on every instance at `https://<slug>.alty.one/api/public/v1/guide.md`
- **OpenAPI 3.1:** `https://www.alty.one/developers/openapi.json`, and on every instance at `https://<slug>.alty.one/api/public/v1/openapi.json`
- **Index for agents:** `https://www.alty.one/llms.txt`

All are public and need no key. The copies on an instance are the same bytes as the ones
on www.alty.one: they describe the API, not your data.

## 1. Where your API lives

Every Alty business ("instance") has its own host. Its API is under `/api/public/v1`:

```
https://<slug>.alty.one/api/public/v1
```

`<slug>` is the instance's subdomain: the part before `.alty.one` in the address the
business signs in at (`https://acme.alty.one` → `acme`). There is no shared API host, and a
key only works on the instance that issued it. An unknown host answers `404 Not found`
(plain text) to every path.

Request and response bodies are JSON (`Content-Type: application/json`) unless an endpoint
says otherwise (a few return a PDF or a file). Times on records and events are ISO-8601 UTC
strings; a few fields say epoch **milliseconds** instead (a record's `version`, a key's
`expires_at`, the older desk and intake planes), and each says so where it appears. Money is
integer **cents**.

## 2. Authentication

Send an Alty API key on every request:

```
Authorization: Bearer altyk_…
```

(`x-api-key: altyk_…` is accepted too.)

**Send a `User-Agent` header** naming your integration, such as
`User-Agent: acme-website/1.0`. Alty's network edge refuses a few bare library defaults
before the request reaches Alty: Python's built-in `urllib` (`Python-urllib/3.x`) and Perl's
`libwww-perl` get `403` with the plain-text body `error code: 1010`, whatever the key. Most
HTTP clients (`requests`, `httpx`, Node's `fetch`, `curl`) are fine as they are, but a named
`User-Agent` also lets Alty support find your calls.

**Getting a key.** An admin of the instance creates it in **Admin › API**. The page asks
"What are you connecting?" and ticks the scopes that connection usually needs (the presets
in section 4); the admin can untick any of them, or tick others. The key is shown **once**; Alty keeps only a hash of it and cannot show it again. Each key has
a label, its scopes, optionally a service account (below) and optionally an expiry date
(the default is never).

- **API access must be switched on** for the instance. It is off by default, and only Alty
  support can switch it on; until they do, every call answers `401`. If Admin › API says
  API access is off, ask Alty support to switch it on.
- **Keep the key on a server.** Put it in an environment variable named `ALTY_API_KEY`.
  Never put it in browser code, a mobile app, a repository, a log line or a ticket. Every
  endpoint here is server-to-server: several of them take the caller's word for who a
  person is.
- **Service accounts.** A key that writes as somebody (working the desk, writing records) must
  be bound to a service account: a named, non-human member of the instance that its
  changes are credited to, such as "Website (Acme site)". Admin › API creates or picks one
  when the preset needs it.
- **Changing a key.** An admin can change a live key's scopes, its label and its expiry in
  Admin › API, without changing the key itself. A scope taken away applies on the key's
  **next request**: a call that needs it gets `403 missing_scope`. Giving a key more access
  (a scope added, or a later expiry) needs the admin to confirm it. Only an admin signed in
  to Alty can change a key; no key can change its own scopes. A revoked or expired key
  cannot be changed: create a new one.
- **Rotation.** A label can have two live keys at once: create the new key, deploy it,
  then revoke the old one in Admin › API. A revoked or expired key stops working
  immediately. Admins are notified 14 days before a key expires.
- **Admins are told.** Creating, rotating or changing a key (or creating a service account)
  notifies every other admin of the instance, in Alty and by email, naming who did it, the
  label, the scopes (for a change: the scopes added and removed) and the expiry; the admin
  who did it gets an email copy. The key itself is never
  in any notice.
- **HTTPS only.** A call over `http://` is refused with `403 https_required` before the key
  is checked. The key has still crossed the network unencrypted: rotate it. The instance's
  admins are alerted that it happened.

**Key format.** Keys made since October 2026 look like this:

```
altyk_ + 43 characters [0-9A-Za-z] (random, ≥ 256 bits) + 6 characters [0-9A-Za-z] (checksum)
```

55 characters in all, matching the regular expression `\baltyk_[0-9A-Za-z]{49}\b`. The last
six characters are a CRC-32 of the 43 random ones, written in base62 (digits, then
`A–Z`, then `a–z`) and left-padded with `0`, so a secret scanner can tell a real key from
a look-alike string without calling Alty. Treat a key as an opaque string all the same:
older keys (`altyk_` or `altysd_` followed by 43 characters that may include `-` and `_`)
keep working unchanged.

## 3. Your first call: `whoami`

Any live key may call it, whatever its scopes. Make it first: it proves the key, the host
and the header are right before you build anything else.

```sh
curl -sS https://<slug>.alty.one/api/public/v1/whoami \
  -H "Authorization: Bearer $ALTY_API_KEY"
```

```json
{
  "tenant": "acme",
  "key": { "label": "Acme site", "scopes": ["enquiry:intake"], "expires_at": null },
  "service_account": { "id": "…", "name": "Website (Acme site)" },
  "modules": ["core"]
}
```

- `key.scopes` is everything this key may do. Each endpoint in section 10 names the one
  scope it needs.
- `modules` lists which of the modules your scopes belong to are switched on. If the module
  you need is missing, its endpoints will answer `403 module_off`.
- `expires_at` is epoch milliseconds, or `null` for a key that never expires.
- A `401` means the key is wrong, revoked or expired, or API access is off for the
  instance. The answer is deliberately the same in every case.

## 4. Scopes

A scope is `<module>:<role>`. A key holds a list of them, chosen when it is created. An
admin can change that list later (section 2, "Changing a key"); a scope removed from a key
answers `403 missing_scope` from its next request. There are no wildcard scopes. Call
`whoami` to see the scopes a key holds now.

<!-- generated:scopes -->
| Scope | What it lets a key do | Module | Needs a service account | Endpoints |
|---|---|---|---|---|
| `support:requester` | raise and follow its own users’ tickets | `support` | no | 5 |
| `support:agent` | work the whole desk queue (staff-grade) | `support` | yes | 15 |
| `enquiry:intake` | let your website post leads and discovery answers, and answer proposals | `core` | no | 8 |
| `mcp:install` | install the MCP client from this instance — download only, no desk access | `mcp` | no | 0 |
| `customers:read` | read customers, their contacts and sites | `core`, `sites` | no | 5 |
| `customers:write` | create, change and delete customers, their contacts and sites (deletes go to the Trash) | `core`, `sites` | yes | 9 |
| `leads:read` | read leads | `enquiries` | no | 2 |
| `leads:write` | create, change and delete leads (deletes go to the Trash) | `enquiries` | yes | 3 |
| `projects:read` | read projects | `projects` | no | 2 |
| `projects:write` | create, change and delete projects (deletes go to the Trash) | `projects` | yes | 3 |
| `quotes:read` | read quotes and their lines | `quotes` | no | 2 |
| `invoices:read` | read invoices, their lines and what is still owed | `invoices` | no | 2 |
| `notes:read` | read notes on customers, leads and projects it can read | `core`, `enquiries`, `projects` | no | 3 |
| `notes:write` | add notes to customers, leads and projects | `core`, `enquiries`, `projects` | yes | 3 |
| `members:read` | see who is on the team (names, emails, roles), to assign work | `core` | no | 1 |
| `webhooks:manage` | register its own webhook receivers and read their delivery log — events only for records it can also read | `webhooks` | no | 10 |
| `actions:read` | read Intelligent Actions, their run log, and preview a run (sends nothing) — never a credential | `actions` | no | 4 |
| `actions:run` | run an action an admin has switched on, as staff — never an admins-only action, never a test run | `actions` | no | 1 |
| `actions:write` | draft actions (always saved off), switch one off, delete its own never-run drafts — never switch one on or set a credential | `actions` | no | 4 |

`whoami` needs no scope: any live key may call it. A scope's endpoints also need its module switched on for the instance.
<!-- /generated:scopes -->

`mcp:install` is for npm only: it goes in the `.npmrc` that installs the Alty MCP client, and
opens no endpoint in this guide.

**Presets.** Admin › API offers these as a starting point, so the admin never has to know
scope names: each one ticks its scopes, and the admin may untick any or tick others.

<!-- generated:presets -->
| What you are connecting | Scopes | Service account | Status | Recipe (section 8) |
|---|---|---|---|---|
| **Website form** — Your website posts enquiries and discovery answers as leads, and answers proposals. | `enquiry:intake` | no | available | "Post a website lead", "Host proposals and the discovery form on your website" |
| **Support desk app** — Another product raises tickets for its users and follows their replies. | `support:requester` | no | available | "Raise a ticket" |
| **Support agent or AI desk** — An automation works the desk queue: raises tickets, replies, notes, status and assignment. | `support:agent` | required | available | "Work the desk queue" |
| **Sync customers and jobs** — Keep another app’s customers, leads and projects in step with Alty, both ways. | `customers:read`, `customers:write`, `projects:read`, `projects:write`, `leads:read`, `leads:write`, `notes:read`, `notes:write`, `members:read` | required | available | "Sync customers and jobs" |
| **Read-only reporting** — A dashboard, wallboard or report reads customers, leads, projects, quotes and invoices. It can change nothing. | `customers:read`, `leads:read`, `projects:read`, `quotes:read`, `invoices:read`, `notes:read`, `members:read` | no | available | "Read records for reporting" |
| **Receive events** — Alty tells your app when a customer, lead, project, quote, invoice or note changes. Your app registers its own receiver. | `webhooks:manage`, `customers:read`, `leads:read`, `projects:read`, `quotes:read`, `invoices:read`, `notes:read` | no | available | "Receive events" |
| **AI agent: Intelligent Actions** — An AI agent reads your actions and their run log, runs the ones an admin has switched on, and drafts new ones for an admin to review. It can never switch one on or see a credential. | `actions:read`, `actions:run`, `actions:write`, `leads:read`, `customers:read`, `projects:read` | required | available | "Use Intelligent Actions from an agent" |
| **Custom** — Choose the scopes yourself. | any, ticked by hand | no | available | — |
<!-- /generated:presets -->

## 5. Errors

Every refusal from Alty is JSON with an `error` field. Read the status first, then `error`.

| Status | `error` | What it means | What to do |
|---|---|---|---|
| 400 | words naming the field | The body is invalid: a missing or malformed field. | Fix the request. Do not retry it unchanged. |
| 400 | `unknown_field` / `invalid_field` | Records writes: a field the endpoint does not take (often a read-only one such as `number`), or a bad value. `field` names it and `message` says why. | Fix the body. |
| 400 | `invalid_idempotency_key` | The `Idempotency-Key` header is not 1–255 visible ASCII characters. | Send a valid value, or none. |
| 401 | `unauthorized` | No key, a wrong, revoked or expired key, or API access off for the instance. Always this exact body. | Check the key, the header and the host with `whoami`; ask the admin whether the key is live and API access is on. |
| 403 | none: plain text `error code: 1010` | Alty's network edge refused the request's `User-Agent` before it reached Alty (Python's `urllib` and Perl's `libwww-perl` defaults). Says nothing about the key. | Send a `User-Agent` naming your integration (section 2), then retry. |
| 403 | `https_required` | The call was made over plain `http://`. The API is HTTPS only, and this is checked before the key is read. | Call `https://`. Treat the key as exposed and ask the admin to rotate it. |
| 403 | `missing_scope` | The key is valid but lacks the scope this endpoint needs. `needs` names it. | Ask the admin to add that scope to the key (Admin › API › Edit). If it worked before, an admin may have removed it. |
| 403 | `module_off` | The key is valid but the module (or its API channel, such as the support desk's API switch) is off for this instance. `module` names it. | Ask the admin to switch it on. |
| 403 | `module_not_in_use` | The instance's admin has marked that part of Alty (e.g. leads) as not in use. `module` names it. | Ask the admin. |
| 403 | words | Agent planes only: the key is not bound to an active service account. | Ask the admin to bind the key to a service account. |
| 403 | `service_account_required` | Records API: a write from a key not bound to a service account, or any call from a key whose service account has been suspended or deleted. | Ask the admin to reactivate the service account, or for a key made with the **Sync customers and jobs** preset. |
| 404 | words | Not found. The same answer whether it does not exist or is not yours to see. | Check the reference or id. |
| 409 | `idempotency_conflict` | This `Idempotency-Key` was already used with a different request. | Use a new key for a new request. |
| 409 | `idempotency_in_progress` | The first request with this `Idempotency-Key` is still running. Comes with `Retry-After: 1`. | Wait, then retry with the same key. |
| 409 | words | The thing already happened: a proposal already accepted or declined, a lead that already has a discovery. | Do not retry; read the current state. |
| 409 | `external_id_taken` / `in_trash` | Records writes: another of your records already holds that `external_id`, or the record holding it is in the Trash. | Look the id up (`?external_id=`); see "Writing records". |
| 410 | words | The link or proposal has expired. | Do not retry. |
| 412 | `version_conflict` | Records writes: the record changed since the `version` you sent in `If-Match`. Nothing was written. `current` is its version now. | Read it again, merge, and retry with the new version. |
| 413 | words | A file is too large (the discovery summary PDF: 2 MB). | Send a smaller file. |
| 429 | `rate_limited` | This key is over its rate limit (section 6), or your address made too many calls with invalid keys to this instance in the last minute. Comes with `Retry-After: 60`. | Wait `Retry-After` seconds, then retry. If it keeps happening, check the key with `whoami` — something at your address is sending a wrong one. |
| 429 | words | A per-instance limit: website intake (60 writes a minute across all keys), or desk attachments. | Wait a minute, then retry. |
| 5xx | varies | Alty or a service it depends on had a problem. | Retry with backoff, with the same `Idempotency-Key`. |

**Order of checks.** `403 https_required` comes first, then the failed-attempt `429` (below).
Before your key is proven valid, you otherwise only ever get the uniform `401`. After it is
proven, you may get `429`, then `403 module_off`, then `403 missing_scope`, then whatever
the endpoint itself answers.

**Request ids.** Every response, refusals included, carries an `x-request-id` header (a
26-character ULID). Log it. When you ask Alty support about a request, quote it: it is how
the request is found. Alty logs it with the key's id, never with the key.

## 6. Limits

- **About 120 requests a minute per key.** Over the limit: `429 rate_limited` with
  `Retry-After: 60`. The limit is counted per network location, so treat the number as
  approximate, not as a guarantee. Another key on the same instance is not affected by
  yours.
- **Failed attempts:** more than about 30 calls a minute with a missing, wrong, revoked or
  expired key from one address (an IPv6 /64 counts as one address) to one instance, and calls from there to that instance
  answer `429 rate_limited` (`Retry-After: 60`) for the next minute. A key that has been used
  successfully on that instance in the last 10 minutes still gets through, so an integration
  that is already running is not locked out by someone else at the same address. A key that
  has not been used there recently waits out the minute. The instance's admins are alerted.
- **Admins are alerted** (at most once an hour each) when a key hits its rate limit, when a
  key collects more than about 20 `403`s in a minute, and when a key is sent over `http://`.
- **Website intake:** at most 60 writes a minute per instance, shared by every key.
- **Lists** on the desk take `limit` (and `offset` where the endpoint says so) and return
  `total`, the count for the filter regardless of paging. The records endpoints page
  differently, by cursor: see "Reading records" in section 8.

## 7. Retries and `Idempotency-Key`

Every keyed `POST` accepts an optional `Idempotency-Key` header. Send one on every POST
that creates or changes something, so a retry after a timeout never does the work twice.

```
Idempotency-Key: 6f1c2b0e-website-lead-2026-10-22T09:14:03Z
```

- A value is 1–255 visible ASCII characters. A UUID per logical request is ideal.
- It is remembered **per key, for 24 hours**.
- Retry the **same request** (same method, same URL, same body bytes) with the same value
  and you get the first response back: its **original status code and body** (a `201`
  stays a `201`, with the same `enquiry_id`), plus the header `idempotent-replayed: true`.
  Nothing happens twice. Use the same URL: `/api/public/v1/…` and an older unversioned path are
  different requests.
- The same value with a **different** request: `409 idempotency_conflict`. "Same body"
  means the same bytes, not the same JSON: serialise the body once and resend that exact
  string, because re-serialising can reorder keys or escape characters differently (an em
  dash becomes `\u2014` in some encoders) and count as different.
- While the first request is still running: `409 idempotency_in_progress` with
  `Retry-After: 1`.
- Every endpoint that takes the header lists these `409`s, the header's own `400
  invalid_idempotency_key` and the `5XX` range in the OpenAPI document.
- **5xx and 429 answers are not remembered**, so retrying those really runs again. Every
  other answer, including a 4xx refusal, is replayed. After you fix a refused request, send
  it with a **new** value.
- A response larger than 64 KB, or one that is not JSON or text (a PDF), is not
  remembered: a retry runs again.
- A signing secret is never replayed. A replayed webhook create or `rotate-secret` answer
  carries `"secret": null` and `"secret_omitted": true`: the subscription id is there, and a
  lost secret means rotating it.

**When to retry:** a network error, a timeout, a `5xx`, a `429` (after `Retry-After`), or
`409 idempotency_in_progress`. Use exponential backoff with jitter and give up after a few
attempts. **Never retry** a `400`, `401`, `403`, `404`, `409 idempotency_conflict` or `410`
unchanged: the answer will not change.

Some endpoints also deduplicate on a field of their own, for example the support desk's
`message_id`, or a records create's `external_id`. Use both.

`PATCH` and `DELETE` on records take no `Idempotency-Key`: send the same PATCH twice and the
second changes nothing; a repeated DELETE answers `404`. Use `If-Match` to keep a retry
from overwriting someone else's newer edit.

## 8. Recipes

Each recipe uses only endpoints that exist today. `$ALTY_API_KEY` is the key, read from the
environment; `<slug>` is the instance.

### Post a website lead

Preset: **Website form** (scope `enquiry:intake`). Your website's **server** receives the
form and posts it to Alty. Never call Alty from the visitor's browser: the key would be
public.

```sh
curl -sS -X POST https://<slug>.alty.one/api/public/v1/intake/enquiries \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "subject": "Quote for a new deck",
    "contact_name": "Pat Lee",
    "contact_email": "pat@example.com",
    "contact_phone": "0400 000 000",
    "company_name": "Lee Builders",
    "message": "Can you quote a 20 m² hardwood deck?"
  }'
```

`201` → `{"enquiry_id": "…", "enquiry_number": "ENQ-042"}`. The lead appears in the
instance's Leads at once, with source **Website** (where the instance keeps that source).

- **`subject` is the lead's title**: it is what the lead is called in the Leads list and at
  the top of the lead's page. For a plain contact form with no subject field, send a fixed
  string such as `"Website enquiry"` or the form's name (`"Contact us"`), so the business can
  tell its forms apart. `message` is kept as the lead's notes.
- **Fields and limits.** Required: `subject`, `contact_name`, `contact_email`, `message`.
  Every text field is trimmed first; a required field that is then empty, or any field over
  its limit, is `400` with `error` naming the field. Limits: `subject`, `contact_name`,
  `company_name` and `service_type` 200 characters; `contact_email` 254, and it must look
  like an email address (`something@domain.tld`); `contact_phone` 40; `message` 20,000;
  `country` 2 (ISO 3166 alpha-2, stored upper-case). A `service_type` that is not one of the
  instance's service type keys is ignored, never a reason to lose the lead. Nothing else is
  validated: phone numbers and names are stored as sent.
- **Confirming it landed.** The `201` body is the contract: once you have it, the lead
  exists. Log `enquiry_id` and `enquiry_number` with your form submission. A `4xx` means no
  lead was made. After a timeout, retry with the same `Idempotency-Key`: if the first
  request did make the lead, you get its original `201` back instead of a second lead.
  After a `5xx`, retry the same way (section 7). To check by hand, find
  the `enquiry_number` in Leads; a key that also holds `leads:read` can
  `GET /api/public/v1/leads/{id}` with the `enquiry_id`. A Website form key does not, so it
  cannot read the lead back: the `201` (or its replay) is the proof.
- **Alty sends the visitor nothing and runs no spam check** on this endpoint, beyond the
  per-instance rate limit below. If your form needs a confirmation email or a captcha, your
  server does it before calling Alty.
- **Reference numbers are display strings.** `enquiry_number` (and every other `…_number`
  or `reference`) is the instance's own reference, shown to people. Its prefix and width are
  the instance's and can change: store it and show it, never parse it or build one. Use
  `enquiry_id` to refer to the lead in later calls.
- `403 module_not_in_use` means the business has switched Leads off.
- `429` with words, not `rate_limited`, is the per-instance intake limit: wait a minute.
- Generate the `Idempotency-Key` once per form submission and reuse it on every retry of
  that submission, so a double-click or a retry makes one lead. Serialise the body once and
  resend those exact bytes (section 7).

### Host proposals and the discovery form on your website

Preset: **Website form** (scope `enquiry:intake`), the same key as your lead form. Only if
the business wants these pages on **its own** website: by default, Alty hosts the proposal
page itself and you need none of this.

A **proposal** is a quote the business sends a customer from Alty, as a link carrying a
token. In **Admin › Records › Leads & proposals**, the business sets its website address and
chooses where customers open proposals. With **On my website**, the link is
`<website>/proposal?token=<token>`, and your site's server answers it:

```sh
# Show it: the lines, totals (cents), terms and whether it can still be answered
curl -sS https://<slug>.alty.one/api/public/v1/proposals/<token> \
  -H "Authorization: Bearer $ALTY_API_KEY"

# The customer accepts…
curl -sS -X POST https://<slug>.alty.one/api/public/v1/proposals/<token>/accept \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

# …or declines, with a reason
curl -sS -X POST https://<slug>.alty.one/api/public/v1/proposals/<token>/decline \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"reason": "TIMING", "detail": "Next financial year"}'
```

- Show the accept and decline buttons only when `superseded`, `expired` and
  `already_responded` are all `false`. A newer version sent, or an answer already given, is
  `409`; a lapsed link is `410`.
- `reason` is one of `PRICE`, `FOUND_ANOTHER`, `TIMING`, `DIFFERENT_DIRECTION`, `OTHER`;
  `detail` is optional, at most 500 characters. The answer moves the lead on and tells the
  business.
- `GET /api/public/v1/proposals/{token}/pdf` is the proposal as a PDF, for a download link.
- The token is the customer's only credential: take it from the link, pass it through, and
  never log it beside their name.

The **discovery form** works the same way: the business sends a lead a link to
`<website>/discover?enquiry=<enquiry_id>`. `GET /api/public/v1/intake/enquiries/{id}/prefill`
fills in the contact details (and records that the form was opened; `410` when no link was
sent, or once it is over 30 days old), `POST /api/public/v1/intake/discoveries` with that `enquiry_id` stores the
answers on the lead, and `POST /api/public/v1/intake/discoveries/{id}/summary-pdf` attaches a
PDF summary of at most 2 MB. Without an `enquiry_id`, a discovery creates a new lead.

### Raise a ticket

Preset: **Support desk app** (scope `support:requester`). Another product raises tickets
for its own signed-in users and shows them their tickets and replies. Your server asserts
who the user is, so take `requester_email` from **your own authenticated session**, never
from anything the browser sent.

An automation that **works** the desk (an AI agent, a triage bot) does not need this preset to
open tickets: its `support:agent` key raises them itself (see Work the desk queue). This one is
for raising tickets on behalf of your own product's users.

```sh
curl -sS -X POST https://<slug>.alty.one/api/public/v1/support/tickets \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "requester_email": "pat@example.com",
    "requester_name": "Pat Lee",
    "source_ref": "myapp",
    "subject": "Export button does nothing",
    "body": "Clicking Export on the Reports page shows nothing.",
    "message_id": "myapp-msg-81723"
  }'
```

`201` → `{"reference": "ACME-CSE-102", "is_new": true, "duplicate": false}`. A repeat with
the same `message_id` returns the same ticket with `duplicate: true`.

- `source_ref` says which product (or which of your customers) the user belongs to; every
  read is filtered by it. If the key was created with a source reference, it may only
  assert that value or `<value>:<anything>`.
- To show the user their tickets: `POST /api/public/v1/support/tickets/lookup` with
  `requester_email` and `source_ref` (and `reference` for one ticket). It is a POST so email
  addresses stay out of URLs. Internal notes are never returned on this plane.
- To reply as the user: `POST /api/public/v1/support/tickets/reply` with `reference`,
  `message` and a `message_id`.
- `403 module_off` with `"module": "support"` means the desk, or its API channel
  (Admin › Support), is switched off.

### Work the desk queue

Preset: **Support agent or AI desk** (scope `support:agent`, service account required). An
automation acts as a member of staff: it reads the queue, raises tickets, replies, adds
notes, changes status and assigns. Everything it does is credited to the key's service
account. This one key is all an agent needs — it does not also need a `support:requester` key.

```sh
# The open queue
curl -sS -X POST https://<slug>.alty.one/api/public/v1/support/agent/tickets/list \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filter": "open", "limit": 50}'

# Reply on one ticket, and email the requester
curl -sS -X POST https://<slug>.alty.one/api/public/v1/support/agent/tickets/reply \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"reference": "ACME-CSE-102", "body": "Fixed in today'"'"'s release.", "send_email": true}'
```

- `POST /api/public/v1/support/agent/tickets/get` reads one ticket: the full thread including
  internal notes, the audit trail and links.
- `POST /api/public/v1/support/agent/tickets/create` raises a ticket, as staff do from the
  console. The requester is the service account unless you name one with
  `requester_email`, so an agent needs no `support:requester` key to open tickets. A
  service account made by the MCP setup card has no email of its own, so naming no
  requester is refused (400) for it: send `requester_email` (the MCP does, from
  `ALTY_DEFAULT_REQUESTER_EMAIL`, which the card fills with the admin's address).
- `POST /api/public/v1/support/agent/tickets/note` adds an internal note, never visible to the requester.
- `POST /api/public/v1/support/agent/tickets/status` changes status (cancelling needs a
  reason). It emails the chosen recipients when the desk's email channel is on, unless you
  send `"send_email": false`.
- **Email is opt-in on a reply** (`"send_email": true`) but **opt-out on a status change**.
  Decide deliberately: an automation that emails customers by accident is the most common
  mistake on this plane.
- A `403` whose `error` is words (not `missing_scope`) means the key has no active service
  account.

### Use Intelligent Actions from an agent

Preset: **AI agent: Intelligent Actions** (scopes `actions:read`, `actions:run`,
`actions:write` plus `leads:read`, `customers:read` and `projects:read`; service account
required). An agent finds the action that fits, previews exactly what it would send, and
runs it on a record. It can also draft a new action for an admin to review.

```sh
# What can be run
curl -sS https://<slug>.alty.one/api/public/v1/actions \
  -H "Authorization: Bearer $ALTY_API_KEY"

# See exactly what a run would send (sends nothing)
curl -sS -X POST https://<slug>.alty.one/api/public/v1/actions/<id>/preview \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"record_id": "<lead id>"}'

# Run it
curl -sS -X POST https://<slug>.alty.one/api/public/v1/actions/<id>/run \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"record_id": "<lead id>"}'
```

- Only actions an admin has **switched on** can run, and only those set for staff: the key
  runs as staff, so an "Admins only" action answers `403`. A switched-off one answers `409`.
- `POST /api/public/v1/actions` saves a draft, always **switched off**. An admin reviews it,
  stores its credential and switches it on in the app; no key can do either.
- Changing what a draft sends (its URL, method, headers, body, hosts or inputs) removes its
  stored credential, and the response says so with `"credential_dropped": true`.
- `DELETE /api/public/v1/actions/<id>` removes only a draft this key's service account made
  and that has never run. `POST /api/public/v1/actions/<id>/disable` switches any action off.
- `GET /api/public/v1/actions/runs` reads the run log for one action (`action_id`) or one
  record (`record_type` and `record_id`).

### Reading records

Customers (with their contacts and sites), leads, projects, quotes, invoices, notes and
members can be **read** with a key. Each resource has its own read scope
(`customers:read`, `leads:read`, `projects:read`, `quotes:read`, `invoices:read`,
`notes:read`, `members:read`); a `:write` scope never lets a key read.

**Shapes.** Every record has `id`, `created_at`, `updated_at` and `version`. Times are
ISO-8601 UTC. Money is integer minor units (cents). Statuses, types and sources are the
instance's picklist **keys** (e.g. `NEW`, `ACTIVE`), never its labels. Names are fixed: the
app may call projects "Jobs", but the API always says `project`, and a lead is a `lead`
(its id is `lead_id` on a project or quote). `version` is the record's last change in epoch
milliseconds; it is what `If-Match` compares on a write (see "Writing records"). Reads never need it.

**Lists** return `{"data": [...], "next_cursor": "…" | null}`, ordered by last change, then
id. Pass `limit` (1–200, default 50). To get everything, keep calling with
`cursor=<next_cursor>` until `next_cursor` is `null`. A record changed while you are paging
moves to the end of the order, so you see it again at its latest state; nothing is skipped.

**Keeping a copy in sync.** Pass `updated_since` (ISO-8601) to get only records changed at
or after that time. Use **the time your previous sync started, less a minute**, not the
time it finished: a record you already have may come back, which is harmless (overwrite
your copy), and a change made while you were syncing is never missed. Add
`include_deleted=true` to also receive a **tombstone** `{"id", "deleted_at", "updated_at",
"version"}` for each record moved to the Trash: a row carrying `deleted_at` means delete
your copy.

**What a key does not see.** A record in the Trash, and a site, lead, project, quote or
invoice whose customer is in the Trash, is invisible: `GET` by id answers `404`, and lists
leave it out (the customer's tombstone covers its records; there is no separate tombstone
for each). Demo (sample) data is never returned. A record permanently deleted from the
Trash leaves no tombstone. Contacts are removed outright in Alty, so they have no
tombstones: read a customer's whole contact list and compare it with yours.

**Filters.** `customer_id` on leads, projects, quotes, invoices and sites; `status` on leads,
projects, quotes and invoices; `project_id` on quotes and invoices. `external_id` (on
customers, sites, leads and projects) finds a record by **your** id for it, and each record's
`external_id` field holds yours; only a key bound to a service account has any, and it never
sees another integration's. A query parameter an endpoint does not list is ignored, so check
the spelling of a filter that seems to do nothing. A bad `limit`, `cursor`, `updated_since`
or `include_deleted` is `400` with `error` naming it (`invalid_limit`, `invalid_cursor`,
`invalid_updated_since`, `invalid_include_deleted`).

**Modules.** Sites, leads, projects, quotes and invoices are parts of Alty a business can
switch off. On an instance that has, those endpoints answer `403 module_off` with `module`
set to `sites`, `enquiries`, `projects`, `quotes` or `invoices`, whatever the key's scopes.
Customers, contacts and members are always on. `whoami` lists which of your key's modules
are on.

**Notes** are read under their record: `GET /api/public/v1/customers/{id}/notes`,
`/api/public/v1/leads/{id}/notes` or `/api/public/v1/projects/{id}/notes`. The key needs
`notes:read` **and** that record's read scope (`customers:read`, `leads:read` or
`projects:read`); without the second it gets `403 missing_scope` naming it. Each note names
its author; an integration's service account is `kind: "service"`.

**Members** (`GET /api/public/v1/members`, `members:read`) are the people and service
accounts on the instance: id, name, email, role, kind and whether active. The id is what
`assigned_to` holds. It is always one page (`next_cursor` is `null`).

### Read records for reporting

Preset: **Read-only reporting** (`customers:read`, `leads:read`, `projects:read`,
`quotes:read`, `invoices:read`, `notes:read`, `members:read`; no service account). A
dashboard, wallboard or report reads Alty and changes nothing.

```sh
# Every open project, a page at a time
cursor=""
while :; do
  page=$(curl -sS "https://<slug>.alty.one/api/public/v1/projects?status=ACTIVE&limit=200${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $ALTY_API_KEY")
  echo "$page" | jq -c '.data[] | {number, title, customer_id, due_date}'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done

# One invoice, with its lines and what is still owed
curl -sS https://<slug>.alty.one/api/public/v1/invoices/<invoice-id> \
  -H "Authorization: Bearer $ALTY_API_KEY"
```

- `status` takes the instance's picklist key; `ACTIVE` is the default project status, but an
  instance may have its own. A status the instance does not have simply matches nothing.
- An invoice's `amount_due` is `null` when it is not tracked for that invoice: never read it
  as zero. `is_overdue` is worked out at read time from `due_at`, so it can turn true
  without the invoice's `updated_at` moving.
- To refresh a report without re-reading everything, use `updated_since` as described in
  "Reading records" above.
- `403 module_off` means the business does not use that part of Alty (for example
  Quotes): leave it out of the report.

### Writing records

Customers (with their contacts and sites), leads and projects can be **created, changed and
deleted** with a key, and notes **added** to a customer, lead or project. Quotes, invoices
and members stay read-only. Each write needs its resource's write scope
(`customers:write` for customers, contacts and sites; `leads:write`; `projects:write`;
`notes:write`), and the key must be bound to a **service account**: Admin › API makes one
for you with the **Sync customers and jobs** preset. Without one, every write is
`403 service_account_required`. If an admin suspends or deletes that service account, the
key stops at once: every call it makes, reads included, is `403 service_account_required`.

**A write through the API is the same write as in the app.** The same checks run (a
project status must be one of the instance's statuses, a name is required), the same
History is written, automatic actions fire, a customer change is pushed to Xero on a
Xero-connected instance, and anyone with the record open in the app or on their phone sees
it change without reloading. Every change is credited to your service account: a project
or lead's History says "<service account> (via API)", and a note's author is the service
account, shown as an integration.

**Bodies** use the names the reads return, so you can send back what you read, minus the
read-only fields (`id`, `number`, `version`, `created_at`, `updated_at`, `lead_id`,
`converted_at`, a lead's `customer_id`):

- A field you **leave out is left alone**; a field sent as **`null` is cleared**. A PATCH
  says exactly what it changes.
- A field the endpoint does not take is `400 unknown_field` with `field` naming it, never
  silently ignored. A bad value is `400 invalid_field`. The app's own refusals (an unknown
  status, a missing name) come back as `400` in words.
- `address` takes `line1`, `line2`, `suburb`, `state`, `postcode`, `country`; send only the
  lines you change. A lead's `contact` takes `name`, `company`, `email`, `phone` the same way.
- Times are ISO-8601 (`2026-11-01` or `2026-11-01T09:00:00Z`); money is integer cents;
  statuses and types are picklist **keys**.
- `custom_fields` **replaces** the record's whole object: read it, change it, send it all
  back with `If-Match` (below) so a member's edit made meanwhile is not lost. The app's own
  saves change only the keys they edit.
- **Showing an account on the customer: `custom_fields.linked_account`.** If your system
  keeps an account for an Alty customer, write its state here and the customer's page shows
  it as a read-only **Linked account** card, updated live. Every key is optional and unknown
  keys are ignored: `system` (your product's name), `ref` (the account's id in your system),
  `url` (your operator page for it) and `app_url` (the customer's own app) — `https://` or
  `http://` only, anything else is not shown as a link —, `status` (`active`, `suspended`,
  `terminating` and `terminated` are coloured; any other value is shown as written), `plan`,
  `modules` (array of strings), `region`, `created_at` and `synced_at` (ISO-8601), and
  `requested: { "status": …, "modules": […] }` (shown as "Requested: …" when either is not
  null). A value of the wrong type is left off the card. One linked account per customer.
- `assigned_to` takes a member id from `GET /api/public/v1/members`; a deactivated member is
  refused.

**Your own ids: `external_id`.** Send `external_id` (your system's id for the record) on a
`POST` to customers, sites, leads or projects. If your service account already gave that id
to a record, that record is **updated** and the answer is `200`; otherwise it is created
(`201`). So posting the same customer twice makes one customer. Look one up with
`?external_id=` on the list. Each service account has its own ids: another integration
never sees yours or overwrites them. `PATCH` with `external_id` sets or changes it on an
existing record (`null` removes it). `409 external_id_taken` means another of your records
already has it; `409 in_trash` means the record holding it is in the Trash: once an admin
restores it in the app, your next POST updates it. The API cannot clear the id of a record
in the Trash (a `PATCH` there is `404`): an admin restores it, or deletes it forever. A record deleted forever from the Trash
frees its id, and your next POST creates a new one.

**Not overwriting someone's edit: `If-Match`.** Every record carries `version`. Send it back
on a `PATCH` or `DELETE` as `If-Match: <version>`. If the record has changed since you read
it, nothing is written and you get `412 version_conflict` with `current`, its version now:
read it again, merge, and retry. Without `If-Match`, the last write wins, exactly as in the
app.

**Changing nothing changes nothing.** A `PATCH` whose values are already the record's
answers `200` with the record and its **same** `version`: no History, no Xero push, and no
`updated_at` change, so it does not come back on your next `updated_since` sync. A sync that
re-sends everything it has is safe.

**Deletes go to the Trash**, as the app's Delete does: an admin can restore the record from
the Trash in the app, and lists with `include_deleted=true` show its tombstone meanwhile.
A deleted customer's Xero contact is archived, never deleted. There is no permanent delete
and no restore through the API. Contacts have no Trash in Alty: deleting one removes it.

**Answers.** A create answers `201` with the record in its read shape (an upsert that
updated, `200`); a PATCH `200` with the record as it is now; a DELETE `200`
`{"id": "…", "deleted": true}`. A `POST` accepts `Idempotency-Key` (section 7).

| Write | Endpoint | Scope |
|---|---|---|
| Customer | `POST /api/public/v1/customers`, `PATCH`/`DELETE /api/public/v1/customers/{id}` | `customers:write` |
| Contact | `POST /api/public/v1/customers/{id}/contacts`, `PATCH`/`DELETE /api/public/v1/customers/{id}/contacts/{cid}` | `customers:write` |
| Site | `POST /api/public/v1/customers/{id}/sites`, `PATCH`/`DELETE /api/public/v1/customers/{id}/sites/{cid}` | `customers:write` (Sites in use) |
| Lead | `POST /api/public/v1/leads`, `PATCH`/`DELETE /api/public/v1/leads/{id}` | `leads:write` (Leads in use) |
| Project | `POST /api/public/v1/projects`, `PATCH`/`DELETE /api/public/v1/projects/{id}` | `projects:write` (Projects in use) |
| Note | `POST /api/public/v1/customers/{id}/notes`, `/api/public/v1/leads/{id}/notes`, `/api/public/v1/projects/{id}/notes` | `notes:write` and the record's read scope |

A lead made through the records API is a plain create, credited to the key's service account.
For a lead from a public form use the website form endpoint instead (`enquiry:intake`, recipe
"Post a website lead"): it records the source as Website and has its own per-instance limit.
Neither sends the visitor an email or runs a spam check. A project's status
moves with a `PATCH` of `status` (add `status_reason` for the History).

### Sync customers and jobs

Preset: **Sync customers and jobs** (`customers:read`, `customers:write`, `leads:read`,
`leads:write`, `projects:read`, `projects:write`, `notes:read`, `notes:write`,
`members:read`; service account required). Another app keeps its customers and jobs in step
with Alty, both ways. The app may call projects "Jobs"; the API always says `project`.

**Push your changes in** — upsert on your own id, so a re-run never duplicates:

```sh
# Create or update the customer you call C-1042 (201 the first time, 200 after)
curl -sS -X POST https://<slug>.alty.one/api/public/v1/customers \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"external_id": "C-1042", "name": "Lee Builders", "email": "office@leebuilders.example",
       "address": {"line1": "4 Dock St", "suburb": "Hobart", "state": "TAS", "postcode": "7000"}}'

# A job for that customer: the customer's Alty id comes from the answer above
curl -sS -X POST https://<slug>.alty.one/api/public/v1/projects \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"external_id": "J-77", "customer_id": "<customer-id>", "title": "Deck rebuild", "due_date": "2026-12-01"}'

# Move it on, refusing to overwrite an edit made in Alty since you read version 1761…
curl -sS -X PATCH https://<slug>.alty.one/api/public/v1/projects/<project-id> \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "If-Match: <version>" \
  -d '{"status": "COMPLETED", "status_reason": "Signed off on site"}'
```

**Pull Alty's changes out** — the change feed from "Reading records":

```sh
since="2026-10-22T08:59:00Z"   # when your LAST sync started, less a minute
cursor=""
while :; do
  page=$(curl -sS "https://<slug>.alty.one/api/public/v1/customers?updated_since=$since&include_deleted=true&limit=200${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $ALTY_API_KEY")
  echo "$page" | jq -c '.data[]'    # deleted_at set → delete your copy; else upsert on external_id or id
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done
```

- Keep Alty's `id` beside your own: the reads return your `external_id`, but a record made
  in Alty has none until you set it (`PATCH` with `external_id`), and a **tombstone carries
  no `external_id`**: an Alty-side delete is matched to your copy by Alty's `id`. If you have
  lost the mapping, `?external_id=<yours>&include_deleted=true` returns the tombstone too.
- The first sync has no shortcut: it reads every record once. After that, `updated_since`
  keeps each run small.
- Your own writes come back on the next pull (they changed `updated_at`). Recognise them
  by `version`: if it equals the version your write returned, you already have it. A
  `PATCH`, or a `POST` upsert, that changed nothing keeps its `version` and never comes back.
- Do projects after customers: a project needs its customer's Alty `id`. A lead may have no
  customer.
- On `412 version_conflict`, someone edited the record in Alty after your last pull: pull
  it, decide which side wins, and retry with the `current` version. Alty does not decide
  for you, and a pull does not say which fields changed (the `customer.updated` webhook
  does, in `changed`). A simple rule that works: per record, the side changed most recently
  wins, comparing each side's own last-change time against when you last synced it, not
  the two clocks against each other.
- Deleted on one side, edited on the other: send the `DELETE` with `If-Match`. A `412` means
  it was edited in Alty since, so keep it and bring the edit back instead of deleting.
- `403 module_off` with `"module": "projects"` (or `enquiries`, `sites`) means the business
  does not use that part of Alty: sync customers only.
- To record what happened, add a note: `POST /api/public/v1/projects/{id}/notes` with
  `{"body": "…"}`.

### Receive events

Alty tells your app when something changes — a customer created, a job moved on, an invoice
paid — by POSTing a signed event to your HTTPS receiver. Use a key from the **Receive
events** preset (`webhooks:manage` plus the read scopes for the records you want to hear
about), or ask an admin to add the receiver in Admin › API › Webhooks.

**1. Subscribe.** The answer carries `secret` exactly once: store it. If you lose it, rotate
it (`POST /api/public/v1/webhooks/{id}/rotate-secret`). A retry with the same
`Idempotency-Key` gives you the subscription back but not the secret (`"secret": null`,
`"secret_omitted": true`).

```bash
curl -s -X POST "https://<slug>.alty.one/api/public/v1/webhooks" \
  -H "Authorization: Bearer $ALTY_API_KEY" \
  -H "User-Agent: my-app/1.0" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'content-type: application/json' \
  -d '{"url":"https://your-app.example.com/alty-webhooks","event_types":["customer.created","customer.updated","project.status_changed","invoice.paid"]}'
```

- The URL must be https on port 443, a public host name that resolves only to public
  addresses, not on the Alty domain, with no credentials in it — refused otherwise (400
  `unsafe_url`, with the reason). It is checked again before every delivery.
- Each event type also needs the read scope for its records (the table below): without
  it, 403 `missing_scope`. The same key fetches the record afterwards.
- A key sees and manages only the subscriptions it made. When the key is revoked or
  expires, its subscriptions stop and are switched off: after rotating a key, register the
  receiver again with the new one.
- Events follow the key's scopes as they are **now**. If an admin removes a read scope, events
  for those records stop, including any not yet delivered. If an admin removes
  `webhooks:manage`, the key's subscriptions are switched off; they can be switched back on
  once the key has the scope again.

**2. Check your receiver end to end:** `POST /api/public/v1/webhooks/{id}/test` sends a
signed `webhook.test` at once and answers with what your receiver said. Afterwards,
`GET /api/public/v1/webhooks/{id}/deliveries` answers `{"deliveries": [ … ]}`, each with its
state, attempts and your receiver's last status, and
`POST /api/public/v1/webhooks/{id}/deliveries/{delivery_id}/resend` sends one again.

**3. Verify every delivery** before trusting it. Deliveries follow
[Standard Webhooks](https://www.standardwebhooks.com): headers `webhook-id`,
`webhook-timestamp` and `webhook-signature` (`v1,<base64 HMAC-SHA256>` of
`webhook-id.webhook-timestamp.body`, under the secret's bytes — the base64 after `whsec_`).
Verify the RAW body, before any JSON parsing. Off-the-shelf libraries do all of it:

```js
// Node: npm install standardwebhooks
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(signingSecret); // the whsec_… value, from your own secret store
app.post('/alty-webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString('utf8'), req.headers); // throws on a bad signature or an old timestamp
  } catch {
    return res.status(401).end(); // any non-2xx is a failed delivery to Alty
  }
  queue.push(event); // answer fast; do the work afterwards
  res.status(204).end();
});
```

```python
# Python: pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
wh = Webhook(signing_secret)  # the whsec_… value, from your own secret store

@app.post("/alty-webhooks")
def alty_webhooks(request):
    event = wh.verify(request.body.decode(), dict(request.headers))  # raises if not genuine
    queue.put(event)
    return Response(status=204)
```

Without a library, the check is (Python):

```python
import base64, hashlib, hmac, time
TOLERANCE_S = 300  # reject deliveries more than 5 minutes old or ahead: replays

def genuine(secret, headers, body: bytes) -> bool:
    h = {k.lower(): v for k, v in headers.items()}  # header names are case-insensitive
    wid, ts, sigs = h.get("webhook-id"), h.get("webhook-timestamp"), h.get("webhook-signature")
    if not (wid and ts and sigs) or not ts.isdigit():
        return False  # unsigned: reject, don't crash
    if abs(time.time() - int(ts)) > TOLERANCE_S:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{wid}.{ts}.".encode() + body
    want = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(want, s.split(",", 1)[1]) for s in sigs.split(" ") if s.startswith("v1,"))
```

Reject anything that fails with any non-2xx (401 is usual): Alty counts it as a failed
delivery and retries. Use a 5-minute timestamp tolerance, as the libraries do.

`webhook-signature` can carry two signatures for 24 hours after a secret rotation
(`POST /api/public/v1/webhooks/{id}/rotate-secret`): accept the delivery if either matches.

**4. What arrives** — a thin event, never the whole record:

```json
{ "id": "evt_9b2f…", "type": "project.status_changed", "occurred_at": "2026-10-22T03:14:00.000Z",
  "tenant": "acme", "source": "alty", "actor": null,
  "data": { "object": "project", "id": "4f1c…", "external_id": "J-77",
            "summary": { "number": "PRJ-0007", "status": "IN_PROGRESS", "previous_status": "QUOTED", "customer_id": "9a0e…" } } }
```

The event's `id` is the same value as the `webhook-id` header. Deliveries usually arrive
within a second or two of the change.

- **`source`** says where the change came from: `alty` (a member, or Alty itself), `xero`
  (see below) or `api` (written through this API under a key). **New values may be added:
  treat one you do not know like `alty`.**
- **`actor`** is set exactly when `source` is `api`: `{ "service_account_id": "…" }`, the
  writing key's service account (the id `GET /whoami` returns for it). Compare it with your
  own to recognise **your own writes** coming back to you. For `alty` and `xero` it is
  `null`; who in the business made a change is not sent.
- **`data.external_id`** is **your** id for the record: the `external_id` your key's service
  account set on it, as it was when the event was queued, so a `*.deleted` event still names
  it. It is `null` when you have not linked the record, for objects without external ids,
  and for subscriptions an admin made (they belong to no service account).
- **Not hearing your own writes:** create the subscription with `"skip_own_changes": true`
  (or `PATCH` it later) and Alty sends it nothing about changes your service account made
  through the API. We suggest leaving it off and skipping events whose `actor` is you: then
  your own writes still confirm what Alty stored.
- When an admin's Intelligent Action links a customer to **your** id (the action's "give it
  an integration's id" step), you get `customer.updated` with `"changed": ["external_id"]`.
  Only your service account's subscriptions receive it.

**5. Rules your receiver must follow:**

- **De-duplicate on `webhook-id`.** Delivery is at least once: a retry, or an admin's
  Resend, sends the same event again with the same id. Seen it already? Answer 2xx and
  do nothing.
- **Answer 2xx within 10 seconds**, then do the work. Anything else — another status, a
  timeout, a redirect (never followed) — is a failure and is retried (schedule below). A
  receiver failing every delivery for 3 days is switched off and the admins are told;
  switching it back on sends new events only.
- **Fetch the record when you need more than the summary**, with the same key:
  `GET /api/public/v1/customers/{id}`, `GET /api/public/v1/projects/{id}`, … — so you always
  act on the record as it is now, even from a late retry.
- **Order.** Events for one record arrive in order (a record's next event waits until the
  previous one is delivered or abandoned); different records arrive in any order. If you
  ever need to, order by `occurred_at`.
- **`source: "xero"`** marks a change made in Xero and applied by Alty's Xero mirror (a
  payment recorded in Xero becomes `invoice.paid` with `source: "xero"`). If your app also
  listens to Xero directly, drop those to avoid acting twice.

**Event types**

<!-- generated:events -->
| Event | When | Summary fields | The subscribing key also needs |
|---|---|---|---|
| `customer.created` | A customer is created. | `name` | `customers:read` |
| `customer.updated` | A customer is changed. `changed` names the fields. | `name`, `changed` | `customers:read` |
| `customer.deleted` | A customer is deleted (moved to the Trash, or removed where it has no Trash). | `name` | `customers:read` |
| `contact.created` | A customer’s contact is created. | `customer_id` | `customers:read` |
| `contact.updated` | A customer’s contact is changed. `changed` names the fields. | `customer_id`, `changed` | `customers:read` |
| `contact.deleted` | A customer’s contact is deleted (moved to the Trash, or removed where it has no Trash). | `customer_id` | `customers:read` |
| `site.created` | A customer’s site is created. | `customer_id`, `name` | `customers:read` |
| `site.updated` | A customer’s site is changed. `changed` names the fields. | `customer_id`, `name`, `changed` | `customers:read` |
| `site.deleted` | A customer’s site is deleted (moved to the Trash, or removed where it has no Trash). | `customer_id`, `name` | `customers:read` |
| `lead.created` | A lead is created — in the app, by the API, from a website form or from mail. | `number`, `status`, `customer_id` | `leads:read` |
| `lead.updated` | A lead is changed, its status included. `changed` names the fields. | `number`, `status`, `previous_status`, `customer_id`, `changed` | `leads:read` |
| `lead.converted` | A lead becomes a customer (and, optionally, a project). | `number`, `status`, `customer_id`, `project_id` | `leads:read` |
| `lead.deleted` | A lead is moved to the Trash. | `number`, `status`, `customer_id` | `leads:read` |
| `project.created` | A project is created — in the app, by the API, or from a lead. | `number`, `status`, `customer_id` | `projects:read` |
| `project.updated` | A project is changed, its status included. `changed` names the fields. | `number`, `status`, `previous_status`, `customer_id`, `changed` | `projects:read` |
| `project.status_changed` | A project’s status moves. Sent as well as `project.updated`. | `number`, `status`, `previous_status`, `customer_id` | `projects:read` |
| `project.deleted` | A project is moved to the Trash. | `number`, `status`, `customer_id` | `projects:read` |
| `quote.sent` | A quote or proposal becomes Sent. | `number`, `status`, `previous_status`, `customer_id`, `project_id`, `total`, `currency` | `quotes:read` |
| `quote.accepted` | A quote or proposal is accepted — by the client on its page, or marked so in the app. | `number`, `status`, `previous_status`, `customer_id`, `project_id`, `total`, `currency` | `quotes:read` |
| `quote.declined` | A quote or proposal is declined. | `number`, `status`, `previous_status`, `customer_id`, `project_id`, `total`, `currency` | `quotes:read` |
| `invoice.issued` | An invoice is approved (AUTHORISED). | `number`, `status`, `previous_status`, `customer_id`, `project_id`, `total`, `currency` | `invoices:read` |
| `invoice.paid` | An invoice becomes fully paid. | `number`, `status`, `previous_status`, `customer_id`, `project_id`, `total`, `currency` | `invoices:read` |
| `invoice.voided` | An invoice is voided. | `number`, `status`, `previous_status`, `customer_id`, `project_id`, `total`, `currency` | `invoices:read` |
| `note.created` | A member, or an integration, adds a note to a customer, lead or project. | `parent_type`, `parent_id` | `notes:read` |
| `webhook.test` | You pressed Send test in Admin › API, or called `POST /webhooks/{id}/test`. Never sent otherwise. `data` is `{ "object": "webhook", "id": <the subscription id>, "summary": { "message": … } }`. | `message` | — |

| Summary field | Type | Meaning |
|---|---|---|
| `name` | string|null | The record’s own name (a customer’s or a site’s). |
| `customer_id` | string|null | The customer it belongs to. |
| `project_id` | string|null | The project it belongs to, or the project a lead became. |
| `number` | string|null | Its human reference (ENQ-0042, PRJ-0007, the quote or invoice number). |
| `status` | string|null | Its status now (a picklist key). |
| `previous_status` | string|null | Its status before this change, when this change moved it. |
| `changed` | string[] | On `*.updated`: which fields changed, named as the records API names them (`address` for any address line). |
| `total` | integer|null | The total in minor units (cents), tax included. |
| `currency` | string|null | ISO 4217 code. |
| `parent_type` | string|null | What a note is on: `customer`, `lead` or `project`. |
| `parent_id` | string|null | The id of what the note is on. |
| `message` | string | A fixed sentence: this is a test. |

Retries after a failure: 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h — then the delivery is abandoned (it stays in the log, where it can be resent).
<!-- /generated:events -->

## 9. What you cannot do (yet)

Do not try these, and do not let an agent invent endpoints for them. If an endpoint is not
in section 10, it does not exist.

- **No writes to quotes, invoices, payments, the books, time entries or members.** They
  are read-only (billing and accounting stay in the app, where the Xero and Books rules
  apply). Customers, contacts, sites, leads and projects can be written, and notes added
  (see "Writing records" in section 8).
- **No changing or deleting notes**, and no notes on anything but a customer, lead or
  project.
- **No file uploads.** Documents and photos on a record cannot be added through the API
  yet; that is planned as its own change.
- **No converting a lead** to a customer or project, and no setting a lead's customer: do
  that in the app.
- **No bulk writes or imports.** One record per request. Bulk imports and migrations are
  done with Alty support — contact Alty support.
- **No fat webhook events.** An event names the record and a small summary; fetch the
  record for the rest. No filtering by field, no custom payload shapes, and nothing about
  quotes or invoices beyond sent/accepted/declined and issued/paid/voided.
- **No permanent deletes.** A records `DELETE` moves the record to the Trash.
- **No restore from the Trash** through the API.
- **No member impersonation, no sign-in on a person's behalf, no personal tokens.** Keys
  belong to the instance and act as the instance (or as their service account), never as a
  person.
- **No access to another instance.** A key works only on the host that issued it. There is
  no cross-instance or platform-wide API.
- **No billing, subscription, payment or Xero writes**, no user or permission management,
  no settings, and no key management over the API: keys are created and revoked only by an
  admin in Admin › API.
- **No browser use.** Keys are for servers. The API does not send CORS headers for keyed
  endpoints, and a key in a web page is a leaked key.
- **No OAuth apps or "Sign in with Alty".**
- **The SPA's own `/api/*` routes are not part of this API.** They need a member's session,
  change without notice, and are not supported for integrations.

## 10. Endpoint index

Paths are relative to `https://<slug>.alty.one`. The OpenAPI document has every request
and response field.

<!-- generated:endpoints -->
**Getting started**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| GET | `/api/public/v1/whoami` | any key | Check a key: which instance, which scopes |
| GET | `/api/public/v1/guide.md` | none | The integration guide, as markdown |
| GET | `/api/public/v1/openapi.json` | none | This OpenAPI document |

**Records**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| GET | `/api/public/v1/customers` | `customers:read` | List customers |
| POST | `/api/public/v1/customers` | `customers:write` | Create a customer |
| GET | `/api/public/v1/customers/{id}` | `customers:read` | Get one customer |
| PATCH | `/api/public/v1/customers/{id}` | `customers:write` | Change a customer |
| DELETE | `/api/public/v1/customers/{id}` | `customers:write` | Delete a customer |
| GET | `/api/public/v1/leads` | `leads:read` | List leads |
| POST | `/api/public/v1/leads` | `leads:write` | Create a lead |
| GET | `/api/public/v1/leads/{id}` | `leads:read` | Get one lead |
| PATCH | `/api/public/v1/leads/{id}` | `leads:write` | Change a lead |
| DELETE | `/api/public/v1/leads/{id}` | `leads:write` | Delete a lead |
| GET | `/api/public/v1/projects` | `projects:read` | List projects |
| POST | `/api/public/v1/projects` | `projects:write` | Create a project |
| GET | `/api/public/v1/projects/{id}` | `projects:read` | Get one project |
| PATCH | `/api/public/v1/projects/{id}` | `projects:write` | Change a project |
| DELETE | `/api/public/v1/projects/{id}` | `projects:write` | Delete a project |
| GET | `/api/public/v1/quotes` | `quotes:read` | List quotes |
| GET | `/api/public/v1/quotes/{id}` | `quotes:read` | Get one quote |
| GET | `/api/public/v1/invoices` | `invoices:read` | List invoices |
| GET | `/api/public/v1/invoices/{id}` | `invoices:read` | Get one invoice |
| GET | `/api/public/v1/sites` | `customers:read` | List sites, across all customers |
| GET | `/api/public/v1/customers/{id}/contacts` | `customers:read` | List a customer's contacts |
| POST | `/api/public/v1/customers/{id}/contacts` | `customers:write` | Add a contact to a customer |
| GET | `/api/public/v1/customers/{id}/sites` | `customers:read` | List a customer's sites |
| POST | `/api/public/v1/customers/{id}/sites` | `customers:write` | Add a site to a customer |
| GET | `/api/public/v1/customers/{id}/notes` | `notes:read` | List a customer's notes |
| POST | `/api/public/v1/customers/{id}/notes` | `notes:write` | Add a note to a customer |
| GET | `/api/public/v1/leads/{id}/notes` | `notes:read` | List a lead's notes |
| POST | `/api/public/v1/leads/{id}/notes` | `notes:write` | Add a note to a lead |
| GET | `/api/public/v1/projects/{id}/notes` | `notes:read` | List a project's notes |
| POST | `/api/public/v1/projects/{id}/notes` | `notes:write` | Add a note to a project |
| GET | `/api/public/v1/members` | `members:read` | List the team |
| PATCH | `/api/public/v1/customers/{id}/contacts/{cid}` | `customers:write` | Change a customer's contact |
| DELETE | `/api/public/v1/customers/{id}/contacts/{cid}` | `customers:write` | Delete a customer's contact |
| PATCH | `/api/public/v1/customers/{id}/sites/{cid}` | `customers:write` | Change a customer's site |
| DELETE | `/api/public/v1/customers/{id}/sites/{cid}` | `customers:write` | Delete a customer's site |

**Webhooks**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| GET | `/api/public/v1/webhooks/event-types` | `webhooks:manage` | List the event types |
| GET | `/api/public/v1/webhooks` | `webhooks:manage` | List your webhook subscriptions |
| POST | `/api/public/v1/webhooks` | `webhooks:manage` | Subscribe a receiver to events |
| GET | `/api/public/v1/webhooks/{id}` | `webhooks:manage` | Get a webhook subscription |
| PATCH | `/api/public/v1/webhooks/{id}` | `webhooks:manage` | Change a webhook subscription |
| DELETE | `/api/public/v1/webhooks/{id}` | `webhooks:manage` | Delete a webhook subscription |
| POST | `/api/public/v1/webhooks/{id}/test` | `webhooks:manage` | Send a test event now |
| POST | `/api/public/v1/webhooks/{id}/rotate-secret` | `webhooks:manage` | Rotate the signing secret |
| GET | `/api/public/v1/webhooks/{id}/deliveries` | `webhooks:manage` | List a subscription’s deliveries |
| POST | `/api/public/v1/webhooks/{id}/deliveries/{delivery_id}/resend` | `webhooks:manage` | Resend a delivery |

**Intelligent Actions**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| GET | `/api/public/v1/actions` | `actions:read` | List Intelligent Actions |
| POST | `/api/public/v1/actions` | `actions:write` | Draft a new action |
| GET | `/api/public/v1/actions/runs` | `actions:read` | Read the run log |
| GET | `/api/public/v1/actions/{id}` | `actions:read` | Read an action |
| PUT | `/api/public/v1/actions/{id}` | `actions:write` | Change a switched-off action |
| DELETE | `/api/public/v1/actions/{id}` | `actions:write` | Delete your own unused draft |
| POST | `/api/public/v1/actions/{id}/preview` | `actions:read` | Preview a run on a record |
| POST | `/api/public/v1/actions/{id}/run` | `actions:run` | Run an action on a record |
| POST | `/api/public/v1/actions/{id}/disable` | `actions:write` | Switch an action off |

**Support desk — requester**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| POST | `/api/public/v1/support/tickets` | `support:requester` | Raise a ticket for a requester |
| POST | `/api/public/v1/support/tickets/lookup` | `support:requester` | Read one of the requester's tickets, or list them |
| POST | `/api/public/v1/support/tickets/reply` | `support:requester` | Reply as the requester |
| POST | `/api/public/v1/support/attachments/get` | `support:requester` | Download a file from the requester's own ticket |
| POST | `/api/public/v1/support/tickets/close` | `support:requester` | Close a ticket as the requester |

**Support desk — agent**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| POST | `/api/public/v1/support/agent/tickets/list` | `support:agent` | List the desk queue |
| POST | `/api/public/v1/support/agent/tickets/get` | `support:agent` | Read a ticket (staff view) |
| POST | `/api/public/v1/support/agent/tickets/status` | `support:agent` | Change a ticket's status |
| POST | `/api/public/v1/support/agent/tickets/reply` | `support:agent` | Reply on a ticket |
| POST | `/api/public/v1/support/agent/tickets/reply/preview` | `support:agent` | Preview a reply before sending it |
| POST | `/api/public/v1/support/agent/tickets/contacts` | `support:agent` | Add or remove a ticket's contacts |
| POST | `/api/public/v1/support/agent/tickets/incident/link` | `support:agent` | Link a ticket as affected by an incident |
| POST | `/api/public/v1/support/agent/tickets/incident/unlink` | `support:agent` | Detach a ticket from its incident |
| POST | `/api/public/v1/support/agent/tickets/incident/affect` | `support:agent` | Raise an affected ticket for one customer |
| POST | `/api/public/v1/support/agent/tickets/create` | `support:agent` | Raise a ticket |
| POST | `/api/public/v1/support/agent/tickets/note` | `support:agent` | Add an internal note |
| POST | `/api/public/v1/support/agent/tickets/assign` | `support:agent` | Assign or unclaim a ticket |
| POST | `/api/public/v1/support/agent/tickets/link` | `support:agent` | Link a ticket to a customer by your ref for it |
| POST | `/api/public/v1/support/agent/attachments/get` | `support:agent` | Download a file from a ticket |
| POST | `/api/public/v1/support/agent/attachments/put` | `support:agent` | Reply or add a note WITH files |

**Website intake**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| POST | `/api/public/v1/intake/enquiries` | `enquiry:intake` | Post a website enquiry as a new lead |
| POST | `/api/public/v1/intake/discoveries` | `enquiry:intake` | Post discovery answers for a lead |
| POST | `/api/public/v1/intake/discoveries/{id}/summary-pdf` | `enquiry:intake` | Attach the discovery summary PDF to a lead |
| GET | `/api/public/v1/intake/enquiries/{id}/prefill` | `enquiry:intake` | Prefill the discovery form from a lead |

**Hosted proposals**

| Method | Path | Key scope | What it does |
|---|---|---|---|
| GET | `/api/public/v1/proposals/{token}` | `enquiry:intake` | Read a proposal by its token |
| POST | `/api/public/v1/proposals/{token}/accept` | `enquiry:intake` | Accept a proposal |
| POST | `/api/public/v1/proposals/{token}/decline` | `enquiry:intake` | Decline a proposal |
| GET | `/api/public/v1/proposals/{token}/pdf` | `enquiry:intake` | Download the proposal PDF |
<!-- /generated:endpoints -->

## 11. Versioning

- `/api/public/v1` is version 1. Within v1, changes are **additive only**: new endpoints,
  new optional request fields, new response fields and new `error` values. Write clients
  that ignore fields they do not know.
- Nothing in v1 is removed or changed incompatibly without **at least 6 months' notice**.
  A breaking change would come as a new version (v2), with v1 kept running alongside it.
- **Older paths.** Integrations built before v1 call the same endpoints without `/v1`
  (`/api/public/support/tickets` rather than `/api/public/v1/support/tickets`). Those paths
  keep working indefinitely and answer exactly the same. New work should use `/v1`.
