Developers

Build on Alty

Every Alty business has its own API at https://<slug>.alty.one/api/public/v1. The guide below is the whole contract, written to be handed to a developer or to an AI coding agent as it is.

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.

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).

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"]
}

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.

ScopeWhat it lets a key doModuleNeeds a service accountEndpoints
support:requesterraise and follow its own users’ ticketssupportno5
support:agentwork the whole desk queue (staff-grade)supportyes15
enquiry:intakelet your website post leads and discovery answers, and answer proposalscoreno8
mcp:installinstall the MCP client from this instance — download only, no desk accessmcpno0
customers:readread customers, their contacts and sitescore, sitesno5
customers:writecreate, change and delete customers, their contacts and sites (deletes go to the Trash)core, sitesyes9
leads:readread leadsenquiriesno2
leads:writecreate, change and delete leads (deletes go to the Trash)enquiriesyes3
projects:readread projectsprojectsno2
projects:writecreate, change and delete projects (deletes go to the Trash)projectsyes3
quotes:readread quotes and their linesquotesno2
invoices:readread invoices, their lines and what is still owedinvoicesno2
notes:readread notes on customers, leads and projects it can readcore, enquiries, projectsno3
notes:writeadd notes to customers, leads and projectscore, enquiries, projectsyes3
members:readsee who is on the team (names, emails, roles), to assign workcoreno1
webhooks:manageregister its own webhook receivers and read their delivery log — events only for records it can also readwebhooksno10
actions:readread Intelligent Actions, their run log, and preview a run (sends nothing) — never a credentialactionsno4
actions:runrun an action an admin has switched on, as staff — never an admins-only action, never a test runactionsno1
actions:writedraft actions (always saved off), switch one off, delete its own never-run drafts — never switch one on or set a credentialactionsno4

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 connectingScopesService accountStatusRecipe (section 8)
Website form — Your website posts enquiries and discovery answers as leads, and answers proposals.enquiry:intakenoavailable”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:requesternoavailable”Raise a ticket”
Support agent or AI desk — An automation works the desk queue: raises tickets, replies, notes, status and assignment.support:agentrequiredavailable”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:readrequiredavailable”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:readnoavailable”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:readnoavailable”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:readrequiredavailable”Use Intelligent Actions from an agent”
Custom — Choose the scopes yourself.any, ticked by handnoavailable—

5. Errors

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

StatuserrorWhat it meansWhat to do
400words naming the fieldThe body is invalid: a missing or malformed field.Fix the request. Do not retry it unchanged.
400unknown_field / invalid_fieldRecords 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.
400invalid_idempotency_keyThe Idempotency-Key header is not 1–255 visible ASCII characters.Send a valid value, or none.
401unauthorizedNo 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.
403none: plain text error code: 1010Alty’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.
403https_requiredThe 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.
403missing_scopeThe 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.
403module_offThe 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.
403module_not_in_useThe instance’s admin has marked that part of Alty (e.g. leads) as not in use. module names it.Ask the admin.
403wordsAgent planes only: the key is not bound to an active service account.Ask the admin to bind the key to a service account.
403service_account_requiredRecords 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.
404wordsNot found. The same answer whether it does not exist or is not yours to see.Check the reference or id.
409idempotency_conflictThis Idempotency-Key was already used with a different request.Use a new key for a new request.
409idempotency_in_progressThe first request with this Idempotency-Key is still running. Comes with Retry-After: 1.Wait, then retry with the same key.
409wordsThe thing already happened: a proposal already accepted or declined, a lead that already has a discovery.Do not retry; read the current state.
409external_id_taken / in_trashRecords 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”.
410wordsThe link or proposal has expired.Do not retry.
412version_conflictRecords 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.
413wordsA file is too large (the discovery summary PDF: 2 MB).Send a smaller file.
429rate_limitedThis 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.
429wordsA per-instance limit: website intake (60 writes a minute across all keys), or desk attachments.Wait a minute, then retry.
5xxvariesAlty 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

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

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).

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"}'

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.

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}'

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>"}'

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"

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 ” (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):

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).

