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 athttps://<slug>.alty.one/api/public/v1/guide.md - OpenAPI 3.1:
https://www.alty.one/developers/openapi.json, and on every instance athttps://<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 with403 https_requiredbefore 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.
curl -sS https://<slug>.alty.one/api/public/v1/whoami \
-H "Authorization: Bearer $ALTY_API_KEY"
{
"tenant": "acme",
"key": { "label": "Acme site", "scopes": ["enquiry:intake"], "expires_at": null },
"service_account": { "id": "…", "name": "Website (Acme site)" },
"modules": ["core"]
}
key.scopesis everything this key may do. Each endpoint in section 10 names the one scope it needs.moduleslists which of the modules your scopes belong to are switched on. If the module you need is missing, its endpoints will answer403 module_off.expires_atis epoch milliseconds, ornullfor a key that never expires.- A
401means 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.
| 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.
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.
| 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 | — |
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_limitedwithRetry-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
403s in a minute, and when a key is sent overhttp://. - Website intake: at most 60 writes a minute per instance, shared by every key.
- Lists on the desk take
limit(andoffsetwhere the endpoint says so) and returntotal, 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
201stays a201, with the sameenquiry_id), plus the headeridempotent-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\u2014in some encoders) and count as different. - While the first request is still running:
409 idempotency_in_progresswithRetry-After: 1. - Every endpoint that takes the header lists these
409s, the header’s own400 invalid_idempotency_keyand the5XXrange 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-secretanswer carries"secret": nulland"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.
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).
subjectis 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.messageis 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, is400witherrornaming the field. Limits:subject,contact_name,company_nameandservice_type200 characters;contact_email254, and it must look like an email address (something@domain.tld);contact_phone40;message20,000;country2 (ISO 3166 alpha-2, stored upper-case). Aservice_typethat 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
201body is the contract: once you have it, the lead exists. Logenquiry_idandenquiry_numberwith your form submission. A4xxmeans no lead was made. After a timeout, retry with the sameIdempotency-Key: if the first request did make the lead, you get its original201back instead of a second lead. After a5xx, retry the same way (section 7). To check by hand, find theenquiry_numberin Leads; a key that also holdsleads:readcanGET /api/public/v1/leads/{id}with theenquiry_id. A Website form key does not, so it cannot read the lead back: the201(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…_numberorreference) 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. Useenquiry_idto refer to the lead in later calls. 403 module_not_in_usemeans the business has switched Leads off.429with words, notrate_limited, is the per-instance intake limit: wait a minute.- Generate the
Idempotency-Keyonce 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:
# 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,expiredandalready_respondedare allfalse. A newer version sent, or an answer already given, is409; a lapsed link is410. reasonis one ofPRICE,FOUND_ANOTHER,TIMING,DIFFERENT_DIRECTION,OTHER;detailis optional, at most 500 characters. The answer moves the lead on and tells the business.GET /api/public/v1/proposals/{token}/pdfis 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.
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_refsays 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/lookupwithrequester_emailandsource_ref(andreferencefor 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/replywithreference,messageand amessage_id. 403 module_offwith"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.
# 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/getreads one ticket: the full thread including internal notes, the audit trail and links.POST /api/public/v1/support/agent/tickets/createraises a ticket, as staff do from the console. The requester is the service account unless you name one withrequester_email, so an agent needs nosupport:requesterkey 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: sendrequester_email(the MCP does, fromALTY_DEFAULT_REQUESTER_EMAIL, which the card fills with the admin’s address).POST /api/public/v1/support/agent/tickets/noteadds an internal note, never visible to the requester.POST /api/public/v1/support/agent/tickets/statuschanges 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
403whoseerroris words (notmissing_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.
# 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 answers409. POST /api/public/v1/actionssaves 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>/disableswitches any action off.GET /api/public/v1/actions/runsreads the run log for one action (action_id) or one record (record_typeandrecord_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.
# 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"
statustakes the instance’s picklist key;ACTIVEis 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_dueisnullwhen it is not tracked for that invoice: never read it as zero.is_overdueis worked out at read time fromdue_at, so it can turn true without the invoice’supdated_atmoving. - To refresh a report without re-reading everything, use
updated_sinceas described in “Reading records” above. 403 module_offmeans 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 ”
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
nullis cleared. A PATCH says exactly what it changes. - A field the endpoint does not take is
400 unknown_fieldwithfieldnaming it, never silently ignored. A bad value is400 invalid_field. The app’s own refusals (an unknown status, a missing name) come back as400in words. addresstakesline1,line2,suburb,state,postcode,country; send only the lines you change. A lead’scontacttakesname,company,email,phonethe same way.- Times are ISO-8601 (
2026-11-01or2026-11-01T09:00:00Z); money is integer cents; statuses and types are picklist keys. custom_fieldsreplaces the record’s whole object: read it, change it, send it all back withIf-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) andapp_url(the customer’s own app) —https://orhttp://only, anything else is not shown as a link —,status(active,suspended,terminatingandterminatedare coloured; any other value is shown as written),plan,modules(array of strings),region,created_atandsynced_at(ISO-8601), andrequested: { "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_totakes a member id fromGET /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:
# 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”:
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
idbeside your own: the reads return yourexternal_id, but a record made in Alty has none until you set it (PATCHwithexternal_id), and a tombstone carries noexternal_id: an Alty-side delete is matched to your copy by Alty’sid. If you have lost the mapping,?external_id=<yours>&include_deleted=truereturns the tombstone too. - The first sync has no shortcut: it reads every record once. After that,
updated_sincekeeps each run small. - Your own writes come back on the next pull (they changed
updated_at). Recognise them byversion: if it equals the version your write returned, you already have it. APATCH, or aPOSTupsert, that changed nothing keeps itsversionand 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 thecurrentversion. Alty does not decide for you, and a pull does not say which fields changed (thecustomer.updatedwebhook does, inchanged). 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
DELETEwithIf-Match. A412means it was edited in Alty since, so keep it and bring the edit back instead of deleting. 403 module_offwith"module": "projects"(orenquiries,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}/noteswith{"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).
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: 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:
// 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: 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):
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:
{ "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.
sourcesays where the change came from:alty(a member, or Alty itself),xero(see below) orapi(written through this API under a key). New values may be added: treat one you do not know likealty.actoris set exactly whensourceisapi:{ "service_account_id": "…" }, the writing key’s service account (the idGET /whoamireturns for it). Compare it with your own to recognise your own writes coming back to you. Foraltyandxeroit isnull; who in the business made a change is not sent.data.external_idis your id for the record: theexternal_idyour key’s service account set on it, as it was when the event was queued, so a*.deletedevent still names it. It isnullwhen 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(orPATCHit later) and Alty sends it nothing about changes your service account made through the API. We suggest leaving it off and skipping events whoseactoris 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.updatedwith"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 becomesinvoice.paidwithsource: "xero"). If your app also listens to Xero directly, drop those to avoid acting twice.
Event types
| 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 |
customer_id | string | null |
project_id | string | null |
number | string | null |
status | string | null |
previous_status | string | null |
changed | string[] | On *.updated: which fields changed, named as the records API names them (address for any address line). |
total | integer | null |
currency | string | null |
parent_type | string | null |
parent_id | string | null |
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).
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
DELETEmoves 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.
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 |
11. Versioning
/api/public/v1is version 1. Within v1, changes are additive only: new endpoints, new optional request fields, new response fields and newerrorvalues. 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/ticketsrather than/api/public/v1/support/tickets). Those paths keep working indefinitely and answer exactly the same. New work should use/v1.