WriteEndpointScope
CustomerPOST /api/public/v1/customers, PATCH/DELETE /api/public/v1/customers/{id}customers:write
ContactPOST /api/public/v1/customers/{id}/contacts, PATCH/DELETE /api/public/v1/customers/{id}/contacts/{cid}customers:write
SitePOST /api/public/v1/customers/{id}/sites, PATCH/DELETE /api/public/v1/customers/{id}/sites/{cid}customers:write (Sites in use)
LeadPOST /api/public/v1/leads, PATCH/DELETE /api/public/v1/leads/{id}leads:write (Leads in use)
ProjectPOST /api/public/v1/projects, PATCH/DELETE /api/public/v1/projects/{id}projects:write (Projects in use)
NotePOST /api/public/v1/customers/{id}/notes, /api/public/v1/leads/{id}/notes, /api/public/v1/projects/{id}/notesnotes: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

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"]}'

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.

5. Rules your receiver must follow:

Event types

EventWhenSummary fieldsThe subscribing key also needs
customer.createdA customer is created.namecustomers:read
customer.updatedA customer is changed. changed names the fields.name, changedcustomers:read
customer.deletedA customer is deleted (moved to the Trash, or removed where it has no Trash).namecustomers:read
contact.createdA customer’s contact is created.customer_idcustomers:read
contact.updatedA customer’s contact is changed. changed names the fields.customer_id, changedcustomers:read
contact.deletedA customer’s contact is deleted (moved to the Trash, or removed where it has no Trash).customer_idcustomers:read
site.createdA customer’s site is created.customer_id, namecustomers:read
site.updatedA customer’s site is changed. changed names the fields.customer_id, name, changedcustomers:read
site.deletedA customer’s site is deleted (moved to the Trash, or removed where it has no Trash).customer_id, namecustomers:read
lead.createdA lead is created — in the app, by the API, from a website form or from mail.number, status, customer_idleads:read
lead.updatedA lead is changed, its status included. changed names the fields.number, status, previous_status, customer_id, changedleads:read
lead.convertedA lead becomes a customer (and, optionally, a project).number, status, customer_id, project_idleads:read
lead.deletedA lead is moved to the Trash.number, status, customer_idleads:read
project.createdA project is created — in the app, by the API, or from a lead.number, status, customer_idprojects:read
project.updatedA project is changed, its status included. changed names the fields.number, status, previous_status, customer_id, changedprojects:read
project.status_changedA project’s status moves. Sent as well as project.updated.number, status, previous_status, customer_idprojects:read
project.deletedA project is moved to the Trash.number, status, customer_idprojects:read
quote.sentA quote or proposal becomes Sent.number, status, previous_status, customer_id, project_id, total, currencyquotes:read
quote.acceptedA 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, currencyquotes:read
quote.declinedA quote or proposal is declined.number, status, previous_status, customer_id, project_id, total, currencyquotes:read
invoice.issuedAn invoice is approved (AUTHORISED).number, status, previous_status, customer_id, project_id, total, currencyinvoices:read
invoice.paidAn invoice becomes fully paid.number, status, previous_status, customer_id, project_id, total, currencyinvoices:read
invoice.voidedAn invoice is voided.number, status, previous_status, customer_id, project_id, total, currencyinvoices:read
note.createdA member, or an integration, adds a note to a customer, lead or project.parent_type, parent_idnotes:read
webhook.testYou 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 fieldTypeMeaning
namestringnull
customer_idstringnull
project_idstringnull
numberstringnull
statusstringnull
previous_statusstringnull
changedstring[]On *.updated: which fields changed, named as the records API names them (address for any address line).
totalintegernull
currencystringnull
parent_typestringnull
parent_idstringnull
messagestringA 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.

10. Endpoint index

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

Getting started

MethodPathKey scopeWhat it does
GET/api/public/v1/whoamiany keyCheck a key: which instance, which scopes
GET/api/public/v1/guide.mdnoneThe integration guide, as markdown
GET/api/public/v1/openapi.jsonnoneThis OpenAPI document

Records

MethodPathKey scopeWhat it does
GET/api/public/v1/customerscustomers:readList customers
POST/api/public/v1/customerscustomers:writeCreate a customer
GET/api/public/v1/customers/{id}customers:readGet one customer
PATCH/api/public/v1/customers/{id}customers:writeChange a customer
DELETE/api/public/v1/customers/{id}customers:writeDelete a customer
GET/api/public/v1/leadsleads:readList leads
POST/api/public/v1/leadsleads:writeCreate a lead
GET/api/public/v1/leads/{id}leads:readGet one lead
PATCH/api/public/v1/leads/{id}leads:writeChange a lead
DELETE/api/public/v1/leads/{id}leads:writeDelete a lead
GET/api/public/v1/projectsprojects:readList projects
POST/api/public/v1/projectsprojects:writeCreate a project
GET/api/public/v1/projects/{id}projects:readGet one project
PATCH/api/public/v1/projects/{id}projects:writeChange a project
DELETE/api/public/v1/projects/{id}projects:writeDelete a project
GET/api/public/v1/quotesquotes:readList quotes
GET/api/public/v1/quotes/{id}quotes:readGet one quote
GET/api/public/v1/invoicesinvoices:readList invoices
GET/api/public/v1/invoices/{id}invoices:readGet one invoice
GET/api/public/v1/sitescustomers:readList sites, across all customers
GET/api/public/v1/customers/{id}/contactscustomers:readList a customer’s contacts
POST/api/public/v1/customers/{id}/contactscustomers:writeAdd a contact to a customer
GET/api/public/v1/customers/{id}/sitescustomers:readList a customer’s sites
POST/api/public/v1/customers/{id}/sitescustomers:writeAdd a site to a customer
GET/api/public/v1/customers/{id}/notesnotes:readList a customer’s notes
POST/api/public/v1/customers/{id}/notesnotes:writeAdd a note to a customer
GET/api/public/v1/leads/{id}/notesnotes:readList a lead’s notes
POST/api/public/v1/leads/{id}/notesnotes:writeAdd a note to a lead
GET/api/public/v1/projects/{id}/notesnotes:readList a project’s notes
POST/api/public/v1/projects/{id}/notesnotes:writeAdd a note to a project
GET/api/public/v1/membersmembers:readList the team
PATCH/api/public/v1/customers/{id}/contacts/{cid}customers:writeChange a customer’s contact
DELETE/api/public/v1/customers/{id}/contacts/{cid}customers:writeDelete a customer’s contact
PATCH/api/public/v1/customers/{id}/sites/{cid}customers:writeChange a customer’s site
DELETE/api/public/v1/customers/{id}/sites/{cid}customers:writeDelete a customer’s site

Webhooks

MethodPathKey scopeWhat it does
GET/api/public/v1/webhooks/event-typeswebhooks:manageList the event types
GET/api/public/v1/webhookswebhooks:manageList your webhook subscriptions
POST/api/public/v1/webhookswebhooks:manageSubscribe a receiver to events
GET/api/public/v1/webhooks/{id}webhooks:manageGet a webhook subscription
PATCH/api/public/v1/webhooks/{id}webhooks:manageChange a webhook subscription
DELETE/api/public/v1/webhooks/{id}webhooks:manageDelete a webhook subscription
POST/api/public/v1/webhooks/{id}/testwebhooks:manageSend a test event now
POST/api/public/v1/webhooks/{id}/rotate-secretwebhooks:manageRotate the signing secret
GET/api/public/v1/webhooks/{id}/deliverieswebhooks:manageList a subscription’s deliveries
POST/api/public/v1/webhooks/{id}/deliveries/{delivery_id}/resendwebhooks:manageResend a delivery

Intelligent Actions

MethodPathKey scopeWhat it does
GET/api/public/v1/actionsactions:readList Intelligent Actions
POST/api/public/v1/actionsactions:writeDraft a new action
GET/api/public/v1/actions/runsactions:readRead the run log
GET/api/public/v1/actions/{id}actions:readRead an action
PUT/api/public/v1/actions/{id}actions:writeChange a switched-off action
DELETE/api/public/v1/actions/{id}actions:writeDelete your own unused draft
POST/api/public/v1/actions/{id}/previewactions:readPreview a run on a record
POST/api/public/v1/actions/{id}/runactions:runRun an action on a record
POST/api/public/v1/actions/{id}/disableactions:writeSwitch an action off

Support desk — requester

MethodPathKey scopeWhat it does
POST/api/public/v1/support/ticketssupport:requesterRaise a ticket for a requester
POST/api/public/v1/support/tickets/lookupsupport:requesterRead one of the requester’s tickets, or list them
POST/api/public/v1/support/tickets/replysupport:requesterReply as the requester
POST/api/public/v1/support/attachments/getsupport:requesterDownload a file from the requester’s own ticket
POST/api/public/v1/support/tickets/closesupport:requesterClose a ticket as the requester

Support desk — agent

MethodPathKey scopeWhat it does
POST/api/public/v1/support/agent/tickets/listsupport:agentList the desk queue
POST/api/public/v1/support/agent/tickets/getsupport:agentRead a ticket (staff view)
POST/api/public/v1/support/agent/tickets/statussupport:agentChange a ticket’s status
POST/api/public/v1/support/agent/tickets/replysupport:agentReply on a ticket
POST/api/public/v1/support/agent/tickets/reply/previewsupport:agentPreview a reply before sending it
POST/api/public/v1/support/agent/tickets/contactssupport:agentAdd or remove a ticket’s contacts
POST/api/public/v1/support/agent/tickets/incident/linksupport:agentLink a ticket as affected by an incident
POST/api/public/v1/support/agent/tickets/incident/unlinksupport:agentDetach a ticket from its incident
POST/api/public/v1/support/agent/tickets/incident/affectsupport:agentRaise an affected ticket for one customer
POST/api/public/v1/support/agent/tickets/createsupport:agentRaise a ticket
POST/api/public/v1/support/agent/tickets/notesupport:agentAdd an internal note
POST/api/public/v1/support/agent/tickets/assignsupport:agentAssign or unclaim a ticket
POST/api/public/v1/support/agent/tickets/linksupport:agentLink a ticket to a customer by your ref for it
POST/api/public/v1/support/agent/attachments/getsupport:agentDownload a file from a ticket
POST/api/public/v1/support/agent/attachments/putsupport:agentReply or add a note WITH files

Website intake

MethodPathKey scopeWhat it does
POST/api/public/v1/intake/enquiriesenquiry:intakePost a website enquiry as a new lead
POST/api/public/v1/intake/discoveriesenquiry:intakePost discovery answers for a lead
POST/api/public/v1/intake/discoveries/{id}/summary-pdfenquiry:intakeAttach the discovery summary PDF to a lead
GET/api/public/v1/intake/enquiries/{id}/prefillenquiry:intakePrefill the discovery form from a lead

Hosted proposals

MethodPathKey scopeWhat it does
GET/api/public/v1/proposals/{token}enquiry:intakeRead a proposal by its token
POST/api/public/v1/proposals/{token}/acceptenquiry:intakeAccept a proposal
POST/api/public/v1/proposals/{token}/declineenquiry:intakeDecline a proposal
GET/api/public/v1/proposals/{token}/pdfenquiry:intakeDownload the proposal PDF

11. Versioning