# Overview

URL: https://sandbox-api-docs.evacrm.co.uk/docs

Create leads, attach files, move them through your workflow and read them back — from your own systems.

The eva crm public API lets your website, forms, lead providers and internal tools talk to your
CRM directly. It is a JSON API over HTTPS, authenticated with an API key issued in the CRM.

## Base URLs [#base-urls]

| Environment | Base URL                                  | Use it for                                               |
| ----------- | ----------------------------------------- | -------------------------------------------------------- |
| Production  | `https://api.evacrm.co.uk`                | Your live CRM data                                       |
| Sandbox     | `https://sandbox-public-api.evacrm.co.uk` | Building and testing an integration against sandbox data |

Every endpoint lives under `/v1`. Keys are environment-specific: a sandbox key does not work in
production, and the other way round.

## Quick start [#quick-start]

1. Ask a CRM administrator for an API key. It is shown once, at creation, and starts with `sk_`.

2. Confirm it works:

   ```bash
   curl https://api.evacrm.co.uk/v1/whoami \
     -H "Authorization: Bearer sk_..."
   ```

3. See what a lead can carry, with the valid values for every dropdown field:

   ```bash
   curl https://api.evacrm.co.uk/v1/leads/fields \
     -H "Authorization: Bearer sk_..."
   ```

4. Create one:

   ```bash
   curl https://api.evacrm.co.uk/v1/leads \
     -H "Authorization: Bearer sk_..." \
     -H "Content-Type: application/json" \
     -d '{
       "reference": "web-form-8812",
       "firstName": "Jane",
       "lastName": "Smith",
       "email": "jane@example.com",
       "mobile": "07700 900123",
       "postcode": "SW1A 1AA",
       "leadType": "Windows",
       "message": "Please call after 5pm",
       "test": true
     }'
   ```

   `test: true` keeps trial submissions out of reporting. Drop it when you go live.

## What you can do [#what-you-can-do]

- [Create leads](/docs/leads/create): POST /v1/leads — idempotent on your reference, with the old webhook's fields still accepted.
- [Update leads](/docs/leads/update): PATCH /v1/leads/{id} — change only the fields you send.
- [Read leads](/docs/leads/list): GET /v1/leads — filter, page and sync safely with cursors.
- [Field reference](/docs/leads/fields): GET /v1/leads/fields — every field, its limits and its valid values.
- [Attachments](/docs/leads/attachments): Photos and PDFs on a lead, visible in the CRM like any staff upload.
- [Stages](/docs/leads/stages): See a lead's workflow and move it, with the same rules the CRM applies.
- [Contracts](/docs/contracts): Read the sale a lead became, move it through its workflow, attach files.
- [Invoices & payments](/docs/invoices): See the ledger, raise invoices, record payments and match them.
- [Supplier invoices](/docs/supplier-invoices): Add supplier invoices in bulk, update them, and mark them exported, which locks them.
- [Appointments](/docs/appointments/read): Read the diary and book appointments on leads and contracts, with the CRM's own checks.
- [Availability](/docs/appointments/availability): Free slots for a person, a role, or whoever can take an appointment type.
- [Users](/docs/users): Who a lead can be assigned to, and their roles.

## Ground rules [#ground-rules]

* Read [Authentication](/docs/authentication) first. Keys are secrets and must stay server-side.
* [Conventions](/docs/conventions) covers ids, dates, errors, idempotency and retries. Everything
  on this site assumes them.
* The [changelog](/docs/changelog) records every change you could notice. Nothing is removed
  or renamed; things are added and, rarely, deprecated.

## Reading these docs with an AI assistant [#reading-these-docs-with-an-ai-assistant]

Every page here is also plain Markdown, so you can hand the reference to Claude, ChatGPT, Cursor
or your own tooling instead of scraping HTML:

* [/llms.txt](/llms.txt) is an index of every page with a one-line summary and a link to its
  Markdown version.
* [/llms-full.txt](/llms-full.txt) is the whole reference in one file. Paste it into a
  conversation or add it to an assistant's context.
* Any page as Markdown: append `.md` to its URL, for example `/docs/leads/create.md`, or request
  the normal URL with the header `Accept: text/markdown`.

The **Copy Markdown** and **Open** buttons at the top of each page do the same for a single page.

---

# Authentication

URL: https://sandbox-api-docs.evacrm.co.uk/docs/authentication

API keys, how to send them, and how to keep them safe.

Every request under `/v1` needs an API key. Keys are issued in the CRM by an administrator,
shown once at creation, and start with `sk_`. If a key is lost, revoke it and create another —
the CRM cannot show it again.

## Sending the key [#sending-the-key]

Either header works. `Authorization` is preferred.

```bash
curl -H "Authorization: Bearer sk_..." https://api.evacrm.co.uk/v1/whoami
curl -H "x-api-key: sk_..."            https://api.evacrm.co.uk/v1/whoami
```

## GET Who am I [#whoami]

`GET /v1/whoami`

Echoes the organisation and key the request resolved to, and the timezone in effect for your
requests (see [dates and timezones](/docs/conventions#dates-and-timezones)). Use it to confirm a
new key works, or to check which organisation a key belongs to. No parameters.

**Responses**

- `200`: The organisation, the key and the timezone.

- `401`: No key, or one that is unknown, revoked or expired. See the table below.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/whoami \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/whoami', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const me = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/whoami', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
me = res.json()
```

**Example response · 200**

```json
{
  "timezone": "Europe/London",
  "organization": { "id": 1, "uuid": "…", "name": "Acme Windows", "timezone": "Europe/London" },
  "apiKey": {
    "uuid": "…",
    "name": "Website form",
    "prefix": "sk_",
    "lastFour": "aB3x",
    "expiresAt": null
  }
}
```

## Responses [#responses]

| Case                              | Status | `error`                                                                  |
| --------------------------------- | ------ | ------------------------------------------------------------------------ |
| Valid key                         | 200    | —                                                                        |
| No key sent                       | 401    | `Missing API key. Send Authorization: Bearer <key> or x-api-key: <key>.` |
| Unknown, malformed or revoked key | 401    | `Invalid API key`                                                        |
| Expired key                       | 401    | `API key has expired`                                                    |

Unknown, malformed and revoked keys all get the same message on purpose, so nobody can use the
API to find out which keys once existed.

A revoked key stops working within a minute of revocation, usually immediately.

## Keeping keys safe [#keeping-keys-safe]

* **Server-side only.** A key grants full access to your organisation's leads. Never put one in
  a web page, a mobile app, or a repository. The API sends no CORS headers, so a key embedded in
  browser JavaScript would not work anyway.
* **One key per integration.** Name them after what uses them. When a vendor relationship ends,
  revoke that key alone.
* **Rotate on staff changes.** Anyone who has seen a key can use it until it is revoked.
* **Expiry is optional** but recommended for trials and contractors.

## Permissions [#permissions]

Keys currently carry no scopes: a valid key can use every endpoint on this site. Scoped keys
(read-only, leads-only) are planned and will be announced in the [changelog](/docs/changelog);
existing keys will keep their current access when they arrive.

---

# Conventions

URL: https://sandbox-api-docs.evacrm.co.uk/docs/conventions

Ids, dates, option values, errors, idempotency and retries — the rules every endpoint follows.

## Requests and responses [#requests-and-responses]

* JSON in, JSON out. Send `Content-Type: application/json` on `POST` and `PUT`, except the
  attachment upload which is `multipart/form-data`.
* Every endpoint lives under `/v1`. Nothing under it will be removed or renamed; see
  [the changelog](/docs/changelog) for how changes are made.
* `GET /health` is unauthenticated and returns `{ "status": "ok" }`. It is there for monitoring;
  it does not check the database.

Every write endpoint's page lists its fields in a table with a **Required** column. Anything not
marked required can be left out, and the notes say what happens when it is. `PATCH` bodies never
require a particular field, only at least one.

## Ids [#ids]

Every record is identified by a UUID string, returned as `id`. Numeric ids are never exposed and
never accepted. Ids are stable for the life of the record; names are not, since staff can rename
things in the CRM. Store ids.

## Dates and timezones [#dates-and-timezones]

The API works in **your organisation's timezone**, the one set in the CRM's organisation settings
(`Europe/London` for UK customers). Storage is always UTC; the zone decides how dates are read
from you and shown to you. `GET /v1/whoami` says which zone is in effect. To use another one for a
request, send `X-Timezone` with an IANA name, e.g. `X-Timezone: Europe/Dublin`; an unknown name is
a 400.

**Every timestamp you receive carries the zone's offset for that instant:**

```
2026-07-10T09:00:00.000+01:00    a July morning, British Summer Time
2026-12-10T09:00:00.000+00:00    a December morning, GMT
```

Both are what a colleague sees in the CRM. Any ISO 8601 parser reads them correctly, and the
UTC instant is unchanged.

**Every date you send without an offset is read in the zone.** `"2026-07-10T09:00"` means 9am
BST and is stored as 08:00 UTC; `"2026-12-10T09:00"` means 9am GMT. The offset is worked out
for the date you send, not for today, so a July time sent in December is still BST. A value with
`Z` or an explicit offset is taken exactly as written.

**A bare date is midnight in the zone.** `"2026-07-10"` is read as 00:00 on 10 July in your
timezone (23:00 UTC the evening before, in summer), which is exactly what the CRM's own date
pickers store, so the CRM and the API agree which day it is. It comes back as
`2026-07-10T00:00:00.000+01:00`. Fields staff usually set by date — `expectedCloseDate`, and
`soldAt`, `lostAt`, `onHoldAt` — are still full timestamps, because the CRM also sets them
automatically with a real time when a stage changes. Read them by their date part.

Accepted input forms: `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, `YYYY-MM-DDTHH:MM:SS`, optionally with
milliseconds and `Z` or `±HH:MM`. Anything else, including `10/07/2026`, is a 422 saying so.

## Option values [#option-values]

Fields that map to a dropdown in the CRM (`leadType`, `marketing`, `country`, `assignedTo`, and
so on) accept **either the option's `id` or its name**. Names match case-insensitively and ignore
surrounding whitespace. A name that matches more than one option is a 422 asking for the id.
[`GET /v1/leads/fields`](/docs/leads/fields) lists every option with both.

An empty string means "not set", the same as leaving the field out, because HTML forms send `""`
for an unselected dropdown.

## Booleans [#booleans]

`true`, `"true"`, `"yes"`, `"y"`, `"1"`, `"on"` and `1` are all true. Anything else is false.

## Errors [#errors]

Every error has the same shape:

```json
{
  "error": "Invalid lead payload",
  "fields": {
    "email": "Must be a valid email address",
    "leadType": "No lead.type option named \"Windows & Doors\""
  }
}
```

`error` is a sentence for a human. `fields`, when present, is keyed by the offending property or
query parameter and says what to change. Some responses add a `hint`.

| Status | Meaning                                                                | What to do                                 |
| ------ | ---------------------------------------------------------------------- | ------------------------------------------ |
| 200    | Done. On a create with a reference already seen, `created: false`      | —                                          |
| 201    | Created                                                                | —                                          |
| 400    | The body could not be read (malformed JSON or multipart)               | Fix the request                            |
| 401    | Key missing, invalid or expired                                        | See [Authentication](/docs/authentication) |
| 403    | The key is valid but this action is not allowed on this record         | Read the `error`                           |
| 404    | No such record in your organisation, or no such route                  | Check the id                               |
| 413    | A file or the request is over the size limit                           | Send less                                  |
| 415    | Wrong `Content-Type`                                                   | Read the endpoint's page                   |
| 422    | The request was understood but is invalid                              | Fix the field(s) named in `fields`         |
| 429    | Too many requests                                                      | Wait for `Retry-After` seconds             |
| 500    | Our fault                                                              | Retry later; we are alerted automatically  |
| 502    | The CRM behind the API did not answer                                  | **Safe to retry** — see below              |
| 503    | A feature is not enabled on this environment, or a query took too long | Read the `error`                           |

## Idempotency and retries [#idempotency-and-retries]

Network calls fail. The API is designed so that retrying is always safe:

* **`POST /v1/leads`** is idempotent on `reference`. Sending the same reference again returns the
  original lead with `200` and `created: false`. Nothing is duplicated.
* **`POST /v1/leads/{id}/attachments`** accepts an optional `Idempotency-Key` header. A replay
  with the same key returns the original files without storing anything.
* **`PUT /v1/leads/{id}/stage`** to a stage the lead is already in returns `changed: false`.
* **`DELETE`** of something already deleted is a 404, which your code can treat as success.

A **502** means the API could not reach the CRM. The request was not applied; repeat it as sent.
A **500** may or may not have been applied, which is why the operations above are built so that
repeating them is harmless.

## Audit trail [#audit-trail]

Everything the API changes is recorded in the CRM's audit log exactly as a staff change would
be: lead and customer creation, every field a patch touches, stage moves, assignments, and
files added or removed. Each entry is marked as made through the API, shows which API key made
it by name, and is attributed to the user who created that key. Name keys after the
integration they belong to, so staff can read the log.

The same goes for the CRM's search: every record the API creates or changes is re-indexed at
once. Endpoint pages take both for granted and do not repeat it.

## Caching [#caching]

Reference data (`/v1/leads/fields`, `/v1/users`) is served with `Cache-Control: private,
max-age=300`. Fetch it when your integration starts and after any 422, not on every request.
Everything else is `no-store`.

## Rate limits [#rate-limits]

None are enforced today. Design for 60 requests per minute per key; when limiting is switched on
it will be announced in the changelog and enforced with 429 and `Retry-After`.

## Test data [#test-data]

`test: true` on a lead marks it as test data in the CRM and excludes it from reporting. Lists
exclude test leads unless you ask for them with `test=include` or `test=only`.

---

# Create a lead

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/create

POST /v1/leads — one call creates the customer, contact and lead, idempotently.

## POST Create a lead [#create-a-lead]

`POST /v1/leads`

Creates the customer, contact and lead in one call. The lead lands in your default lead workflow
at its first stage, with the CRM's usual on-creation automations (stage actions, team
assignment, notifications). If a customer with the same email **and** phone already exists, the
lead is attached to them rather than creating a duplicate; otherwise a new customer and contact
are created.

The call is idempotent on `reference`: the same reference returns the same lead with
`created: false`, so retries are safe.

### Requirements [#requirements]

Three rules a single field cannot express. A body that breaks one is a 422 naming the field.

* `reference` is required.
* A name: `firstName` and/or `lastName`, or `companyName`.
* A way to contact them: at least one of `email`, `phoneNumber`, `mobile`.

Everything else is optional. Send what you have. [`GET /v1/leads/fields`](/docs/leads/fields)
is the authoritative list, generated from the validator, with the valid values for every option
field.

**Request body · identity**

- `reference` (string · max 200, required): Your own id for this submission, unique per submission, such as your form's row id. The same reference returns the same lead.

- `source` (string · max 100, default public-api): A label for where the lead came from. Stored on the lead and filterable. Idempotency is per `reference` within a `source`.

- `test` (boolean, default false): Marks the lead as test data, excluded from reporting and from lists unless asked for.

**Request body · person**

- `firstName` (string · max 100, one of firstName, lastName, companyName required): 

- `lastName` (string · max 100, one of firstName, lastName, companyName required): 

- `companyName` (string · max 250, one of firstName, lastName, companyName required): 

- `title` (option): An option's `id` or name, from the CRM's title list.

- `customerType` (option): An option's `id` or name.

**Request body · contact**

- `email` (string · email · max 255, one of email, phoneNumber, mobile required): 

- `phoneNumber` (string · max 50, one of email, phoneNumber, mobile required): Normalised: spaces, dashes and brackets are removed and `+44` becomes `0`.

- `mobile` (string · max 50, one of email, phoneNumber, mobile required): Normalised as `phoneNumber`. `07…` numbers are mobiles.

- `address` (string · max 250): 

- `address2` (string · max 250): 

- `address3` (string · max 250): 

- `town` (string · max 100): 

- `county` (string · max 100): 

- `postcode` (string · max 20): Upper-cased.

- `country` (option): An option's `id` or name.

- `what3Words` (string · max 100): 

**Request body · consent**

- `marketingEmail` (boolean): See [booleans](/docs/conventions#booleans).

- `marketingSms` (boolean): 

- `marketingPost` (boolean): 

**Request body · attribution**

- `marketing` (option): A marketing source's `id` or name, from your organisation's list.

- `subSourceCampaign` (option · depends on marketing): A campaign's `id` or name. Sent alone, its source is filled in; sent with `marketing`, they must agree. Campaign names repeat under every source, so a campaign sent by name is looked up within the `marketing` you sent; alone it is usually ambiguous and you will be asked for its id.

- `leadType` (option): An option's `id` or name, such as `Windows`.

- `mainInterest` (option): 

- `salesArea` (option): 

- `leadProductTypes` (option[] · max 50): Shares its list with `mainInterest`; that is how the CRM models it.

**Request body · property**

- `propertyType` (option): 

- `propertyCategory` (option): 

- `planningRequired` (option): 

- `planningType` (option): 

- `propertyOther` (string · max 250): 

- `yearBuilt` (integer · 1000 to 2200): 

**Request body · assignment and free text**

- `assignedTo` (string · user id or email): Who holds the lead. See [Users](/docs/users). An unknown or disabled user is a 422.

- `message` (string · max 5000): The customer's message.

- `advancedData` (object): Any JSON object, kept as sent.

- `expectedCloseDate` (string · date or date-time): Read in your [timezone](/docs/conventions#dates-and-timezones).

**Deprecated aliases**

- `id` (string · max 200): Use `reference`.

- `name` (string · max 200): Use `firstName` and `lastName`.

- `phone` (string · max 50): Use `phoneNumber` or `mobile`.

- `emailMarketing` (boolean): Use `marketingEmail`.

- `smsMarketing` (boolean): Use `marketingSms`.

Option fields take an option's `id` or its name: `"leadType": "Repairs"` and
`"leadType": "3340ec93-…"` are the same. See [Conventions](/docs/conventions#option-values).
Unknown properties in the body are ignored; unknown query parameters on `GET` are not.

**Responses**

- `201`: Created.

- `200`: This `reference` was seen before; the original lead is returned with `created: false`.

- `422`: Invalid payload, a broken requirement, or an option that does not resolve. `fields` names each offender.

- `502`: The CRM did not answer. Nothing was created; retry the same request.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "reference": "web-form-8812",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "mobile": "07700 900123",
    "address": "1 High Street",
    "town": "Reading",
    "postcode": "rg1 1aa",
    "leadType": "Windows",
    "marketing": "Website",
    "marketingEmail": true,
    "message": "Please call after 5pm",
    "assignedTo": "tuan@example.com"
  }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    reference: 'web-form-8812',
    firstName: 'Jane',
    lastName: 'Smith',
    email: 'jane@example.com',
    mobile: '07700 900123',
    address: '1 High Street',
    town: 'Reading',
    postcode: 'rg1 1aa',
    leadType: 'Windows',
    marketing: 'Website',
    marketingEmail: true,
    message: 'Please call after 5pm',
    assignedTo: 'tuan@example.com',
  }),
});
if (!res.ok) throw new Error(`${res.status}: ${JSON.stringify(await res.json())}`);
const { created, lead } = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/leads',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={
        'reference': 'web-form-8812',
        'firstName': 'Jane',
        'lastName': 'Smith',
        'email': 'jane@example.com',
        'mobile': '07700 900123',
        'address': '1 High Street',
        'town': 'Reading',
        'postcode': 'rg1 1aa',
        'leadType': 'Windows',
        'marketing': 'Website',
        'marketingEmail': True,
        'message': 'Please call after 5pm',
        'assignedTo': 'tuan@example.com',
    },
)
res.raise_for_status()
lead = res.json()
```

**Example response · 201**

```json
{
  "created": true,
  "reference": "web-form-8812",
  "lead": { "uuid": "1e4e3f90-4384-42ce-90c3-540db5343964", "code": "L013898" },
  "customer": { "uuid": "7b1d…", "code": "CUS007378" }
}
```

## Migrating from the old webhook [#migrating-from-the-old-webhook]

The `webhooks.evacrm.co.uk` intake accepted nine fields: `id`, `name`, `email`, `phone`,
`postcode`, `message`, `smsMarketing`, `emailMarketing`. All are still accepted here with the
same meaning, so an existing integration can switch the URL and add the key header without
other changes. `GET /v1/leads/fields` lists them as `deprecated` with `replacedBy`.

Two long-standing bugs are fixed in the process: real JSON booleans (`"emailMarketing": true`)
were recorded as false by the old intake, and `+447…` numbers were filed as landlines. Prefer
the explicit fields (`firstName`/`lastName`, `mobile`/`phoneNumber`, `marketingEmail`) for
anything new.

---

# Update a lead

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/update

PATCH /v1/leads/{id} — change only the fields you send.

## PATCH Update a lead [#update-a-lead]

`PATCH /v1/leads/{id}`

Sends only what changes. No field is required, but the body must hold at least one. Fields you
leave out are untouched; `null` (or an empty string) clears one. The response is the updated
lead in the same shape as [`GET /v1/leads`](/docs/leads/list).

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

**Headers**

- `If-Unmodified-Since` (string · date-time): The `updatedAt` you last read. If staff have edited the lead since, the patch is refused with a 409 and the current `updatedAt`, so you can re-read and decide. Without it the patch always applies.

**Request body · person**

- `firstName` (string · max 100, or null): The customer must keep a name: one of `firstName`, `lastName` or `companyName`.

- `lastName` (string · max 100, or null): 

- `companyName` (string · max 250, or null): 

- `title` (option · id or name, or null): 

- `customerType` (option · id or name, or null): 

**Request body · contact**

- `email` (string · email · max 255, or null): The customer must keep a way to be contacted: one of `email`, `phoneNumber` or `mobile`.

- `phoneNumber` (string · max 50, or null): Normalised as on create.

- `mobile` (string · max 50, or null): Normalised as on create.

- `address` (string · max 250, or null): 

- `address2` (string · max 250, or null): 

- `address3` (string · max 250, or null): 

- `town` (string · max 100, or null): 

- `county` (string · max 100, or null): 

- `postcode` (string · max 20, or null): Upper-cased.

- `country` (option · id or name, or null): 

- `what3Words` (string · max 100, or null): 

**Request body · consent**

- `marketingEmail` (boolean): 

- `marketingSms` (boolean): 

- `marketingPost` (boolean): 

**Request body · attribution**

- `marketing` (option · id or name, or null): Clearing it clears `subSourceCampaign` with it.

- `subSourceCampaign` (option · id or name, or null): Sent alone, moves the lead to that campaign's source. By name it is looked up within the current or sent `marketing`.

- `leadType` (option · id or name, or null): 

- `mainInterest` (option · id or name, or null): 

- `salesArea` (option · id or name, or null): 

- `leadProductTypes` (option[] · max 50, or null): Replaces the whole list.

**Request body · property**

- `propertyType` (option · id or name, or null): 

- `propertyCategory` (option · id or name, or null): 

- `planningRequired` (option · id or name, or null): 

- `planningType` (option · id or name, or null): 

- `propertyOther` (string · max 250, or null): 

- `yearBuilt` (integer · 1000 to 2200, or null): 

**Request body · assignment and free text**

- `assignedTo` (string · user id or email, or null): Reassigns and notifies the new user; `null` unassigns.

- `message` (string · max 5000, or null): 

- `advancedData` (object, or null): 

- `expectedCloseDate` (string · date or date-time, or null): Read in your [timezone](/docs/conventions#dates-and-timezones).

- `test` (boolean): 

Types, limits and valid values are exactly those of [create](/docs/leads/create): option fields
take an `id` or name, dates are read in your
[timezone](/docs/conventions#dates-and-timezones), phone numbers and postcodes are normalised.
The identity fields cannot change: `reference`, `source`, and the old webhook's aliases (`id`,
`name`, `phone`, `emailMarketing`, `smsMarketing`) are refused.

Any patch bumps the lead's `updatedAt`, even when only customer or contact fields
changed, so a sync using `updatedSince` sees it. Changing the stage is a separate call:
[move to a stage](/docs/leads/stages#move-to-a-stage).

**Responses**

- `200`: Applied. `changed` lists what moved; an empty list means the values already matched.

- `404`: No such lead in your organisation.

- `409`: `If-Unmodified-Since` is older than the lead's `updatedAt`.

- `422`: Unknown field, invalid value, unresolvable option, or a rule above broken. `fields` says which.

- `502`: The CRM did not answer. Nothing changed; repeat the request.

**Example request**

*curl*

```bash
curl -X PATCH https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964 \
  -H "Authorization: Bearer sk_..." \
  -H "If-Unmodified-Since: 2026-09-02T11:14:03.512+01:00" \
  -H "Content-Type: application/json" \
  -d '{
    "mobile": "07700 900456",
    "leadType": "Doors",
    "expectedCloseDate": "2026-10-01",
    "assignedTo": "sam@example.com",
    "companyName": null
  }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.EVA_API_KEY}`,
    'Content-Type': 'application/json',
    'If-Unmodified-Since': '2026-09-02T11:14:03.512+01:00',
  },
  body: JSON.stringify({
    mobile: '07700 900456',
    leadType: 'Doors',
    expectedCloseDate: '2026-10-01',
    assignedTo: 'sam@example.com',
    companyName: null,
  }),
});
if (res.status === 409) {
  // someone edited it first — re-read and decide
}
const { changed, lead } = await res.json();
```

*Python*

```python
import os, requests

res = requests.patch(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964',
    headers={
        'Authorization': f"Bearer {os.environ['EVA_API_KEY']}",
        'If-Unmodified-Since': '2026-09-02T11:14:03.512+01:00',
    },
    json={
        'mobile': '07700 900456',
        'leadType': 'Doors',
        'expectedCloseDate': '2026-10-01',
        'assignedTo': 'sam@example.com',
        'companyName': None,
    },
)
res.raise_for_status()
result = res.json()
```

**Example response · 200**

```json
{
  "changed": ["mobile", "leadType", "expectedCloseDate", "assignedTo", "companyName"],
  "lead": { "id": "1e4e3f90-…", "code": "L013898", "…": "…" }
}
```

---

# List leads

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/list

GET /v1/leads — filter, page and keep a mirror in sync.

## GET List leads [#list-leads]

`GET /v1/leads`

Leads in your organisation, newest first, with cursor paging. **Unknown parameters are a 422**
naming each one: a silently ignored filter is how an integration confidently mirrors the wrong
rows.

**Query parameters**

- `limit` (integer · 1 to 100, default 50): Out of range is a 422, never silently clamped.

- `cursor` (string): From a previous `nextCursor`. Opaque; never construct one.

- `order` (string, default createdAt:desc): See [paging](#paging) for what each guarantees.

- `createdSince` (string · date or date-time): Read in your [timezone](/docs/conventions#dates-and-timezones) unless it carries an offset.

- `createdBefore` (string · date or date-time): Exclusive, so adjacent windows tile without overlap.

- `updatedSince` (string · date or date-time): Everything changed since then. Implies `order=updatedAt:asc`.

- `reference` (string · max 255): Exact match on the reference you sent on create.

- `externalRef` (string · max 255): Exact match.

- `source` (string · max 100): Exact match on the `source` label.

- `code` (string · max 64): The CRM's lead number, such as `L013898`.

- `status` (string · list): One or several, comma-separated or repeated. All by default.

- `test` (string, default exclude): Whether test leads are in the list.

- `assignedTo` (string): A user's `id` or email, or `none` for leads nobody holds. See [Users](/docs/users).

- `stage` (string · list): A stage `id` or name, one or several. A name matches every workflow that has it, so `stage=Quote Sent` covers that stage in each of your lead workflows; add `workflow` to narrow it. Case-insensitive; an unknown name is a 422. [List workflows](/docs/leads/stages#list-workflows) for the names and ids.

- `workflow` (string · list): A workflow `id` or name, one or several. On its own, every lead in those workflows; with `stage`, only that stage within them.

- `daysInStage` (string): Whole days the lead has been in its current stage. A bare number is *at least*, so `14` is two weeks or more. Prefix for the rest: `eq:14`, `gt:14`, `gte:14`, `lt:14`, `lte:14`. Counted from `stageChangedAt`, or `createdAt` when the stage has never changed, as of each request.

**Responses**

- `200`: A page of leads, `hasMore`, and `nextCursor` to fetch the next one.

- `422`: An unknown parameter, a value out of range, or a cursor that no longer matches the filters. `fields` names it.

**Example request**

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/leads?status=open&limit=50" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/leads');
url.searchParams.set('status', 'open');
url.searchParams.set('limit', '50');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data, hasMore, nextCursor } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads',
    params={'status': 'open', 'limit': 50},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
page = res.json()
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "1e4e3f90-4384-42ce-90c3-540db5343964",
      "code": "L013898",
      "reference": "web-form-8812",
      "externalRef": null,
      "source": "public-api",
      "status": "open",
      "stage": { "id": "90ee4027-…", "name": "Unassigned & Unappointed" },
      "workflow": { "id": "…", "name": "Leads" },
      "stageChangedAt": "2026-09-02T11:14:03.512+01:00",
      "daysInStage": 2,
      "assigned": true,
      "assignee": { "id": "cd9356b7-…", "name": "Tuan Dinh", "email": "tuan@example.com" },
      "assignedTeam": null,
      "converted": false,
      "appointmentCompleted": false,
      "test": false,
      "message": "Please call after 5pm",
      "messageTruncated": false,
      "leadType": "Windows",
      "mainInterest": null,
      "productTypes": [],
      "property": {
        "type": null, "category": null, "planningRequired": null, "planningType": null,
        "other": null, "yearBuilt": null
      },
      "marketing": { "source": "Website", "campaign": null },
      "customer": {
        "id": "7b1d…", "code": "CUS007378",
        "firstName": "Jane", "lastName": "Smith", "fullName": "Jane Smith", "companyName": null,
        "consents": {
          "doNotContact": false, "noMarketing": false,
          "marketingEmail": true, "marketingSms": false, "marketingPost": false
        }
      },
      "contact": {
        "id": "…", "email": "jane@example.com", "phoneNumber": null, "mobile": "07700900123",
        "address": "1 High Street", "address2": null, "address3": null,
        "town": "Reading", "county": null, "postcode": "RG1 1AA", "country": "United Kingdom"
      },
      "createdAt": "2026-09-02T11:14:03.512+01:00",
      "updatedAt": "2026-09-02T11:14:03.512+01:00",
      "soldAt": null, "lostAt": null, "onHoldAt": null,
      "expectedCloseDate": null,
      "importedAt": null
    }
  ],
  "hasMore": true,
  "nextCursor": "eyJ2IjoxLCJvIjoi…"
}
```

**Example · quotes sent with no reply for two weeks**

The stage's name is what the CRM shows; it is matched in every lead workflow that has it.

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/leads?stage=Quote%20Sent&daysInStage=14&status=open" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/leads');
url.searchParams.set('stage', 'Quote Sent');
url.searchParams.set('daysInStage', '14');
url.searchParams.set('status', 'open');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data } = await res.json();
for (const lead of data) console.log(lead.code, lead.customer?.fullName, `${lead.daysInStage} days`);
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads',
    params={'stage': 'Quote Sent', 'daysInStage': 14, 'status': 'open'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
for lead in res.json()['data']:
    print(lead['code'], lead['customer']['fullName'], lead['daysInStage'], 'days')
```

* `status` is one of `open`, `sold`, `lost`, `on_hold`, derived from the lead's stage category
  and flags so the three can never contradict each other.
* `daysInStage` is the whole days since `stageChangedAt`, the same measure the `daysInStage`
  filter uses, so a row always explains why it matched.
* `customer.consents` is there so that you never contact someone who opted out using data we
  handed you. Honour it.
* `message` is capped at 5,000 characters; `messageTruncated` says if more exists in the CRM.
* Every timestamp carries your organisation's timezone offset; see [dates and timezones](/docs/conventions#dates-and-timezones).
* There is &#x2A;*no `total`**. Page until `hasMore` is false.

## Paging [#paging]

Follow `nextCursor` until `hasMore` is `false`. Cursors are keyset, not offset, so a page never
skips or repeats a row even while leads are being created. Changing filters or `order`
mid-crawl invalidates the cursor (422); `limit` can change freely.

| `order`          | Guarantee                                                                                                                                                               |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createdAt:desc` | Every lead that existed when you started, exactly once. Leads created during the crawl are ahead of your start point; start a fresh crawl to see them.                  |
| `createdAt:asc`  | The same exactly-once property from oldest forward. Misses **edits** to leads already passed. Use for a one-off backfill.                                               |
| `updatedAt:asc`  | **Sync mode.** Any edit moves a lead ahead of the cursor, so changes are not missed, but a lead you have seen can be re-emitted. &#x2A;*Upsert by `id`, never append.** |

`updatedAt:desc` is deliberately not offered; paging backwards down a column that changes under
you skips rows permanently.

### Keeping a mirror [#keeping-a-mirror]

Poll `GET /v1/leads?updatedSince=<watermark>` (which implies `order=updatedAt:asc`), upsert by
`id`, and advance the watermark to the last `updatedAt` you processed. Timestamps are written by
application processes rather than in commit order, so a long-running bulk import in the CRM can
commit rows stamped slightly *earlier* than your watermark. Resume from `watermark − 60s` and
tolerate re-emits, and run a `createdAt:asc` backfill occasionally.

## What is not returned [#what-is-not-returned]

Deliberately withheld: numeric ids, all pricing (list, net, quoted, discounts, deposits),
`advancedData` (it carries the price calculation), installation instructions (routinely contain
gate codes and similar), lost reasons, and geolocation. Staff identities are limited to the
assignee. If your use case needs any of these, talk to us rather than working around it.

## GET Get a lead [#get-a-lead]

`GET /v1/leads/{id}`

One lead by its `id`, in exactly the shape the list returns. To look one up by your own
reference or its CRM code, use the list: `?reference=web-form-8812` or `?code=L013899`.

**Path parameters**

- `id` (string · uuid, required): The lead's `id` from create or list.

**Responses**

- `200`: The lead.

- `404`: No such lead in your organisation, or the value is not an id.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/3738d84c-61ff-4d96-9d4d-cd87adc27a5f \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/3738d84c-61ff-4d96-9d4d-cd87adc27a5f', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const lead = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads/3738d84c-61ff-4d96-9d4d-cd87adc27a5f',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
lead = res.json()
```

**Example response · 200**

```json
{
  "id": "3738d84c-61ff-4d96-9d4d-cd87adc27a5f",
  "code": "L013899",
  "reference": "web-form-8812",
  "externalRef": null,
  "source": "public-api",
  "status": "open",
  "stage": {
    "id": "90ee4027-…",
    "name": "Unassigned & Unappointed"
  },
  "workflow": {
    "id": "…",
    "name": "Leads"
  },
  "stageChangedAt": "2026-09-02T11:14:03.512+01:00",
  "daysInStage": 2,
  "assigned": true,
  "assignee": {
    "id": "cd9356b7-…",
    "name": "Tuan Dinh",
    "email": "tuan@example.com"
  },
  "assignedTeam": null,
  "converted": false,
  "appointmentCompleted": false,
  "test": false,
  "message": "Please call after 5pm",
  "messageTruncated": false,
  "leadType": "Windows",
  "mainInterest": null,
  "productTypes": [],
  "property": {
    "type": null,
    "category": null,
    "planningRequired": null,
    "planningType": null,
    "other": null,
    "yearBuilt": null
  },
  "marketing": {
    "source": "Website",
    "campaign": null
  },
  "customer": {
    "id": "7b1d…",
    "code": "CUS007378",
    "firstName": "Jane",
    "lastName": "Smith",
    "fullName": "Jane Smith",
    "companyName": null,
    "consents": {
      "doNotContact": false,
      "noMarketing": false,
      "marketingEmail": true,
      "marketingSms": false,
      "marketingPost": false
    }
  },
  "contact": {
    "id": "…",
    "email": "jane@example.com",
    "phoneNumber": null,
    "mobile": "07700900123",
    "address": "1 High Street",
    "address2": null,
    "address3": null,
    "town": "Reading",
    "county": null,
    "postcode": "RG1 1AA",
    "country": "United Kingdom"
  },
  "createdAt": "2026-09-02T11:14:03.512+01:00",
  "updatedAt": "2026-09-02T11:14:03.512+01:00",
  "soldAt": null,
  "lostAt": null,
  "onHoldAt": null,
  "expectedCloseDate": null,
  "importedAt": null
}
```

---

# Field reference

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/fields

GET /v1/leads/fields — every field POST accepts, with limits and valid values, generated from the validator itself.

## GET List fields [#list-fields]

`GET /v1/leads/fields`

Describes every field [create a lead](/docs/leads/create) accepts, keyed by the exact property
name you send: type and limits for scalars, and the valid values for option fields. It is
generated from the same definitions the validator uses, so it cannot disagree with what the API
accepts. No parameters.

Fetch it once when your integration starts, and again when a `POST` comes back 422, rather than
hard-coding values. Cached for five minutes (`Cache-Control: private, max-age=300`). For a large
organisation the full response runs to a few hundred KB, most of it campaigns.

**Responses**

- `200`: `fields` keyed by property name, and `requirements`, the cross-field rules.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/fields \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/fields', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { fields, requirements } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/leads/fields', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
fields = res.json()['fields']
```

**Example response · 200**

```json
{
  "fields": {
    "reference":  { "type": "string", "required": true, "maxLength": 200 },
    "email":      { "type": "string", "maxLength": 255, "format": "email" },
    "yearBuilt":  { "type": "integer", "min": 1000, "max": 2200 },
    "expectedCloseDate": { "type": "date" },
    "marketingEmail":    { "type": "boolean" },
    "advancedData":      { "type": "object" },
    "leadType": {
      "type": "option",
      "options": [
        { "id": "3340ec93-2b79-4134-a2be-b5c890dd3f25", "name": "Repairs" },
        { "id": "0ef1e6df-6dcd-4e61-b8e4-6218257bcc0d", "name": "Conservatories" }
      ]
    },
    "leadProductTypes": { "type": "option", "multiple": true, "maxItems": 50, "options": [ "…" ] },
    "marketing": {
      "type": "option",
      "options": [ { "id": "115f1cc5-…", "name": "Website" } ]
    },
    "subSourceCampaign": {
      "type": "option",
      "dependsOn": "marketing",
      "options": [ { "id": "339b463f-…", "name": "2021/05 May", "marketing": "115f1cc5-…" } ]
    },
    "assignedTo": {
      "type": "option",
      "options": [ { "id": "cd9356b7-…", "name": "Tuan Dinh", "email": "tuan@example.com" } ]
    },
    "name": { "type": "string", "maxLength": 200, "deprecated": true, "replacedBy": ["firstName", "lastName"] }
  },
  "requirements": [
    "reference",
    "one of: firstName, lastName, companyName",
    "one of: email, phoneNumber, mobile"
  ]
}
```

### Reading it [#reading-it]

| `type`    | Extra keys                                      | Send                                                         |
| --------- | ----------------------------------------------- | ------------------------------------------------------------ |
| `string`  | `maxLength`, `required`, `format` (`email`)     | A string                                                     |
| `integer` | `min`, `max`                                    | A number, or a numeric string                                |
| `boolean` | —                                               | See [booleans](/docs/conventions#booleans)                   |
| `date`    | —                                               | ISO 8601                                                     |
| `object`  | —                                               | Any JSON object                                              |
| `option`  | `options`, `multiple` + `maxItems`, `dependsOn` | An option's `id` or `name`; an array of them when `multiple` |

* `options` are the values valid **for your organisation**. `title`, `country`, `leadType` and
  the other CRM-wide lists are the same for everyone; `marketing`, `subSourceCampaign` and
  `assignedTo` are yours, active ones only.
* `subSourceCampaign` options carry the `id` of their `marketing` source, so a form can filter
  campaigns once a source is picked. `dependsOn` says which field that is.
* `leadProductTypes` shares its list with `mainInterest`; that is how the CRM models it.
* `deprecated` fields are the old webhook's names. They still work; new integrations should use
  `replacedBy`.
* `requirements` are the cross-field rules a single field cannot express.

## GET Get a field [#get-a-field]

`GET /v1/leads/fields/{field}`

One field, in the same shape, with `field` added.

**Path parameters**

- `field` (string, required): The property name as you send it, such as `subSourceCampaign` or `assignedTo`.

**Responses**

- `200`: The field's description.

- `404`: Not a field the API accepts, with a hint.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/fields/subSourceCampaign \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/fields/subSourceCampaign', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const field = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/leads/fields/subSourceCampaign', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
field = res.json()
```

**Example response · 200**

```json
{ "field": "subSourceCampaign", "type": "option", "dependsOn": "marketing", "options": [ "…" ] }
```

---

# Attachments

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/attachments

Photos and PDFs on a lead, stored and displayed exactly like a staff upload in the CRM.

## POST Upload attachments [#upload-attachments]

`POST /v1/leads/{id}/attachments`

Attaches up to ten files to a lead in one `multipart/form-data` request. They are stored and
shown in the CRM exactly like a staff upload, in the photo view for images. Files cannot be sent
with the lead payload: create the lead first, then post the files to its `id`.

**Path parameters**

- `id` (string · uuid, required): The lead's `id` from create or list.

**Headers**

- `Idempotency-Key` (string · max 200): Makes a retry safe: the same key on the same lead returns 200 with `created: false` and the files from the first call, without storing anything. Send the retry exactly as before. Recommended.

**Request body**

- `files` (file · 1 to 10, required): Repeat the part for several files. JPEG, PNG, WebP, GIF, HEIC/HEIF and PDF, decided by the file's content, not its name. 25 MB each, 50 MB per request.

- `documentType` (string · id or name): Files the whole batch under a document type from [list categories](#list-categories); the category comes with the type. Images without one go to the organisation's Photos category, as the CRM does; anything else is left uncategorized.

- `comment` (string): Applies to every file in the batch.

**Responses**

- `201`: Stored. `data` lists the files in the order sent, with signed `urls` that expire at `urlsExpireAt`.

- `200`: The `Idempotency-Key` was seen before on this lead; the first upload's files, `created: false`.

- `404`: No such lead in your organisation.

- `413`: Too many files, a file over 25 MB, or a request over 50 MB.

- `422`: A file of a type not accepted, named; or a `documentType` that is unknown, ambiguous, or restricted to named staff. Nothing from the request is stored.

- `503`: Attachments are not enabled on this environment. Leads keep working.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments \
  -H "Authorization: Bearer sk_..." \
  -H "Idempotency-Key: order-8812-photos" \
  -F "files=@front.jpg" -F "files=@survey.pdf" -F "comment=Site survey" -F "documentType=Survey report"
```

*Node.js*

```js
import { openAsBlob } from 'node:fs';

const form = new FormData();
form.append('files', await openAsBlob('front.jpg'), 'front.jpg');
form.append('files', await openAsBlob('survey.pdf'), 'survey.pdf');
form.append('comment', 'Site survey');
form.append('documentType', 'Survey report');

const res = await fetch('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Idempotency-Key': 'order-8812-photos' },
  body: form,
});
const { created, data } = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}", 'Idempotency-Key': 'order-8812-photos'},
    files=[('files', open('front.jpg', 'rb')), ('files', open('survey.pdf', 'rb'))],
    data={'comment': 'Site survey', 'documentType': 'Survey report'},
)
res.raise_for_status()
uploaded = res.json()['data']
```

**Example response · 201**

```json
{
  "created": true,
  "lead": { "id": "1e4e3f90-4384-42ce-90c3-540db5343964", "code": "L013898" },
  "data": [
    {
      "id": "9c1b6e4a-…",
      "attachmentId": "5d0f2c77-…",
      "name": "front.jpg",
      "type": "image",
      "mimeType": "image/jpeg",
      "size": 2481733,
      "photoNo": "001",
      "comment": "Site survey",
      "category": { "id": "6d2a…", "name": "Surveys" },
      "documentType": { "id": "b41c…", "name": "Survey report" },
      "source": "api",
      "createdAt": "2026-09-02T11:14:03.512+01:00",
      "urls": {
        "download": "https://…",
        "thumbnail": "https://…",
        "medium": "https://…",
        "large": "https://…"
      },
      "urlsExpireAt": "2026-09-02T12:14:03.512+01:00"
    },
    {
      "id": "…", "name": "survey.pdf", "type": "document", "mimeType": "application/pdf",
      "size": 88213, "photoNo": null, "urls": { "download": "https://…" }
    }
  ]
}
```

### What is accepted [#what-is-accepted]

The type of each file is decided by its **content**, not its name or the `Content-Type` of the
part. Accepted: JPEG, PNG, WebP, GIF, HEIC/HEIF and PDF. SVG is not. A file that does not match
is a **422** naming it, and nothing from that request is stored. `name` comes back with the
extension the bytes warrant.

### What happens to images [#what-happens-to-images]

Images get the same thumbnail, medium and large versions the CRM makes for its own uploads, so
they appear in the CRM's photo view like any staff upload. `photoNo` is the CRM's per-lead
numbering (`L013898-001.jpg`), reserved in order within the request. HEIC is converted to JPEG on
arrival. Image metadata, including GPS position if present, is kept, as it is for staff uploads;
strip it before sending if you do not want it stored.

### Links [#links]

`urls` are signed and expire at `urlsExpireAt`, an hour after the response. They are for
fetching now, not storing; `GET` the list again for fresh ones.

### Filing [#filing]

The CRM files documents in **categories** (folders, which can be nested) containing **document
types**. Send `documentType` as a type's `id` or name and the batch is filed under it; the
category comes with the type, since every type belongs to exactly one. Names match
case-insensitively; a name that exists under more than one category is a 422 asking for the id.
A type the CRM restricts to named staff is refused, because files under it would be hidden from
this API afterwards. Both come back as `category` and `documentType` on every file, `null` when
unset.

## GET List categories [#list-categories]

`GET /v1/leads/attachments/categories`

The document categories and types your organisation offers on a lead, archived ones excluded,
each type marked `restricted` when the CRM limits it to named staff. `parent` links a nested
category to the one it sits in. No parameters. Cached for five minutes.

**Responses**

- `200`: The categories, each with its `types`.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/attachments/categories \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/attachments/categories', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: categories } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/leads/attachments/categories', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
categories = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "6d2a…",
      "name": "Surveys",
      "description": null,
      "parent": null,
      "types": [
        { "id": "b41c…", "name": "Survey report", "description": null, "restricted": false },
        { "id": "0f9e…", "name": "Structural calculations", "description": null, "restricted": true }
      ]
    },
    { "id": "7c11…", "name": "Photos", "description": null, "parent": null, "types": [] }
  ]
}
```

## GET List attachments [#list-attachments]

`GET /v1/leads/{id}/attachments`

Everything attached to the lead, whoever uploaded it, newest first, in the file shape above.
`source` says where a file came from: `api`, `web` (the CRM) or `mobile`. Files filed under a
document type the CRM restricts to named staff are omitted. Capped at 500, not paginated.

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

**Responses**

- `200`: `data`, the files, each with fresh signed `urls`.

- `404`: No such lead in your organisation.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: files } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
files = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "9c1b6e4a-2f7d-4c3e-8a1b-5d6e7f8a9b0c",
      "attachmentId": "5d0f2c77-…",
      "name": "front.jpg",
      "type": "image",
      "mimeType": "image/jpeg",
      "size": 2481733,
      "photoNo": "001",
      "comment": "Site survey",
      "category": {
        "id": "6d2a…",
        "name": "Surveys"
      },
      "documentType": {
        "id": "b41c…",
        "name": "Survey report"
      },
      "source": "api",
      "createdAt": "2026-09-02T11:14:03.512+01:00",
      "urls": {
        "download": "https://…",
        "thumbnail": "https://…",
        "medium": "https://…",
        "large": "https://…"
      },
      "urlsExpireAt": "2026-09-02T12:14:03.512+01:00"
    },
    {
      "id": "…",
      "attachmentId": "5d0f2c77-…",
      "name": "survey.pdf",
      "type": "document",
      "mimeType": "application/pdf",
      "size": 88213,
      "photoNo": null,
      "comment": "Site survey",
      "category": {
        "id": "6d2a…",
        "name": "Surveys"
      },
      "documentType": {
        "id": "b41c…",
        "name": "Survey report"
      },
      "source": "api",
      "createdAt": "2026-09-02T11:14:03.512+01:00",
      "urls": {
        "download": "https://…"
      },
      "urlsExpireAt": "2026-09-02T12:14:03.512+01:00"
    }
  ]
}
```

## DELETE Delete an attachment [#delete-an-attachment]

`DELETE /v1/leads/{id}/attachments/{fileId}`

Removes one file, its derivatives, and its entry in the CRM. Only files that came through this
API can be deleted through it.

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

- `fileId` (string · uuid, required): The file's `id` from the upload or list response.

**Responses**

- `200`: Deleted. The body carries `deleted: true` and the file id.

- `403`: The file was uploaded by staff, not through the API.

- `404`: No such file on this lead, or it was already deleted.

**Example request**

*curl*

```bash
curl -X DELETE https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments/9c1b6e4a-2f7d-4c3e-8a1b-5d6e7f8a9b0c \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments/9c1b6e4a-2f7d-4c3e-8a1b-5d6e7f8a9b0c', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { deleted } = await res.json();
```

*Python*

```python
import os, requests

res = requests.delete(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/attachments/9c1b6e4a-2f7d-4c3e-8a1b-5d6e7f8a9b0c',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
```

**Example response · 200**

```json
{
  "deleted": true,
  "id": "9c1b6e4a-2f7d-4c3e-8a1b-5d6e7f8a9b0c"
}
```

---

# Notes and tasks

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/notes-and-tasks

Read the notes written against a lead and the tasks raised on it, as staff see them in the CRM.

Notes and tasks are read-only through the API. Staff write them in the CRM, and the CRM adds its
own: an email logged against the lead becomes an auto-generated note, and a stage move can
raise a task or leave a note when the workflow is set up that way. Contracts have the same two
endpoints under `/v1/contracts/{id}`; see [contracts](/docs/contracts#notes-and-tasks).

## GET List notes [#list-notes]

`GET /v1/leads/{id}/notes`

Every note on the lead, newest first, with cursor paging. `body` is HTML as the CRM's editor
stores it. `stage` is where the lead was when the note was written.

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

**Query parameters**

- `limit` (integer · 1 to 100, default 50): Out of range is a 422, never silently clamped.

- `cursor` (string): From a previous `nextCursor`. Opaque; never construct one.

- `order` (string, default createdAt:desc): 

- `createdSince` (string · date or date-time): Read in your [timezone](/docs/conventions#dates-and-timezones) unless it carries an offset.

- `createdBefore` (string · date or date-time): Exclusive.

- `auto` (string, default include): Notes the CRM generated itself, chiefly logged emails. `exclude` leaves what people wrote.

- `internal` (string, default include): Staff-only notes the CRM keeps off customer-facing views. Honour the flag if you show notes to the customer.

- `pinned` (boolean): `true` for the pinned note only, `false` for the rest.

**Responses**

- `200`: `data`, the notes, with `hasMore` and `nextCursor`. The `lead` the notes belong to is echoed.

- `404`: No such lead in your organisation.

- `422`: An unknown parameter, a bad value, or a cursor that no longer matches the filters. `fields` names it.

**Example request**

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/notes?auto=exclude" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/notes');
url.searchParams.set('auto', 'exclude');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data: notes, hasMore, nextCursor } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/notes',
    params={'auto': 'exclude'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
notes = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "lead": { "id": "1e4e3f90-4384-42ce-90c3-540db5343964", "code": "L013898" },
  "data": [
    {
      "id": "6a2f9c1d-…",
      "body": "<p>Called, no answer. Left a voicemail about the revised quote.</p>",
      "action": "Call Mobile",
      "internal": false,
      "pinned": false,
      "autoGenerated": false,
      "stage": { "id": "c0d7…", "name": "Quote Sent" },
      "createdBy": { "id": "cd9356b7-…", "name": "Sam Rep", "email": "sam@example.com" },
      "externalRef": null,
      "source": null,
      "createdAt": "2026-09-02T14:05:11.208+01:00",
      "updatedAt": "2026-09-02T14:05:11.208+01:00"
    },
    {
      "id": "0b41…",
      "body": "Email received</br>From: jane@example.com</br>To: sam@example.com</br>Subject: Re: Your quotation</br>",
      "action": null,
      "internal": false,
      "pinned": false,
      "autoGenerated": true,
      "stage": { "id": "c0d7…", "name": "Quote Sent" },
      "createdBy": { "id": "cd9356b7-…", "name": "Sam Rep", "email": "sam@example.com" },
      "externalRef": null,
      "source": null,
      "createdAt": "2026-09-01T16:40:02.913+01:00",
      "updatedAt": "2026-09-01T16:40:02.913+01:00"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

* `action` is the note type staff picked, such as `Call Mobile`, `Email` or `Text Message`, or
  `null` for a plain note.
* `createdBy` is the staff member whose account wrote the note. For an auto-generated note that
  is whoever's mailbox or action produced it.
* Paging follows the same [guarantees](/docs/leads/list#paging) as leads: keyset cursors, and a
  422 if the filters change mid-crawl.

## GET List tasks [#list-tasks]

`GET /v1/leads/{id}/tasks`

Every task raised on the lead, soonest due first. A task is for one person or one team, and
`overdue` is set once an open task's due date is behind today in your timezone; a task due
today is not overdue yet.

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

**Query parameters**

- `limit` (integer · 1 to 100, default 50): 

- `cursor` (string): From a previous `nextCursor`.

- `order` (string, default dueDate:asc): 

- `status` (string · list): One or both. Both by default.

- `dueSince` (string · date or date-time): Due on or after. A due date set from the CRM's date picker is midnight in your timezone, so a bare date works; tasks can also carry a time.

- `dueBefore` (string · date or date-time): Exclusive. `status=open&dueBefore=<today>` is the overdue list.

**Responses**

- `200`: `data`, the tasks, with `hasMore` and `nextCursor`. The `lead` is echoed.

- `404`: No such lead in your organisation.

- `422`: An unknown parameter, a bad value, or a stale cursor. `fields` names it.

**Example request**

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/tasks?status=open" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/tasks');
url.searchParams.set('status', 'open');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data: tasks } = await res.json();
const overdue = tasks.filter((t) => t.overdue);
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/tasks',
    params={'status': 'open'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
overdue = [t for t in res.json()['data'] if t['overdue']]
```

**Example response · 200**

```json
{
  "object": "list",
  "lead": { "id": "1e4e3f90-4384-42ce-90c3-540db5343964", "code": "L013898" },
  "data": [
    {
      "id": "8d3e…",
      "body": "Chase the customer about the revised quote",
      "dueAt": "2026-09-03T00:00:00.000+01:00",
      "completed": false,
      "overdue": true,
      "completedAt": null,
      "completedBy": null,
      "assignee": { "id": "cd9356b7-…", "name": "Sam Rep", "email": "sam@example.com" },
      "assignedTeam": null,
      "autoCreated": false,
      "stage": { "id": "c0d7…", "name": "Quote Sent" },
      "createdBy": { "id": "7f1a…", "name": "Sales Manager", "email": "manager@example.com" },
      "externalRef": null,
      "source": null,
      "createdAt": "2026-09-01T09:12:44.001+01:00",
      "updatedAt": "2026-09-01T09:12:44.001+01:00"
    },
    {
      "id": "5c9b…",
      "body": "Book the survey",
      "dueAt": "2026-09-10T00:00:00.000+01:00",
      "completed": true,
      "overdue": false,
      "completedAt": "2026-09-02T10:30:15.442+01:00",
      "completedBy": { "id": "cd9356b7-…", "name": "Sam Rep", "email": "sam@example.com" },
      "assignee": null,
      "assignedTeam": { "id": "3b2c…", "name": "Surveyors" },
      "autoCreated": true,
      "stage": { "id": "…", "name": "Appointed" },
      "createdBy": { "id": "cd9356b7-…", "name": "Sam Rep", "email": "sam@example.com" },
      "externalRef": null,
      "source": null,
      "createdAt": "2026-08-30T15:00:00.000+01:00",
      "updatedAt": "2026-09-02T10:30:15.442+01:00"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

* `autoCreated` marks tasks a stage action raised rather than a person.
* `stage` is where the lead was when the task was raised, when the CRM recorded it.
* Task forms the CRM attaches to some tasks are not returned.

---

# Stages

URL: https://sandbox-api-docs.evacrm.co.uk/docs/leads/stages

See where a lead is in its workflow, and move it with the same rules the CRM applies.

## GET Get the workflow [#get-the-workflow]

`GET /v1/leads/{id}/stages`

The lead's workflow and every stage in it, in order, with the current one marked and
`allowedFromCurrent` saying where it can go next. Not cacheable; it depends on where the lead is
right now.

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

**Responses**

- `200`: The workflow, the current stage, and `data`, every stage in order.

- `404`: No such lead in your organisation.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/stages \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/stages', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { current, data: stages } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/stages',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
stages = res.json()['data']
```

**Example response · 200**

```json
{
  "lead": { "id": "1e4e3f90-…", "code": "L013898" },
  "workflow": { "id": "…", "name": "Leads" },
  "current": { "id": "90ee4027-…", "name": "Unassigned & Unappointed" },
  "data": [
    { "id": "90ee4027-…", "name": "Unassigned & Unappointed", "order": 1, "category": "Live", "current": true,  "allowedFromCurrent": false },
    { "id": "24c32379-…", "name": "Appointed", "order": 2, "category": "Live", "current": false, "allowedFromCurrent": true, "action": "Book appointment" },
    { "id": "bd7c3675-…", "name": "Sold",      "order": 3, "category": "Sold", "current": false, "allowedFromCurrent": true },
    { "id": "86b1e72e-…", "name": "Lost",      "order": 4, "category": "Lost", "current": false, "allowedFromCurrent": true }
  ]
}
```

* `allowedFromCurrent` follows the rule the CRM's own stage dropdown uses: the transitions your
  administrator configured from the current stage. A workflow with no transitions configured at
  all is free-form, and every other stage is allowed.
* `action` names what the CRM would prompt staff for on that move (an appointment, a note).
  The API moves the stage without it.
* `category` is the stage's kind. Moving into a `Sold` or `Lost` category stage flips the lead's
  `status`.
* `description` carries the administrator's note on the stage, when there is one.

## PUT Move to a stage [#move-to-a-stage]

`PUT /v1/leads/{id}/stage`

Moves the lead. The move runs through the CRM, so everything the CRM does on a stage change
happens: the stage history (marked as moved via the API), automatic tasks and notes configured
on the stage, and sold/lost flags.

**Path parameters**

- `id` (string · uuid, required): The lead's `id`.

**Request body**

- `stage` (string · id or name, required): A stage from [the workflow](#get-the-workflow). Names match case-insensitively within the lead's own workflow.

**Responses**

- `200`: Moved. Or, if the lead was already there, `changed: false` and nothing written.

- `404`: No such lead in your organisation.

- `422`: Unknown stage, or one not allowed from the current stage. `fields.stage` says which.

- `502`: The CRM did not answer. Nothing changed; repeat the request.

**Example request**

*curl*

```bash
curl -X PUT https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/stage \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "stage": "Appointed" }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/stage', {
  method: 'PUT',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ stage: 'Appointed' }),
});
const { changed, from, to } = await res.json();
```

*Python*

```python
import os, requests

res = requests.put(
    'https://api.evacrm.co.uk/v1/leads/1e4e3f90-4384-42ce-90c3-540db5343964/stage',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={'stage': 'Appointed'},
)
res.raise_for_status()
move = res.json()
```

**Example response · 200**

```json
{
  "changed": true,
  "lead": { "id": "1e4e3f90-…", "code": "L013898" },
  "from": { "id": "90ee4027-…", "name": "Unassigned & Unappointed" },
  "to":   { "id": "24c32379-…", "name": "Appointed" }
}
```

## GET List workflows [#list-workflows]

`GET /v1/leads/workflows`

Every lead workflow in your organisation with its stages in order. This is where the names and
ids for the `stage` and `workflow` filters on [list leads](/docs/leads/list#list-leads) come from.
The same stage name often exists in more than one workflow; a filter by name covers all of them.
Cacheable for five minutes; it changes only when someone edits the workflow settings in the CRM.

**Responses**

- `200`: `data`, one entry per workflow, each with its `stages`.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/leads/workflows \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/leads/workflows', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: workflows } = await res.json();
const stageNames = new Set(workflows.flatMap((w) => w.stages.map((s) => s.name)));
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/leads/workflows',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
workflows = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "3f0c1b2e-…",
      "name": "T&K August 2023",
      "active": true,
      "default": true,
      "subWorkflow": false,
      "stages": [
        { "id": "90ee4027-…", "name": "Unassigned & Unappointed", "order": 1, "category": "Live", "description": null },
        { "id": "24c32379-…", "name": "Appointed", "order": 2, "category": "Live", "description": null },
        { "id": "bd7c3675-…", "name": "Sold", "order": 3, "category": "Sold", "description": null },
        { "id": "86b1e72e-…", "name": "Lost", "order": 4, "category": "Lost", "description": null }
      ]
    },
    {
      "id": "a91d…",
      "name": "T&K Lead Workflow",
      "active": true,
      "default": false,
      "subWorkflow": false,
      "stages": [
        { "id": "…", "name": "New", "order": 1, "category": "Live", "description": null },
        { "id": "…", "name": "Quote Sent", "order": 2, "category": "Live", "description": "Awaiting the customer" },
        { "id": "…", "name": "Sold", "order": 3, "category": "Sold", "description": null }
      ]
    }
  ]
}
```

`default` marks the workflow new leads start in. `subWorkflow` is a workflow a stage of another
one hands off to; leads can still sit in it, and its stages filter like any other.

---

# Contracts

URL: https://sandbox-api-docs.evacrm.co.uk/docs/contracts

Read contracts, follow them through their workflow, and attach files — the same way as leads.

A contract is the sale a lead became. The API reads them, moves them through their workflow and
attaches files to them exactly as it does for leads. Creating or editing a contract is not
available through the API, since that involves pricing and conversion that belong in the CRM.

## GET List contracts [#list-contracts]

`GET /v1/contracts`

Contracts in your organisation, newest first, with the same cursor paging and
[guarantees](/docs/leads/list#paging) as leads. Unknown parameters are a 422.

**Query parameters**

- `limit` (integer · 1 to 100, default 50): 

- `cursor` (string): From a previous `nextCursor`.

- `order` (string, default createdAt:desc): 

- `createdSince` (string · date or date-time): In your [timezone](/docs/conventions#dates-and-timezones).

- `createdBefore` (string · date or date-time): Exclusive.

- `updatedSince` (string · date or date-time): Implies `order=updatedAt:asc`.

- `code` (string · max 64): The CRM's contract number, such as `CON000561`.

- `reference` (string · max 255): Exact match.

- `externalRef` (string · max 255): Exact match.

- `leadId` (string · uuid): Contracts converted from that lead.

- `status` (string · list): One or several, comma-separated or repeated. All by default.

- `test` (string, default exclude): 

- `assignedTo` (string): A user's `id` or email, or `none`.

- `stage` (string · list): A stage `id` or name, one or several. A name matches every contract workflow that has it — `stage=Survey` is the Survey stage of each one — unless `workflow` narrows it. Unknown names are a 422. See [list workflows](#list-workflows).

- `workflow` (string · list): A workflow `id` or name, one or several.

- `daysInStage` (string): Whole days in the current stage: `14` is at least 14, or `eq:`, `gt:`, `gte:`, `lt:`, `lte:` a number. As for [leads](/docs/leads/list#list-leads).

**Responses**

- `200`: A page of contracts, `hasMore` and `nextCursor`.

- `422`: An unknown parameter, a value out of range, or a stale cursor. `fields` names it.

**Example request**

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/contracts?status=open&limit=50" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/contracts');
url.searchParams.set('status', 'open');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data, hasMore, nextCursor } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/contracts',
    params={'status': 'open', 'limit': 50},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
page = res.json()
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "6c99094a-…",
      "code": "CON000561",
      "reference": null,
      "leadReference": "web-form-8812",
      "externalRef": null,
      "source": null,
      "leadId": "1e4e3f90-…",
      "status": "open",
      "stage": { "id": "…", "name": "Survey booked", "category": "Live" },
      "workflow": { "id": "…", "name": "Contract Workflow Standard" },
      "stageChangedAt": "2026-09-02T11:14:03.512+01:00",
      "daysInStage": 2,
      "orderType": "Windows & Doors",
      "assigned": true,
      "assignee": { "id": "…", "name": "Tuan Dinh", "email": "…" },
      "assignedTeam": null,
      "test": false,
      "value": { "net": 4216.67, "vatRate": 20, "gross": 5060, "deposit": 1250, "depositPaid": true, "depositPaidAt": "…", "finance": false },
      "dates": {
        "contract": "…", "signed": "…", "survey": null,
        "provisionalInstallStart": null, "provisionalInstallEnd": null, "installStart": null, "installFinish": null,
        "installCompleted": false, "installCompletedAt": null, "warrantyStart": null, "warrantyEnd": null,
        "onHoldAt": null, "cancelledAt": null, "lastInvoiceAt": "…", "lastPaymentAt": "…"
      },
      "marketing": { "source": "Website", "campaign": null },
      "customer": { "id": "…", "code": "CUS007378", "firstName": "Jane", "lastName": "Smith", "fullName": "Jane Smith", "companyName": null, "consents": { "…": "…" } },
      "contact": { "id": "…", "email": "jane@example.com", "phoneNumber": null, "mobile": "07700900123" },
      "installAddress": { "address": "1 High Street", "address2": null, "address3": null, "town": "Reading", "county": null, "postcode": "RG1 1AA", "country": "United Kingdom", "contactName": null, "siteReference": null, "phone": null, "email": null },
      "createdAt": "…",
      "updatedAt": "…"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

* `status` is derived the way the CRM does: `cancelled` wins, then `on_hold`, then `completed`
  (installation complete, or a stage in the Completed category), otherwise `open`.
* `value` is the contract price. Unlike leads, prices are returned here, since integrators
  reconcile [invoices](/docs/invoices) against them.
* `installAddress` is where the work happens, which need not be where the customer lives.

## GET Get a contract [#get-a-contract]

`GET /v1/contracts/{id}`

One contract, in the same shape.

**Path parameters**

- `id` (string · uuid, required): The contract's `id`.

**Responses**

- `200`: The contract.

- `404`: No such contract in your organisation.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const contract = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
contract = res.json()
```

**Example response · 200**

```json
{
  "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05",
  "code": "CON000561",
  "reference": null,
  "leadReference": "web-form-8812",
  "externalRef": null,
  "source": null,
  "leadId": "1e4e3f90-…",
  "status": "open",
  "stage": {
    "id": "…",
    "name": "Survey booked",
    "category": "Live"
  },
  "workflow": {
    "id": "…",
    "name": "Contract Workflow Standard"
  },
  "stageChangedAt": "2026-09-02T11:14:03.512+01:00",
  "daysInStage": 2,
  "orderType": "Windows & Doors",
  "assigned": true,
  "assignee": {
    "id": "…",
    "name": "Tuan Dinh",
    "email": "tuan@example.com"
  },
  "assignedTeam": null,
  "test": false,
  "value": {
    "net": 4216.67,
    "vatRate": 20,
    "gross": 5060,
    "deposit": 1250,
    "depositPaid": true,
    "depositPaidAt": "2026-09-02T00:00:00.000+01:00",
    "finance": false
  },
  "dates": {
    "contract": "2026-09-01T00:00:00.000+01:00",
    "signed": "2026-09-01T00:00:00.000+01:00",
    "survey": null,
    "provisionalInstallStart": null,
    "provisionalInstallEnd": null,
    "installStart": null,
    "installFinish": null,
    "installCompleted": false,
    "installCompletedAt": null,
    "warrantyStart": null,
    "warrantyEnd": null,
    "onHoldAt": null,
    "cancelledAt": null,
    "lastInvoiceAt": "2026-09-02T10:14:03.512+01:00",
    "lastPaymentAt": "2026-09-02T10:14:03.512+01:00"
  },
  "marketing": {
    "source": "Website",
    "campaign": null
  },
  "customer": {
    "id": "…",
    "code": "CUS007378",
    "firstName": "Jane",
    "lastName": "Smith",
    "fullName": "Jane Smith",
    "companyName": null,
    "consents": {
      "doNotContact": false,
      "noMarketing": false,
      "marketingEmail": true,
      "marketingSms": false,
      "marketingPost": false
    }
  },
  "contact": {
    "id": "…",
    "email": "jane@example.com",
    "phoneNumber": null,
    "mobile": "07700900123"
  },
  "installAddress": {
    "address": "1 High Street",
    "address2": null,
    "address3": null,
    "town": "Reading",
    "county": null,
    "postcode": "RG1 1AA",
    "country": "United Kingdom",
    "contactName": null,
    "siteReference": null,
    "phone": null,
    "email": null
  },
  "createdAt": "2026-09-01T09:00:00.000+01:00",
  "updatedAt": "2026-09-02T10:14:03.512+01:00"
}
```

## Stages [#stages]

The contract's workflow, read and moved exactly as for leads: the workflow with
`allowedFromCurrent` per stage, and a move by stage `id` or name that runs through the CRM with
its actions. See [lead stages](/docs/leads/stages) for the shapes and rules.

`GET /v1/contracts/{id}/stages`

`PUT /v1/contracts/{id}/stage`

### List workflows [#list-workflows]

`GET /v1/contracts/workflows`

Every contract workflow with its stages, in the shape of
[the lead version](/docs/leads/stages#list-workflows). The names and ids are what the `stage`
and `workflow` filters on the list take; the same stage name usually exists in several
workflows, and a filter by name covers all of them.

## Notes and tasks [#notes-and-tasks]

The notes written against a contract and the tasks raised on it, in the shapes and with the
filters of [lead notes and tasks](/docs/leads/notes-and-tasks). Read-only; both are written in
the CRM. The response echoes the `contract` instead of the `lead`.

`GET /v1/contracts/{id}/notes`

`GET /v1/contracts/{id}/tasks`

## Attachments [#attachments]

Files on a contract behave exactly as [lead attachments](/docs/leads/attachments), including
`documentType` filing and the Photos default. Contract photos get the CRM's contract numbering.

`GET /v1/contracts/{id}/attachments`

`POST /v1/contracts/{id}/attachments`

`DELETE /v1/contracts/{id}/attachments/{fileId}`

`GET /v1/contracts/attachments/categories`

## Invoices, payments and appointments [#invoices-payments-and-appointments]

A contract's ledger and its bookings have their own pages:
[invoices](/docs/invoices), [payments](/docs/payments) and
[appointments](/docs/appointments/read).

---

# Invoices

URL: https://sandbox-api-docs.evacrm.co.uk/docs/invoices

See a contract's invoices, payments, credit notes and refunds, and raise new invoices.

The CRM keeps invoices, payments (receipts), credit notes and refunds in one ledger per contract.
The API shows the ledger with each entry's `kind`, and lets you raise invoices and
[record payments](/docs/payments).

## GET Invoice types [#invoice-types]

`GET /v1/invoices/types`

What `type` means on the write calls: your organisation's invoice types by kind, and the payment
methods. No parameters. Cached for five minutes.

**Responses**

- `200`: `types` and `paymentMethods`.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/invoices/types \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/invoices/types', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { types, paymentMethods } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/invoices/types', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
types = res.json()['types']
```

**Example response · 200**

```json
{
  "types": [
    { "id": "…", "name": "Deposit Invoice", "kind": "invoice", "description": null, "numberPrefix": "INV", "deposit": true, "lineItems": false },
    { "id": "…", "name": "Balance Invoice", "kind": "invoice", "description": null, "numberPrefix": "BAL", "deposit": false, "lineItems": false },
    { "id": "…", "name": "Receipt", "kind": "payment", "description": null, "numberPrefix": "REC", "deposit": false, "lineItems": false },
    { "id": "…", "name": "Credit Note", "kind": "creditNote", "description": null, "numberPrefix": "CRN", "deposit": false, "lineItems": false }
  ],
  "paymentMethods": [ { "id": "…", "name": "BACS" }, { "id": "…", "name": "Card" } ]
}
```

## GET List a contract's ledger [#list-a-contracts-ledger]

`GET /v1/contracts/{id}/invoices`

Every entry on the contract, oldest first.

**Path parameters**

- `id` (string · uuid, required): The contract's `id`.

**Query parameters**

- `kind` (string): Only entries of that kind. All by default.

**Responses**

- `200`: `data`, the entries in the ledger shape below.

- `404`: No such contract in your organisation.

- `422`: A `kind` that is not one of the four.

**Example request**

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05/invoices?kind=invoice" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05/invoices?kind=invoice', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: entries } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05/invoices',
    params={'kind': 'invoice'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
entries = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "…",
      "number": "INV000189",
      "kind": "invoice",
      "type": { "id": "…", "name": "Deposit Invoice" },
      "status": "paid",
      "description": "Deposit",
      "dateRaised": "2026-09-01T00:00:00.000+01:00",
      "receivedDate": null,
      "dueDate": "2026-09-08T00:00:00.000+01:00",
      "amounts": { "net": 416.67, "vat": 83.33, "vatRate": 20, "gross": 500, "balance": 0, "allocated": 500 },
      "paymentMethod": null,
      "contract": { "id": "…", "code": "CON000561" },
      "allocations": [
        { "id": "…", "amount": 500, "with": { "id": "…", "number": "REC000191", "kind": "payment" }, "createdAt": "…" }
      ],
      "items": [],
      "exported": false,
      "exportedAt": null,
      "createdAt": "…",
      "updatedAt": "…"
    }
  ]
}
```

* `status` is the CRM's: `not_raised`, `draft`, `raised`, `matched`, `paid_part`, `paid`,
  `cancelled`.
* `amounts.balance` is what is still outstanding on an invoice, or still unallocated on a
  payment. `allocated` is the difference from `gross`.
* `allocations` name the other side of each match: the payment on an invoice, the invoice on
  a payment.
* `number` is the CRM's printed number and can be used anywhere an invoice id is accepted.

## GET Get an entry [#get-an-entry]

`GET /v1/invoices/{id}`

One entry of any kind, by `id` or by number.

Numbers come from a sequence and are unique in practice, but the CRM does not enforce it: an
import or a manual edit can repeat one. If a number matches more than one entry the API answers
409 with the candidate ids rather than guessing, and the same request by `id` resolves it. The
same rule applies wherever a number is accepted in place of an id.

**Path parameters**

- `id` (string · id or number, required): The entry's `id`, or its number such as `INV000189`.

**Responses**

- `200`: The entry, in the ledger shape.

- `404`: Nothing with that id or number in your organisation.

- `409`: More than one entry carries that number. `candidates` lists their ids.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/invoices/INV000189 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/invoices/INV000189', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const invoice = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/invoices/INV000189', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
invoice = res.json()
```

**Example response · 200**

```json
{
  "id": "e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6071",
  "number": "INV000189",
  "kind": "invoice",
  "type": {
    "id": "…",
    "name": "Deposit Invoice"
  },
  "status": "paid",
  "description": "Deposit",
  "dateRaised": "2026-09-01T00:00:00.000+01:00",
  "receivedDate": null,
  "dueDate": "2026-09-08T00:00:00.000+01:00",
  "amounts": {
    "net": 416.67,
    "vat": 83.33,
    "vatRate": 20,
    "gross": 500,
    "balance": 0,
    "allocated": 500
  },
  "paymentMethod": null,
  "contract": {
    "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05",
    "code": "CON000561"
  },
  "allocations": [
    {
      "id": "…",
      "amount": 500,
      "with": {
        "id": "3a1f0c92-7d4e-4b8a-9c21-5e6f7a8b9c0d",
        "number": "REC000191",
        "kind": "payment"
      },
      "createdAt": "2026-09-02T10:14:03.512+01:00"
    }
  ],
  "items": [],
  "exported": false,
  "exportedAt": null,
  "createdAt": "2026-09-01T09:00:00.000+01:00",
  "updatedAt": "2026-09-02T10:14:03.512+01:00"
}
```

## GET Download a PDF [#download-a-pdf]

`GET /v1/invoices/{id}/pdf`

A link to the entry as a PDF, produced the way the CRM's own download button produces it: the
organisation's invoice, credit note or receipt template, the invoice type's template override
where one is set, and the reverse-charge template when the tax band calls for it. Works for
payments too, which come out as receipts.

The link points at the CRM's PDF service, needs no key, and is good for 30 minutes. Fetch the
file straight away rather than storing the link; ask again for a fresh one.

**Path parameters**

- `id` (string · id or number, required): The entry's `id`, or its number such as `INV000189`.

**Responses**

- `200`: `url` to download from, the `fileName` the CRM would give it, and `expiresAt`.

- `404`: No such entry, or the organisation has no PDF template for entries of this kind.

- `409`: The number matches more than one entry; use an id.

- `502`: The CRM did not answer; repeat the request.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/invoices/INV000189/pdf \
  -H "Authorization: Bearer sk_..."

# then fetch the file
curl -L -o INV000189.pdf "https://pdf-api.crummy.io/gen/3f9c…?action=download"
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/invoices/INV000189/pdf', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { url, fileName } = await res.json();
const pdf = Buffer.from(await (await fetch(url)).arrayBuffer());
await fs.promises.writeFile(fileName, pdf);
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/invoices/INV000189/pdf', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
link = res.json()
with open(link['fileName'], 'wb') as f:
    f.write(requests.get(link['url']).content)
```

**Example response · 200**

```json
{
  "url": "https://pdf-api.crummy.io/gen/3f9c2a7e1b4d8f60a5c3e9b1d2f4a6c8?action=download",
  "fileName": "Invoice INV000189.pdf",
  "expiresAt": "2026-09-03T10:45:12.000+01:00"
}
```

## POST Create an invoice [#create-an-invoice]

`POST /v1/contracts/{id}/invoices`

**Not available yet.** The route is reserved and answers 501. The CRM creates a contract's
deposit and balance invoices when a lead converts; raise those with
[raise an invoice](#raise-an-invoice). Creating further invoices through the API will be
announced in the [changelog](/docs/changelog).

**Responses**

- `501`: Always, with a `hint` pointing at raise.

- `404`: No such contract in your organisation.

**Example response · 501**

```json
{
  "error": "Creating invoices is not available yet",
  "hint": "The CRM creates a contract's invoices on conversion; raise one with POST /v1/invoices/{id}/raise"
}
```

## POST Raise an invoice [#raise-an-invoice]

`POST /v1/invoices/{id}/raise`

Raises an invoice the CRM has already prepared. When a lead converts, the CRM creates the
contract's deposit and balance invoices with status `not_raised`, no number and the amounts from
the sale; raising one gives it the next number, `raised` status and its balance, and updates the
contract's summary. The amount is the CRM's and cannot be set here.
No field is required: an empty body raises it today, due on receipt.

**Path parameters**

- `id` (string · id or number, required): The invoice's `id` or number.

**Request body**

- `dateRaised` (string · date or date-time, default today): Cannot be earlier than the day the invoice was created; backdating is a staff permission the API does not carry.

- `dueDate` (string · date or date-time, default dateRaised): Left out, the invoice is due on receipt: the same day it is raised. Not with `dueDays`.

- `dueDays` (integer · 0 to 365): Days after `dateRaised`. Not with `dueDate`.

**Responses**

- `200`: The invoice, raised.

- `404`: No such entry.

- `409`: The number matches more than one entry; use an id.

- `422`: Already raised, a payment or refund, exported, or allocated: the same guards that lock the CRM's own edit form. A backdated `dateRaised`, or an amount or description in the body.

- `502`: The CRM did not answer. Nothing changed; repeat the request.

**Example request**

*curl*

```bash
curl -X POST https://api.evacrm.co.uk/v1/invoices/BAL000193/raise \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "dateRaised": "2026-09-02", "dueDays": 14 }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/invoices/BAL000193/raise', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ dateRaised: '2026-09-02', dueDays: 14 }),
});
const invoice = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/invoices/BAL000193/raise',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={'dateRaised': '2026-09-02', 'dueDays': 14},
)
res.raise_for_status()
invoice = res.json()
```

**Example response · 200**

```json
{
  "id": "f1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
  "number": "BAL000202",
  "kind": "invoice",
  "type": {
    "id": "…",
    "name": "Balance Invoice"
  },
  "status": "raised",
  "description": "Balance",
  "dateRaised": "2026-09-03T00:00:00.000+01:00",
  "receivedDate": null,
  "dueDate": "2026-09-17T00:00:00.000+01:00",
  "amounts": {
    "net": 3175,
    "vat": 635,
    "vatRate": 20,
    "gross": 3810,
    "balance": 3810,
    "allocated": 0
  },
  "paymentMethod": null,
  "contract": {
    "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05",
    "code": "CON000561"
  },
  "allocations": [],
  "items": [],
  "exported": false,
  "exportedAt": null,
  "createdAt": "2026-09-01T09:00:00.000+01:00",
  "updatedAt": "2026-09-03T09:12:41.020+01:00"
}
```

## POST Unraise an invoice [#unraise-an-invoice]

`POST /v1/invoices/{id}/unraise`

Returns a raised invoice to `not_raised`: the number is cleared (the next raise gets a fresh
one), the balance goes to zero and the contract's summary is recalculated. No body.

**Path parameters**

- `id` (string · id or number, required): The invoice's `id` or number.

**Responses**

- `200`: The invoice, back to `not_raised`.

- `404`: No such entry.

- `422`: The invoice has allocations or has been exported. Remove the allocation in the CRM first.

- `502`: The CRM did not answer. Nothing changed; repeat the request.

**Example request**

*curl*

```bash
curl -X POST https://api.evacrm.co.uk/v1/invoices/BAL000193/unraise \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/invoices/BAL000193/unraise', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const invoice = await res.json();
```

*Python*

```python
import os, requests

res = requests.post('https://api.evacrm.co.uk/v1/invoices/BAL000193/unraise', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
invoice = res.json()
```

**Example response · 200**

```json
{
  "id": "f1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b",
  "number": null,
  "kind": "invoice",
  "type": {
    "id": "…",
    "name": "Balance Invoice"
  },
  "status": "not_raised",
  "description": "Balance",
  "dateRaised": null,
  "receivedDate": null,
  "dueDate": null,
  "amounts": {
    "net": 3175,
    "vat": 635,
    "vatRate": 20,
    "gross": 3810,
    "balance": 0,
    "allocated": 0
  },
  "paymentMethod": null,
  "contract": {
    "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05",
    "code": "CON000561"
  },
  "allocations": [],
  "items": [],
  "exported": false,
  "exportedAt": null,
  "createdAt": "2026-09-01T09:00:00.000+01:00",
  "updatedAt": "2026-09-03T09:15:02.311+01:00"
}
```

## DELETE Delete an entry [#delete-an-entry]

`DELETE /v1/invoices/{id}`

Deletes an entry of any kind that has no allocations and has not been exported, and
recalculates the contract.

**Path parameters**

- `id` (string · id or number, required): The entry's `id` or number.

**Responses**

- `200`: Deleted: the entry's `id` and `number` with `deleted: true`.

- `404`: No such entry.

- `422`: The entry has allocations or has been exported.

- `502`: The CRM did not answer. Nothing changed; repeat the request.

**Example request**

*curl*

```bash
curl -X DELETE https://api.evacrm.co.uk/v1/invoices/BAL000193 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/invoices/BAL000193', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { deleted, number } = await res.json();
```

*Python*

```python
import os, requests

res = requests.delete('https://api.evacrm.co.uk/v1/invoices/BAL000193', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
```

**Example response · 200**

```json
{ "deleted": true, "id": "…", "number": "BAL000193" }
```

---

# Payments

URL: https://sandbox-api-docs.evacrm.co.uk/docs/payments

Record a payment against a contract and match it to invoices by number or id.

A payment is a receipt on the contract's ledger. Recording one gives it a number and, if you say
which invoices it pays, matches it to them straight away; otherwise it sits unallocated until you
match it.

## POST Record a payment [#record-a-payment]

`POST /v1/contracts/{id}/payments`

Records a payment on the contract through the CRM's own receipt writer: it gets the next receipt
number and the contract's balances are recalculated, as if a member of staff had recorded it.
This is not idempotent: send it twice and two
payments are recorded, so check the ledger before retrying a failed call.

**Path parameters**

- `id` (string · uuid, required): The contract's `id`.

**Request body**

- `amount` (number, required): What was received, including VAT. Greater than 0.

- `receivedDate` (string · date or date-time, default today): Read in your [timezone](/docs/conventions#dates-and-timezones).

- `paymentMethod` (string): A payment method's `id` or name, from [`GET /v1/invoices/types`](/docs/invoices#invoice-types).

- `reference` (string · max 255): Free text. Used as the description when no `description` is sent.

- `description` (string · max 500): Free text.

- `invoices` (string[] · max 50): Invoice ids or numbers on this contract to match, in the order to fill them. Left out, the payment sits unallocated.

- `type` (string): Only if your organisation has more than one receipt type: its `id` or name.

Matching fills invoices in the order given, each up to its outstanding balance, until the payment
is used up. The CRM decides the amounts, so per-invoice amounts are not accepted.

**Responses**

- `201`: The payment in the ledger shape, with `allocations` showing what it was matched to and `amounts.balance` what is left unallocated.

- `404`: No such contract in your organisation.

- `422`: A field is wrong, or an invoice is not on this contract, not raised, or already paid. `fields` names the offender and nothing is written.

- `502`: The CRM did not answer. Nothing was written; repeat the request.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05/payments \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 500,
    "receivedDate": "2026-09-02",
    "paymentMethod": "BACS",
    "reference": "Bank ref 88213",
    "invoices": ["INV000189"]
  }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05/payments', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 500,
    receivedDate: '2026-09-02',
    paymentMethod: 'BACS',
    reference: 'Bank ref 88213',
    invoices: ['INV000189'],
  }),
});
if (!res.ok) throw new Error(`${res.status}: ${JSON.stringify(await res.json())}`);
const payment = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/contracts/6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05/payments',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={
        'amount': 500,
        'receivedDate': '2026-09-02',
        'paymentMethod': 'BACS',
        'reference': 'Bank ref 88213',
        'invoices': ['INV000189'],
    },
)
res.raise_for_status()
payment = res.json()
```

**Example response · 201**

```json
{
  "id": "3a1f0c92-7d4e-4b8a-9c21-5e6f7a8b9c0d",
  "number": "REC000191",
  "kind": "payment",
  "type": { "id": "b0c1d2e3-f4a5-4b6c-8d7e-9f0a1b2c3d4e", "name": "Receipt" },
  "status": "matched",
  "description": "Bank ref 88213",
  "dateRaised": "2026-09-02T00:00:00.000+01:00",
  "receivedDate": "2026-09-02T00:00:00.000+01:00",
  "dueDate": null,
  "amounts": { "net": 416.67, "vat": 83.33, "vatRate": 20, "gross": 500, "balance": 0, "allocated": 500 },
  "paymentMethod": { "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "name": "BACS" },
  "contract": { "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05", "code": "CON000561" },
  "allocations": [
    { "id": "d2e3f4a5-b6c7-4d8e-9f0a-1b2c3d4e5f60", "amount": 500, "with": { "id": "e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6071", "number": "INV000189", "kind": "invoice" }, "createdAt": "2026-09-02T10:14:03.512+01:00" }
  ],
  "items": [],
  "exported": false,
  "exportedAt": null,
  "createdAt": "2026-09-02T10:14:03.512+01:00",
  "updatedAt": "2026-09-02T10:14:03.512+01:00"
}
```

## GET Get a payment [#get-a-payment]

`GET /v1/payments/{id}`

One payment, in the [ledger shape](/docs/invoices#list-a-contracts-ledger). For a receipt PDF, see
[download a PDF](/docs/invoices#download-a-pdf): it works for payments too.

**Path parameters**

- `id` (string · id or number, required): The payment's `id`, or its number such as `REC000191`.

**Responses**

- `200`: The payment.

- `404`: Nothing with that id or number, or it is not a payment.

- `409`: More than one entry carries that number. `candidates` lists their ids; use one of them.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/payments/REC000191 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/payments/REC000191', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const payment = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/payments/REC000191',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
payment = res.json()
```

**Example response · 200**

```json
{
  "id": "3a1f0c92-7d4e-4b8a-9c21-5e6f7a8b9c0d",
  "number": "REC000191",
  "kind": "payment",
  "type": {
    "id": "…",
    "name": "Receipt"
  },
  "status": "matched",
  "description": "Bank ref 88213",
  "dateRaised": "2026-09-02T00:00:00.000+01:00",
  "receivedDate": "2026-09-02T00:00:00.000+01:00",
  "dueDate": null,
  "amounts": {
    "net": 416.67,
    "vat": 83.33,
    "vatRate": 20,
    "gross": 500,
    "balance": 0,
    "allocated": 500
  },
  "paymentMethod": {
    "id": "…",
    "name": "BACS"
  },
  "contract": {
    "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05",
    "code": "CON000561"
  },
  "allocations": [
    {
      "id": "…",
      "amount": 500,
      "with": {
        "id": "e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6071",
        "number": "INV000189",
        "kind": "invoice"
      },
      "createdAt": "2026-09-02T10:14:03.512+01:00"
    }
  ],
  "items": [],
  "exported": false,
  "exportedAt": null,
  "createdAt": "2026-09-02T10:14:03.512+01:00",
  "updatedAt": "2026-09-02T10:14:03.512+01:00"
}
```

## POST Allocate a payment [#allocate-a-payment]

`POST /v1/payments/{id}/allocations`

Matches a payment's remaining balance to invoices, in the order given, each up to its
outstanding balance. Unmatching is not available through the API; staff can remove an
allocation in the CRM.

**Path parameters**

- `id` (string · id or number, required): The payment's `id` or number.

**Request body**

- `invoices` (string[] · 1 to 50, required): Invoice ids or numbers on the payment's contract, in the order to fill them.

**Responses**

- `201`: The updated payment, with its new `allocations` and `amounts.balance`.

- `404`: No such payment.

- `422`: The payment is fully allocated, or an invoice is not on its contract, not raised, or already paid. `fields` says which.

- `502`: The CRM did not answer. Nothing was written; repeat the request.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/payments/REC000191/allocations \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "invoices": ["INV000189", "BAL000190"] }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/payments/REC000191/allocations', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ invoices: ['INV000189', 'BAL000190'] }),
});
const payment = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/payments/REC000191/allocations',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={'invoices': ['INV000189', 'BAL000190']},
)
res.raise_for_status()
payment = res.json()
```

**Example response · 201**

```json
{
  "id": "3a1f0c92-7d4e-4b8a-9c21-5e6f7a8b9c0d",
  "number": "REC000191",
  "kind": "payment",
  "type": {
    "id": "…",
    "name": "Receipt"
  },
  "status": "matched",
  "description": "Bank ref 88213",
  "dateRaised": "2026-09-02T00:00:00.000+01:00",
  "receivedDate": "2026-09-02T00:00:00.000+01:00",
  "dueDate": null,
  "amounts": {
    "net": 416.67,
    "vat": 83.33,
    "vatRate": 20,
    "gross": 500,
    "balance": 0,
    "allocated": 500
  },
  "paymentMethod": {
    "id": "…",
    "name": "BACS"
  },
  "contract": {
    "id": "6c99094a-6f2e-4c7d-9b1e-2b4a8f3c1d05",
    "code": "CON000561"
  },
  "allocations": [
    {
      "id": "…",
      "amount": 300,
      "with": {
        "id": "…",
        "number": "INV000189",
        "kind": "invoice"
      },
      "createdAt": "2026-09-02T10:14:03.512+01:00"
    },
    {
      "id": "…",
      "amount": 200,
      "with": {
        "id": "…",
        "number": "BAL000190",
        "kind": "invoice"
      },
      "createdAt": "2026-09-03T09:20:44.101+01:00"
    }
  ],
  "items": [],
  "exported": false,
  "exportedAt": null,
  "createdAt": "2026-09-02T10:14:03.512+01:00",
  "updatedAt": "2026-09-02T10:14:03.512+01:00"
}
```

---

# Supplier invoices

URL: https://sandbox-api-docs.evacrm.co.uk/docs/supplier-invoices

Add supplier invoices in bulk the way the CRM's grid does, read and update them, and mark them exported — which locks them until unmarked.

A supplier invoice is what the CRM lists under **Purchasing → Supplier Invoices**: one row per
invoice or credit note from a supplier, with the supplier's own number, a date, net, VAT and
total, a nominal code and VAT rate, and optionally the purchase order and contract it belongs
to. There are no line items and no status. The one state an invoice has is **exported**: once an
accounts export has taken it, it is read-only here until it is unmarked.

## GET List suppliers [#list-suppliers]

`GET /v1/suppliers`

The suppliers you can invoice against, by `id` or `code`, with the nominal code and VAT rate an
invoice gets when the row leaves them out. Inactive suppliers are listed so you can read their
invoices, but cannot be invoiced, as in the CRM. Only suppliers with `exportToAccounts` are
picked up by the CRM's accounts exports. No parameters. Cached for five minutes.

**Responses**

- `200`: `data`, the suppliers, ordered by code.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/suppliers \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/suppliers', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: suppliers } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/suppliers', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
suppliers = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "c5c5ea34-994b-4278-8f34-1fb9047cb6fe",
      "code": "EMP",
      "name": "Emplas Windows Systems Ltd",
      "tradingName": null,
      "active": true,
      "onStop": false,
      "exportToAccounts": true,
      "accountsReference": "EMP01",
      "defaults": { "nominalCode": { "code": "000", "name": "None - Use contract nc" }, "vatRate": { "code": "0", "name": "Zero-Rated" } }
    }
  ]
}
```

## GET Reference data [#reference-data]

`GET /v1/supplier-invoices/types`

What the write calls accept: the invoice types, the nominal codes flagged for suppliers, and the
VAT rates with the percentage in force today. Send a type by name, value or id; a nominal code
by code or id; a VAT rate by code, name or id. No parameters. Cached for five minutes.

**Responses**

- `200`: `types`, `nominalCodes` and `vatRates`.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/supplier-invoices/types \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices/types', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { types, nominalCodes, vatRates } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/supplier-invoices/types', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
reference = res.json()
```

**Example response · 200**

```json
{
  "types": [
    { "id": "b96b8164-873e-47c2-aa3e-2c4741d518bf", "name": "Invoice", "value": "invoice", "credit": false },
    { "id": "c8575baa-242e-4e1f-b4ed-7e0a9e64f2e2", "name": "Credit Note", "value": "credit_note", "credit": true }
  ],
  "nominalCodes": [{ "id": "2c4f4109-86c6-4d73-aa28-dc51aa455d9b", "code": "201", "name": "COS - Supply Only - Windows & Doors" }],
  "vatRates": [
    { "id": "79785292-0368-40fd-a119-3ef06abb33e2", "code": "1", "name": "Standard Rates", "percent": 20, "default": true },
    { "id": "f84f5a52-55f3-40cb-921a-2ca8aabc6290", "code": "0", "name": "Zero-Rated", "percent": 0, "default": false }
  ]
}
```

## GET List supplier invoices [#list-supplier-invoices]

`GET /v1/supplier-invoices`

Newest first, with cursor paging as for [leads](/docs/leads/list#paging). A supplier's invoice
number is not unique, so lookup by number is a list with `supplier` and `number`.

**Query parameters**

- `supplier` (string · list): One or more suppliers by id or code.

- `number` (string · max 64): Exact match, case-insensitive.

- `reference` (string · max 255): Exact match, case-insensitive.

- `type` (string · list): One or more types by name, value or id.

- `credit` (boolean): 

- `exported` (boolean): 

- `nominalCode` (string · list): One or more, by id or code.

- `vatRate` (string · list): One or more, by id, code or name.

- `purchaseOrder` (string): A purchase order's code or id.

- `contractId` (string · uuid): Invoices on a contract, directly or through its purchase orders.

- `dateFrom` (string · date or date-time): Invoice date, from.

- `dateTo` (string · date or date-time): Invoice date, to. A date-only value covers that whole day.

- `dueFrom` (string · date or date-time): Payment due date, from.

- `dueTo` (string · date or date-time): Payment due date, to; date-only covers the day.

- `exportedFrom` (string · date or date-time): Exported date, from.

- `exportedTo` (string · date or date-time): Exported date, to; date-only covers the day.

- `createdSince` (string · date or date-time): 

- `createdBefore` (string · date or date-time): 

- `updatedSince` (string · date or date-time): The way to keep a mirror. Implies `order=updatedAt:asc`.

- `order` (string, default createdAt:desc): 

- `limit` (integer · 1 to 100, default 50): 

- `cursor` (string): From a previous `nextCursor`.

**Responses**

- `200`: A page of invoices, `hasMore` and `nextCursor`.

- `422`: An unknown parameter, a bad flag or date, or a stale cursor. `fields` names it.

**Example request**

*curl*

```bash
# everything from one supplier that has not been exported yet
curl "https://api.evacrm.co.uk/v1/supplier-invoices?supplier=EMP&exported=false" \
  -H "Authorization: Bearer sk_..."

# credit notes dated in september
curl "https://api.evacrm.co.uk/v1/supplier-invoices?credit=true&dateFrom=2026-09-01&dateTo=2026-09-30" \
  -H "Authorization: Bearer sk_..."

# a supplier's number — numbers are not unique, so this is a list
curl "https://api.evacrm.co.uk/v1/supplier-invoices?supplier=EMP&number=INV-88812" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/supplier-invoices');
url.searchParams.set('supplier', 'EMP');
url.searchParams.set('exported', 'false');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data, hasMore, nextCursor } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/supplier-invoices',
    params={'supplier': 'EMP', 'exported': 'false'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
page = res.json()
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "e94cc565-b021-46af-bcd7-ced0d70111c2",
      "number": "INV-88812",
      "reference": "INV-88812",
      "detail": "Frames for plot 4",
      "type": { "id": "b96b8164-873e-47c2-aa3e-2c4741d518bf", "name": "Invoice", "value": "invoice" },
      "credit": false,
      "supplier": { "id": "c5c5ea34-994b-4278-8f34-1fb9047cb6fe", "code": "EMP", "name": "Emplas Windows Systems Ltd" },
      "date": "2026-09-02T00:00:00.000+01:00",
      "dateReceived": "2026-09-03T00:00:00.000+01:00",
      "dueDate": "2026-10-02T00:00:00.000+01:00",
      "amounts": { "net": 5638, "vat": 1127.6, "total": 6765.6, "vatPercent": 20 },
      "nominalCode": { "id": "2c4f4109-86c6-4d73-aa28-dc51aa455d9b", "code": "201", "name": "COS - Supply Only - Windows & Doors" },
      "vatRate": { "id": "79785292-0368-40fd-a119-3ef06abb33e2", "code": "1", "name": "Standard Rates" },
      "purchaseOrder": { "id": "ed0a9368-f8c7-45de-ae77-a7ff6129d09b", "code": "PO000116" },
      "contract": { "id": "1553a10d-1150-47e1-86d9-d3323798227e", "code": "CON000561" },
      "exported": false,
      "exportedAt": null,
      "createdBy": { "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e", "name": "Tuan Dinh" },
      "createdAt": "2026-09-02T14:45:53.405+01:00",
      "updatedAt": "2026-09-02T14:45:53.405+01:00"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

`amounts.vatPercent` is the rate that was applied; `vatRate` is the band it came from. `contract`
is the one the invoice sits on, directly or through its purchase order, which is how the CRM's
profit and loss tab reads it.

## GET Get a supplier invoice [#get-a-supplier-invoice]

`GET /v1/supplier-invoices/{id}`

One invoice, in the same shape.

**Path parameters**

- `id` (string · uuid, required): The invoice's `id`.

**Responses**

- `200`: The invoice.

- `404`: No such invoice in your organisation, or the value is not an id.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const invoice = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
invoice = res.json()
```

**Example response · 200**

```json
{
  "id": "e94cc565-b021-46af-bcd7-ced0d70111c2",
  "number": "INV-88812",
  "reference": "INV-88812",
  "detail": "Frames for plot 4",
  "type": {
    "id": "b96b8164-873e-47c2-aa3e-2c4741d518bf",
    "name": "Invoice",
    "value": "invoice"
  },
  "credit": false,
  "supplier": {
    "id": "c5c5ea34-994b-4278-8f34-1fb9047cb6fe",
    "code": "EMP",
    "name": "Emplas Windows Systems Ltd"
  },
  "date": "2026-09-02T00:00:00.000+01:00",
  "dateReceived": "2026-09-03T00:00:00.000+01:00",
  "dueDate": "2026-10-02T00:00:00.000+01:00",
  "amounts": {
    "net": 5638,
    "vat": 1127.6,
    "total": 6765.6,
    "vatPercent": 20
  },
  "nominalCode": {
    "id": "2c4f4109-86c6-4d73-aa28-dc51aa455d9b",
    "code": "201",
    "name": "COS - Supply Only - Windows & Doors"
  },
  "vatRate": {
    "id": "79785292-0368-40fd-a119-3ef06abb33e2",
    "code": "1",
    "name": "Standard Rates"
  },
  "purchaseOrder": {
    "id": "ed0a9368-f8c7-45de-ae77-a7ff6129d09b",
    "code": "PO000116"
  },
  "contract": {
    "id": "1553a10d-1150-47e1-86d9-d3323798227e",
    "code": "CON000561"
  },
  "exported": false,
  "exportedAt": null,
  "createdBy": {
    "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e",
    "name": "Tuan Dinh"
  },
  "createdAt": "2026-09-02T14:45:53.405+01:00",
  "updatedAt": "2026-09-02T14:45:53.405+01:00"
}
```

## POST Add supplier invoices [#add-supplier-invoices]

`POST /v1/supplier-invoices`

Adds invoices in bulk, exactly as the CRM's **Create Supplier Invoice** grid does: send up to 200
rows, every row is checked, and either all of them are written or none. VAT is worked out the
way the grid does it: the rate on the VAT band in force on the invoice date, applied to the net
and rounded to pence, with the total as net plus VAT. Send `vat` when the supplier's figure
differs by a penny or two.

**Request body**

- `invoices` (object[] · 1 to 200, required): The rows, each with the fields below.

- `force` (boolean, default false): Add rows whose supplier and number already exist.

**Each row**

- `number` (string · max 20, required): The supplier's invoice number.

- `supplier` (string · id or code, required): Inactive suppliers are refused.

- `date` (string · date or date-time, required): The invoice date.

- `net` (number · 0 or more, required): Credit notes are positive amounts with a credit type.

- `type` (string · name, value or id, default the plain invoice type): 

- `vatRate` (string · id, code or name, default the supplier's): A supplier with no default needs one.

- `vat` (number · 0 or more): The supplier's own VAT figure. Left out, it is worked out from the rate.

- `nominalCode` (string · id or code, default the supplier's): A supplier with no default needs one.

- `reference` (string · max 255, default the invoice number): The CRM requires one.

- `detail` (string · max 2000): 

- `dueDate` (string · date or date-time): 

- `dateReceived` (string · date or date-time): 

- `purchaseOrder` (string · code or id): Must belong to the supplier; fills in the contract.

- `contractId` (string · uuid): The contract, when there is no purchase order to take it from.

**Responses**

- `201`: `data`, the rows as GET shows them, in the order sent.

- `422`: Something missing or unknown on a row, or a purchase order that belongs to another supplier. `rows` names each row by `index` with its `fields`; nothing was written.

- `409`: A row's supplier already has an invoice with that number. The CRM allows duplicates; the API refuses them so a retried import cannot double up. `rows` names them; send `force: true` to add anyway.

- `502`: The CRM did not answer. Nothing was written; repeat the request.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/supplier-invoices \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "invoices": [
      { "number": "INV-88812", "supplier": "EMP", "date": "2026-09-02", "net": 5638, "purchaseOrder": "PO000116" },
      { "number": "CN-0091", "supplier": "EMP", "type": "Credit Note", "date": "2026-09-02", "net": 120, "vatRate": "Reduced Rate", "reference": "Damaged sash", "dueDate": "2026-10-02" }
    ]
  }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    invoices: [
      { number: 'INV-88812', supplier: 'EMP', date: '2026-09-02', net: 5638, purchaseOrder: 'PO000116' },
      { number: 'CN-0091', supplier: 'EMP', type: 'Credit Note', date: '2026-09-02', net: 120, vatRate: 'Reduced Rate', reference: 'Damaged sash', dueDate: '2026-10-02' },
    ],
  }),
});
const body = await res.json();
if (!res.ok) console.error(body.rows); // [{ index, fields }]
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/supplier-invoices',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={'invoices': [
        {'number': 'INV-88812', 'supplier': 'EMP', 'date': '2026-09-02', 'net': 5638, 'purchaseOrder': 'PO000116'},
        {'number': 'CN-0091', 'supplier': 'EMP', 'type': 'Credit Note', 'date': '2026-09-02', 'net': 120, 'vatRate': 'Reduced Rate', 'reference': 'Damaged sash', 'dueDate': '2026-10-02'},
    ]},
)
if res.status_code in (409, 422):
    print(res.json()['rows'])
res.raise_for_status()
created = res.json()['data']
```

**Example response · 422**

```json
{
  "error": "Some invoices need attention",
  "fields": { "invoices": "1 of 2 row(s) refused, nothing was written — see rows" },
  "rows": [{ "index": 1, "fields": { "supplier": "no supplier with id or code ACME — GET /v1/suppliers lists them" } }]
}
```

## PATCH Update a supplier invoice [#update-a-supplier-invoice]

`PATCH /v1/supplier-invoices/{id}`

Changes only the fields sent, with the same fields and lookups as a row above. No field is
required; send at least one. `null` clears `detail`, `dueDate`, `dateReceived`, `purchaseOrder`
and `contractId`. A change to `net`, `vat`, `vatRate` or `date` re-derives the VAT and total,
unless `vat` itself is sent.

**Path parameters**

- `id` (string · uuid, required): The invoice's `id`.

**Headers**

- `If-Unmodified-Since` (string · date-time): The `updatedAt` you last saw; a 409 if someone else changed the invoice first.

**Request body**

- `number` (string · max 20): The supplier's invoice number.

- `supplier` (string · id or code): Inactive suppliers are refused.

- `type` (string · name, value or id): Changes whether it is a credit note.

- `date` (string · date or date-time): The invoice date. Re-derives the VAT from the rate in force on it.

- `net` (number · 0 or more): Re-derives the VAT and total.

- `vat` (number · 0 or more): The supplier's own VAT figure; the total follows.

- `vatRate` (string · id, code or name): Re-derives the VAT and total.

- `nominalCode` (string · id or code): 

- `reference` (string · max 255): 

- `detail` (string · max 2000, or null): `null` clears.

- `dueDate` (string · date or date-time, or null): `null` clears.

- `dateReceived` (string · date or date-time, or null): `null` clears.

- `purchaseOrder` (string · code or id, or null): Must belong to the supplier; fills in the contract. `null` clears.

- `contractId` (string · uuid, or null): `null` clears.

**Responses**

- `200`: The invoice, updated.

- `404`: No such invoice.

- `409`: The invoice is exported (`fields.exported` says to unmark it first), or `If-Unmodified-Since` is stale.

- `422`: A field is wrong or a lookup failed. `fields` names it.

- `502`: The CRM did not answer. Nothing changed; repeat the request.

**Example request**

*curl*

```bash
curl -X PATCH https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2 \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "net": 5700, "dueDate": "2026-10-09" }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2', {
  method: 'PATCH',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ net: 5700, dueDate: '2026-10-09' }),
});
const invoice = await res.json();
```

*Python*

```python
import os, requests

res = requests.patch(
    'https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={'net': 5700, 'dueDate': '2026-10-09'},
)
res.raise_for_status()
invoice = res.json()
```

**Example response · 200**

```json
{
  "id": "e94cc565-b021-46af-bcd7-ced0d70111c2",
  "number": "INV-88812",
  "reference": "INV-88812",
  "detail": "Frames for plot 4",
  "type": {
    "id": "b96b8164-873e-47c2-aa3e-2c4741d518bf",
    "name": "Invoice",
    "value": "invoice"
  },
  "credit": false,
  "supplier": {
    "id": "c5c5ea34-994b-4278-8f34-1fb9047cb6fe",
    "code": "EMP",
    "name": "Emplas Windows Systems Ltd"
  },
  "date": "2026-09-02T00:00:00.000+01:00",
  "dateReceived": "2026-09-03T00:00:00.000+01:00",
  "dueDate": "2026-10-09T00:00:00.000+01:00",
  "amounts": {
    "net": 5700,
    "vat": 1140,
    "total": 6840,
    "vatPercent": 20
  },
  "nominalCode": {
    "id": "2c4f4109-86c6-4d73-aa28-dc51aa455d9b",
    "code": "201",
    "name": "COS - Supply Only - Windows & Doors"
  },
  "vatRate": {
    "id": "79785292-0368-40fd-a119-3ef06abb33e2",
    "code": "1",
    "name": "Standard Rates"
  },
  "purchaseOrder": {
    "id": "ed0a9368-f8c7-45de-ae77-a7ff6129d09b",
    "code": "PO000116"
  },
  "contract": {
    "id": "1553a10d-1150-47e1-86d9-d3323798227e",
    "code": "CON000561"
  },
  "exported": false,
  "exportedAt": null,
  "createdBy": {
    "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e",
    "name": "Tuan Dinh"
  },
  "createdAt": "2026-09-02T14:45:53.405+01:00",
  "updatedAt": "2026-09-03T09:30:12.774+01:00"
}
```

## POST Mark exported [#mark-exported]

`POST /v1/supplier-invoices/{id}/export`

Marks the invoice exported. From then on update and delete are refused until it is unmarked.
Marking an invoice that is already exported changes nothing and is not an error.

**Path parameters**

- `id` (string · uuid, required): The invoice's `id`.

**Request body**

- `exportedDate` (string · date or date-time, default now): When it was exported.

**Responses**

- `200`: `changed`, and `invoice` as GET shows it.

- `404`: No such invoice.

- `422`: `exportedDate` is not a date.

**Example request**

*curl*

```bash
curl -X POST https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2/export \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "exportedDate": "2026-09-15" }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2/export', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ exportedDate: '2026-09-15' }),
});
const { changed, invoice } = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2/export',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={'exportedDate': '2026-09-15'},
)
res.raise_for_status()
result = res.json()
```

**Example response · 200**

```json
{ "changed": true, "invoice": { "id": "e94cc565-…", "exported": true, "exportedAt": "2026-09-15T00:00:00.000+01:00", "…": "…" } }
```

## POST Unmark exported [#unmark-exported]

`POST /v1/supplier-invoices/{id}/unexport`

Clears the exported flag and date. This is the only way to reopen an exported invoice, so a
correction after an export is always two calls: unexport, then update. The CRM's own accounts
export will pick the invoice up again on its next run unless it is re-marked. No body.

**Path parameters**

- `id` (string · uuid, required): The invoice's `id`.

**Responses**

- `200`: `changed`, and `invoice` as GET shows it. `changed: false` when it was not exported.

- `404`: No such invoice.

**Example request**

*curl*

```bash
curl -X POST https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2/unexport \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2/unexport', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { changed, invoice } = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2/unexport',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
```

**Example response · 200**

```json
{
  "changed": true,
  "invoice": {
    "id": "e94cc565-b021-46af-bcd7-ced0d70111c2",
    "number": "INV-88812",
    "reference": "INV-88812",
    "detail": "Frames for plot 4",
    "type": {
      "id": "b96b8164-873e-47c2-aa3e-2c4741d518bf",
      "name": "Invoice",
      "value": "invoice"
    },
    "credit": false,
    "supplier": {
      "id": "c5c5ea34-994b-4278-8f34-1fb9047cb6fe",
      "code": "EMP",
      "name": "Emplas Windows Systems Ltd"
    },
    "date": "2026-09-02T00:00:00.000+01:00",
    "dateReceived": "2026-09-03T00:00:00.000+01:00",
    "dueDate": "2026-10-02T00:00:00.000+01:00",
    "amounts": {
      "net": 5638,
      "vat": 1127.6,
      "total": 6765.6,
      "vatPercent": 20
    },
    "nominalCode": {
      "id": "2c4f4109-86c6-4d73-aa28-dc51aa455d9b",
      "code": "201",
      "name": "COS - Supply Only - Windows & Doors"
    },
    "vatRate": {
      "id": "79785292-0368-40fd-a119-3ef06abb33e2",
      "code": "1",
      "name": "Standard Rates"
    },
    "purchaseOrder": {
      "id": "ed0a9368-f8c7-45de-ae77-a7ff6129d09b",
      "code": "PO000116"
    },
    "contract": {
      "id": "1553a10d-1150-47e1-86d9-d3323798227e",
      "code": "CON000561"
    },
    "exported": false,
    "exportedAt": null,
    "createdBy": {
      "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e",
      "name": "Tuan Dinh"
    },
    "createdAt": "2026-09-02T14:45:53.405+01:00",
    "updatedAt": "2026-09-03T09:31:05.209+01:00"
  }
}
```

## DELETE Delete a supplier invoice [#delete-a-supplier-invoice]

`DELETE /v1/supplier-invoices/{id}`

Removes the invoice.

**Path parameters**

- `id` (string · uuid, required): The invoice's `id`.

**Responses**

- `204`: Deleted. No body.

- `404`: No such invoice.

- `409`: The invoice is exported; unmark it first.

**Example request**

*curl*

```bash
curl -X DELETE https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
if (res.status !== 204) throw new Error(`${res.status}`);
```

*Python*

```python
import os, requests

res = requests.delete(
    'https://api.evacrm.co.uk/v1/supplier-invoices/e94cc565-b021-46af-bcd7-ced0d70111c2',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
```

**Example response · 204**

```http
HTTP/1.1 204 No Content
```

---

# Read the diary

URL: https://sandbox-api-docs.evacrm.co.uk/docs/appointments/read

Appointment types, and the diary filtered by window, type, status, staff, role, team or record.

An appointment is a diary entry: a Sales visit, a Survey, a Fitting, a Service call, a Holiday
or a plain Standard entry. Each has a **type**, a **status** (`provisional`, `confirmed` or
`cancelled`), a start and an end, the staff it is for (**participants**, shown as "For" in the
CRM: users, teams, or both) and, usually, the lead or contract it belongs to. To add to the
diary, see [booking](/docs/appointments/book); to find a free person first,
[availability](/docs/appointments/availability).

## GET Appointment types [#appointment-types]

`GET /v1/appointments/types`

The types your organisation has switched on, in its own order. `roles` says who a type is
booked for, the way the CRM's booking form narrows its "For" list; pass them to
[availability](/docs/appointments/availability) to find a free person. An empty `roles` means
anyone. Wherever a type is expected, send its `name`, its `value` or its `id`; all three work
and matching ignores case. `value` is the safe choice for names with spaces: `type=showroom`
rather than `type=Showroom%20Appointment`. No parameters. Cached for five minutes.

**Responses**

- `200`: `data`, the types.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/appointments/types \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/appointments/types', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: types } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/appointments/types', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
types = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    { "id": "ab6e78a0-4e85-405f-ba19-4753beac8445", "name": "Sales", "value": "sales", "roles": ["salesman"], "color": "#ddd6fe", "textColor": "#374151" },
    { "id": "5d1f0c2e-1b7a-4c0e-9c1d-2a6f3b8e9d10", "name": "Surveyor", "value": "surveyor", "roles": ["surveyor"], "color": "#c7d2fe", "textColor": "#374151" },
    { "id": "0c4b9a7e-6d2f-4e8a-b1c3-7f9e2d5a6b41", "name": "Standard", "value": "standard", "roles": [], "color": "#fbcfe8", "textColor": "#374151" }
  ]
}
```

## GET List appointments [#list-appointments]

`GET /v1/appointments`

A window of the diary, earliest first. A recurring series appears as its occurrences, one per
date, as in the CRM's diary.

**Query parameters**

- `from` (string · date or date-time, default today): Start of the window; anything overlapping it is returned.

- `to` (string · date or date-time, default 31 days after from): End of the window. A date-only `to` covers that whole day, a date-time is exact. At most 366 days after `from`.

- `type` (string · list): One or more types by name, value or id: `type=Sales,Surveyor`.

- `status` (string · list): One or several. All of them by default, cancelled included.

- `user` (string · list): One or more users by id or email: appointments they are on directly, or through a team they belong to.

- `role` (string · list): One or more [roles](/docs/users#roles): appointments with at least one participant holding the role.

- `team` (string · list): One or more teams by id or name.

- `leadId` (string · uuid): Appointments on one lead. Not with `contractId`.

- `contractId` (string · uuid): Appointments on one contract.

- `app` (string · list): What the appointment hangs off; `none` is a diary entry on nothing.

- `updatedSince` (string · date or date-time): Everything changed since then, oldest change first and no window: for keeping a mirror. Implies `order=updatedAt:asc`.

- `order` (string, default start:asc): 

- `limit` (integer · 1 to 100, default 50): 

- `cursor` (string): From a previous `nextCursor`. Paging works exactly as for [leads](/docs/leads/list#paging).

**Responses**

- `200`: A page of appointments, `hasMore` and `nextCursor`.

- `422`: An unknown parameter, a backwards or oversized window, both `leadId` and `contractId`, or a stale cursor. `fields` names it.

**Example request**

*curl*

```bash
# confirmed sales visits in a given week
curl "https://api.evacrm.co.uk/v1/appointments?from=2026-10-13&to=2026-10-17&type=Sales&status=confirmed" \
  -H "Authorization: Bearer sk_..."

# showroom appointments in the next 31 days — the type's value, or its name URL-encoded
curl "https://api.evacrm.co.uk/v1/appointments?type=showroom" \
  -H "Authorization: Bearer sk_..."

# one person's diary for a day, whether booked directly or through their team
curl "https://api.evacrm.co.uk/v1/appointments?user=jo@example.com&from=2026-10-13&to=2026-10-13" \
  -H "Authorization: Bearer sk_..."

# keep a mirror: every change since your last run, oldest first
curl "https://api.evacrm.co.uk/v1/appointments?updatedSince=2026-10-12T22:00&limit=100" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/appointments');
url.searchParams.set('from', '2026-10-13');
url.searchParams.set('to', '2026-10-17');
url.searchParams.set('type', 'Sales');
url.searchParams.set('status', 'confirmed');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { data, hasMore, nextCursor } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/appointments',
    params={'from': '2026-10-13', 'to': '2026-10-17', 'type': 'Sales', 'status': 'confirmed'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
page = res.json()
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "cff5e8d2-bd17-4546-bbc6-0376a41596c3",
      "title": "NN4 8LN - Smith",
      "eventName": null,
      "description": "Customer asked for a morning visit",
      "location": "6 Briar Hill Walk Northampton NN4 8LN",
      "type": { "id": "ab6e78a0-4e85-405f-ba19-4753beac8445", "name": "Sales", "value": "sales" },
      "status": "confirmed",
      "outcome": null,
      "start": "2026-10-13T10:00:00.000+01:00",
      "end": "2026-10-13T11:00:00.000+01:00",
      "allDay": false,
      "participants": [
        { "kind": "user", "id": "50f716dc-c202-4d27-bc60-a1697533e03b", "name": "Jo Savant", "email": "jo@example.com" }
      ],
      "linkedTo": { "kind": "lead", "id": "8bd8e096-dd31-44cf-8373-3a00db14dfd3", "code": "L013885" },
      "locked": false,
      "createdBy": { "id": "50f716dc-c202-4d27-bc60-a1697533e03b", "name": "Jo Savant" },
      "createdAt": "2026-10-01T14:14:54.527+01:00",
      "updatedAt": "2026-10-01T14:14:54.527+01:00"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

* `start`, `end` and `allDay`: in your timezone. An all-day entry covers its calendar days.
* `participants`: who it is for. `kind` is `user` (with `email`) or `team`. Empty when
  unassigned.
* `linkedTo`: the record it belongs to. `kind` is `lead`, `contract`, `aftercare` (a service
  call), `holiday-booking` (an approved absence) or whatever else the CRM files it under, with the
  record's `id` and `code` where it has them. `null` for a diary entry on nothing.
* `status` and `outcome`: the status as one of the three values, and the outcome staff recorded
  afterwards, if any.
* `locked`: set by the CRM on fittings that must not be moved.
* `title` and `eventName`: `title` is what the diary shows; `eventName` is the label staff typed,
  when they did.

## GET Get an appointment [#get-an-appointment]

`GET /v1/appointments/{id}`

One appointment, in the same shape.

**Path parameters**

- `id` (string · uuid, required): The appointment's `id`.

**Responses**

- `200`: The appointment.

- `404`: No such appointment in your organisation.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/appointments/cff5e8d2-bd17-4546-bbc6-0376a41596c3 \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/appointments/cff5e8d2-bd17-4546-bbc6-0376a41596c3', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const appointment = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/appointments/cff5e8d2-bd17-4546-bbc6-0376a41596c3',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
appointment = res.json()
```

**Example response · 200**

```json
{
  "id": "cff5e8d2-bd17-4546-bbc6-0376a41596c3",
  "title": "NN4 8LN - Smith",
  "eventName": null,
  "description": "Customer asked for a morning visit",
  "location": "6 Briar Hill Walk Northampton NN4 8LN",
  "type": {
    "id": "ab6e78a0-4e85-405f-ba19-4753beac8445",
    "name": "Sales",
    "value": "sales"
  },
  "status": "confirmed",
  "outcome": null,
  "start": "2026-10-13T10:00:00.000+01:00",
  "end": "2026-10-13T11:00:00.000+01:00",
  "allDay": false,
  "participants": [
    {
      "kind": "user",
      "id": "50f716dc-c202-4d27-bc60-a1697533e03b",
      "name": "Jo Savant",
      "email": "jo@example.com"
    }
  ],
  "linkedTo": {
    "kind": "lead",
    "id": "8bd8e096-dd31-44cf-8373-3a00db14dfd3",
    "code": "L013885"
  },
  "locked": false,
  "createdBy": {
    "id": "50f716dc-c202-4d27-bc60-a1697533e03b",
    "name": "Jo Savant"
  },
  "createdAt": "2026-10-01T14:14:54.527+01:00",
  "updatedAt": "2026-10-01T14:14:54.527+01:00"
}
```

---

# Book an appointment

URL: https://sandbox-api-docs.evacrm.co.uk/docs/appointments/book

POST /v1/appointments — book on a lead, a contract or nothing, with the CRM's duplicate and clash checks.

## POST Book an appointment [#book-an-appointment]

`POST /v1/appointments`

Books an appointment through the CRM's own writer, so everything that happens when staff book
happens here too: notifications to the people it is for, and on a lead the sales
person or surveyor, the sales date and the workflow's *appointed* event, which may move the lead's
stage exactly as booking in the CRM would.

**Request body**

- `type` (string · name, value or id, required): From [appointment types](/docs/appointments/read#appointment-types).

- `start` (string · date or date-time, required): In your timezone unless it carries an offset.

- `end` (string · date or date-time): Not with `durationMinutes`. Neither means one hour.

- `durationMinutes` (integer · 5 to 1440, default 60): Not with `end`.

- `allDay` (boolean, default false): Covers whole calendar days; the times in `start` and `end` are ignored.

- `status` (string, default provisional): Confirmed needs at least one user or team, as in the CRM.

- `users` (string[] · max 20): Who it is for, by user id or email. Neither `users` nor `teams` books it unassigned.

- `teams` (string[] · max 10): Teams by id or name. May be sent together with `users`.

- `leadId` (string · uuid): The lead it belongs to. Not with `contractId`. Neither makes a plain diary entry.

- `contractId` (string · uuid): The contract it belongs to.

- `title` (string · max 255): Left out, the CRM's own is generated: postcode and surname for a sales visit on a lead, otherwise the type and who it is for.

- `description` (string · max 2000): 

- `location` (string · max 500): Left out, the record's contact address is used.

- `force` (boolean, default false): Book even when the checks below would refuse.

### The CRM's checks [#the-crms-checks]

Before writing, the two checks the CRM's booking form runs. Send `force: true` to book anyway,
as staff can in the CRM. Cancelled appointments never block.

* **The customer already has one.** Another appointment for the same customer on the same kind of
  record overlaps the window.
* **Someone is already booked.** A user or team on this appointment has an overlapping appointment
  that is not cancelled. [Availability](/docs/appointments/availability) shows where they are
  free.

**Responses**

- `201`: The appointment, as GET shows it.

- `404`: No such lead or contract.

- `409`: The customer already has an appointment in that window (`fields.start`), or someone on it is already booked (`fields.users`).

- `422`: A field is wrong, confirmed with nobody on it, or a confirmed Fitting on a contract whose workflow stage does not allow confirming fittings — as in the CRM. `fields` names it.

- `502`: The CRM did not answer. Nothing was booked; repeat the request.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/appointments \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "Sales",
    "start": "2026-10-13T10:00",
    "durationMinutes": 60,
    "users": ["jo@example.com"],
    "leadId": "8bd8e096-dd31-44cf-8373-3a00db14dfd3",
    "description": "Customer asked for a morning visit"
  }'
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/appointments', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    type: 'Sales',
    start: '2026-10-13T10:00',
    durationMinutes: 60,
    users: ['jo@example.com'],
    leadId: '8bd8e096-dd31-44cf-8373-3a00db14dfd3',
    description: 'Customer asked for a morning visit',
  }),
});
if (res.status === 409) {
  // clash — show availability, or resend with force: true
}
const appointment = await res.json();
```

*Python*

```python
import os, requests

res = requests.post(
    'https://api.evacrm.co.uk/v1/appointments',
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
    json={
        'type': 'Sales',
        'start': '2026-10-13T10:00',
        'durationMinutes': 60,
        'users': ['jo@example.com'],
        'leadId': '8bd8e096-dd31-44cf-8373-3a00db14dfd3',
        'description': 'Customer asked for a morning visit',
    },
)
res.raise_for_status()
appointment = res.json()
```

**Example response · 201**

```json
{
  "id": "cff5e8d2-bd17-4546-bbc6-0376a41596c3",
  "title": "NN4 8LN - Smith",
  "eventName": null,
  "description": "Customer asked for a morning visit",
  "location": "6 Briar Hill Walk Northampton NN4 8LN",
  "type": { "id": "ab6e78a0-4e85-405f-ba19-4753beac8445", "name": "Sales", "value": "sales" },
  "status": "provisional",
  "outcome": null,
  "start": "2026-10-13T10:00:00.000+01:00",
  "end": "2026-10-13T11:00:00.000+01:00",
  "allDay": false,
  "participants": [{ "kind": "user", "id": "50f716dc-c202-4d27-bc60-a1697533e03b", "name": "Jo Savant", "email": "jo@example.com" }],
  "linkedTo": { "kind": "lead", "id": "8bd8e096-dd31-44cf-8373-3a00db14dfd3", "code": "L013885" },
  "locked": false,
  "createdBy": { "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e", "name": "Tuan Dinh" },
  "createdAt": "2026-10-01T14:14:54.527+01:00",
  "updatedAt": "2026-10-01T14:14:54.527+01:00"
}
```

Cancelling, rescheduling and editing are not available yet; do those in the CRM.

---

# Availability

URL: https://sandbox-api-docs.evacrm.co.uk/docs/appointments/availability

Free slots for a person, a role, or whoever can take an appointment type, from the same diary and working hours the CRM uses.

## GET Availability [#availability]

`GET /v1/appointments/availability`

Which staff are free when. Ask for particular people, for everyone with a role, or let an
appointment type choose the role, and get each person's slots for the days requested plus, per
slot, who is free in it. One of `users`, `roles` or a role-bearing `type` is required. At most
50 people per request; narrow with `users` or `roles` for more.

**Query parameters**

- `from` (string · date, default today): First calendar day, in your timezone.

- `to` (string · date, default six days after from): Last calendar day, inclusive. At most 31 days.

- `users` (string · list, one of users, roles, type required): User ids or emails.

- `roles` (string · list, one of users, roles, type required): Everyone holding any of these [roles](/docs/users#roles).

- `type` (string · name, value or id, one of users, roles, type required): When neither `users` nor `roles` is sent, the type's `roles` (see [appointment types](/docs/appointments/read#appointment-types)) pick the people. A type for anyone still needs `users` or `roles`.

- `slotMinutes` (integer · 15 to 480, default 60): Slot length.

- `available` (string, default all): `only` drops the slots, and the people in each slot, that are not free.

### How a slot is decided [#how-a-slot-is-decided]

Slots run across your organisation's working time, the **Morning** and **Afternoon** set under the
CRM's schedule settings (09:00 to 17:00 when none is set), starting when the day opens. For each
person a slot is free unless it is:

* `closed`: the day is a bank holiday or shutdown in the CRM's working days.
* `busy`: they are on an appointment that overlaps it, directly or through a team, in any status
  but cancelled. An all-day appointment, including an approved absence, blocks its whole days.
* `not_working`: your organisation runs the CRM's **Work Schedules** and they are not scheduled
  then. As in the CRM's Slot view this is advisory, and booking is still allowed.

Weekends are not treated specially; close them with the CRM's working days if needed.

**Responses**

- `200`: `users`, each with their `slots`, and `slots`, each with who is free in it.

- `422`: No `users`, `roles` or role-bearing `type`; an unknown user or type; more than 50 people; or a window over 31 days. `fields` names it.

**Example request**

*curl*

```bash
# any salesman, next week, hour slots
curl "https://api.evacrm.co.uk/v1/appointments/availability?roles=salesman&from=2026-10-12&to=2026-10-16" \
  -H "Authorization: Bearer sk_..."

# one person's diary, half-hour slots, free ones only
curl "https://api.evacrm.co.uk/v1/appointments/availability?users=jo@example.com&from=2026-10-13&slotMinutes=30&available=only" \
  -H "Authorization: Bearer sk_..."

# a showroom slot: the type is booked for salesmen, so their free slots come back
curl "https://api.evacrm.co.uk/v1/appointments/availability?type=showroom&from=2026-10-13&available=only" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const url = new URL('https://api.evacrm.co.uk/v1/appointments/availability');
url.searchParams.set('roles', 'salesman');
url.searchParams.set('from', '2026-10-12');
url.searchParams.set('to', '2026-10-16');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` } });
const { users, slots } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/appointments/availability',
    params={'roles': 'salesman', 'from': '2026-10-12', 'to': '2026-10-16'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
availability = res.json()
```

**Example response · 200**

```json
{
  "from": "2026-10-13",
  "to": "2026-10-13",
  "timezone": "Europe/London",
  "slotMinutes": 240,
  "workingHours": [{ "start": "08:00", "end": "17:00" }],
  "workingHoursSource": "organisation",
  "workSchedules": false,
  "type": null,
  "roles": ["salesman"],
  "closedDays": [],
  "users": [
    {
      "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e",
      "name": "Tuan Dinh",
      "email": "tuan@example.com",
      "roles": ["salesman", "surveyor"],
      "slots": [
        { "start": "2026-10-13T08:00:00.000+01:00", "end": "2026-10-13T12:00:00.000+01:00", "available": true },
        { "start": "2026-10-13T12:00:00.000+01:00", "end": "2026-10-13T16:00:00.000+01:00", "available": false, "reason": "busy" }
      ]
    }
  ],
  "slots": [
    { "start": "2026-10-13T08:00:00.000+01:00", "end": "2026-10-13T12:00:00.000+01:00", "available": ["cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e"] },
    { "start": "2026-10-13T12:00:00.000+01:00", "end": "2026-10-13T16:00:00.000+01:00", "available": [] }
  ]
}
```

`users[].slots` is one person's diary; `slots` is the same information turned round, for "who can
I send at ten on Tuesday". `closedDays` lists the days that came back closed and why, and
`workingHoursSource` is `default` when your organisation has no working time set.

### Booking a slot [#booking-a-slot]

Take a slot's `start` and the person's `id` to
[book an appointment](/docs/appointments/book#book-an-appointment). The booking runs the CRM's
clash check again, so a slot taken between the two calls comes back as a `409`.

---

# Users

URL: https://sandbox-api-docs.evacrm.co.uk/docs/users

The staff a lead can be assigned to, and how assignment works.

## GET List users [#list-users]

`GET /v1/users`

The people in your organisation a lead can be assigned to: enabled users who are not team-only
members (fitters and surveyors who exist for scheduling and never log in). Ordered by name, not
paginated, cached for five minutes. The same list is available as
[`GET /v1/leads/fields/assignedTo`](/docs/leads/fields#get-a-field).

**Query parameters**

- `role` (string · list): One or more [roles](#roles), comma-separated or repeated: users holding any of them.

**Responses**

- `200`: `data`, the users. `email` can be `null` for a few older accounts; those can only be referenced by `id`.

- `422`: An unknown parameter.

**Example request**

*curl*

```bash
curl "https://api.evacrm.co.uk/v1/users?role=salesman" \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/users?role=salesman', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: users } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get(
    'https://api.evacrm.co.uk/v1/users',
    params={'role': 'salesman'},
    headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"},
)
res.raise_for_status()
users = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    {
      "id": "cd9356b7-dd81-4f9a-a6fd-9cc9ae967a4e",
      "name": "Tuan Dinh",
      "firstName": "Tuan",
      "lastName": "Dinh",
      "email": "tuan@example.com",
      "jobTitle": "Sales manager",
      "roles": ["salesman"]
    }
  ]
}
```

## Roles [#roles]

`roles` is what the CRM shows as **Roles** on a staff record: `salesman`, `surveyor`, `fitter`,
`service_engineer` and any your administrator has added. They say what a person does, not what
they are allowed to see. The same values drive [availability](/docs/appointments/availability)
and tell you who an [appointment type](/docs/appointments/read#appointment-types) is for.

## GET List roles [#list-roles]

`GET /v1/users/roles`

The roles your organisation uses, as `value` (what you send) and `name` (what staff see). No
parameters. Cached for five minutes.

**Responses**

- `200`: `data`, the roles.

**Example request**

*curl*

```bash
curl https://api.evacrm.co.uk/v1/users/roles \
  -H "Authorization: Bearer sk_..."
```

*Node.js*

```js
const res = await fetch('https://api.evacrm.co.uk/v1/users/roles', {
  headers: { Authorization: `Bearer ${process.env.EVA_API_KEY}` },
});
const { data: roles } = await res.json();
```

*Python*

```python
import os, requests

res = requests.get('https://api.evacrm.co.uk/v1/users/roles', headers={'Authorization': f"Bearer {os.environ['EVA_API_KEY']}"})
res.raise_for_status()
roles = res.json()['data']
```

**Example response · 200**

```json
{
  "object": "list",
  "data": [
    { "value": "salesman", "name": "Salesman" },
    { "value": "surveyor", "name": "Surveyor" },
    { "value": "fitter", "name": "Fitter" }
  ]
}
```

## Assigning a lead [#assigning-a-lead]

Send a user's `id` or `email` as `assignedTo` when [creating a lead](/docs/leads/create). The
user gets the CRM's normal "lead assigned to you" notification. An unknown or disabled user is a
422 naming `assignedTo`.

A lead is held by a user *or* a team, never both; the CRM's automatic team assignment still runs
for leads you do not assign. To reassign later, send `assignedTo` on
[update a lead](/docs/leads/update); `null` unassigns.

## Finding leads by assignee [#finding-leads-by-assignee]

`GET /v1/leads?assignedTo=<id or email>` returns that user's leads, and `assignedTo=none`
returns leads nobody holds. Every lead in a list carries `assignee` and `assignedTeam` (at most
one of them is set):

```json
{
  "assigned": true,
  "assignee": { "id": "cd9356b7-…", "name": "Tuan Dinh", "email": "tuan@example.com" },
  "assignedTeam": null
}
```

---

# Changelog

URL: https://sandbox-api-docs.evacrm.co.uk/docs/changelog

Every change you could notice, dated. Nothing is removed or renamed.

Changes are additive. An endpoint or field, once published, is never removed or renamed; at most
it is marked deprecated with a replacement named, and keeps working.

## 2026-09-02 — initial release [#2026-09-02--initial-release]

* `GET /v1/whoami`
* `POST /v1/leads`, replacing the `webhooks.evacrm.co.uk` intake. Its nine fields (`id`, `name`,
  `email`, `phone`, `postcode`, `message`, `smsMarketing`, `emailMarketing`) are accepted
  unchanged and marked deprecated in favour of explicit ones.
* `PATCH /v1/leads/{id}` to change any subset of a lead's fields, with `If-Unmodified-Since`.
* `GET /v1/leads/{id}`, and `GET /v1/leads` with cursor pagination and `status`, date, `reference`, `source`, `code`,
  `test` and `assignedTo` filters, plus `stage`, `workflow` and `daysInStage` to find leads stuck
  in a stage — a stage name matches every workflow that has it. Each lead carries `daysInStage`.
* `GET /v1/leads/workflows` and `GET /v1/contracts/workflows`: every workflow with its stages,
  the names and ids those filters take.
* `GET /v1/leads/fields` and `GET /v1/leads/fields/{field}`.
* `POST`, `GET` and `DELETE` on `/v1/leads/{id}/attachments`, with `documentType` to file a
  batch, and `GET /v1/leads/attachments/categories` to list what's available.
* `GET /v1/leads/{id}/stages` and `PUT /v1/leads/{id}/stage`.
* `GET /v1/leads/{id}/notes` and `GET /v1/leads/{id}/tasks`, and the same under
  `/v1/contracts/{id}`: what staff wrote and what the CRM raised, paged, with `auto`, `internal`
  and `pinned` filters on notes and `status` and due-date filters on tasks.
* `GET /v1/users` and `assignedTo` on lead creation, with each user's `roles` and a `role` filter, and
  `GET /v1/users/roles`.
* Supplier invoices: `GET /v1/suppliers`, `GET /v1/supplier-invoices`, `GET /v1/supplier-invoices/{id}`,
  `GET /v1/supplier-invoices/types`, bulk `POST /v1/supplier-invoices`, `PATCH /v1/supplier-invoices/{id}`,
  `DELETE /v1/supplier-invoices/{id}`, and `POST /v1/supplier-invoices/{id}/export` and
  `POST /v1/supplier-invoices/{id}/unexport` — an exported invoice is read-only until unmarked.
* Appointments: `GET /v1/appointments` with window, type, status, user, role, team and record
  filters, `GET /v1/appointments/{id}`, `GET /v1/appointments/types` and `POST /v1/appointments`
  with the CRM's duplicate and clash checks.
* `GET /v1/appointments/availability`: free slots by person, role or appointment type.
* Contracts: `GET /v1/contracts` with the same filters as leads, `stage`, `workflow` and
  `daysInStage` included, `GET /v1/contracts/{id}`, stages and attachments as for leads.
* Invoices: `GET /v1/invoices/types`, `GET /v1/contracts/{id}/invoices`, `GET /v1/invoices/{id}`,
  `POST /v1/invoices/{id}/raise`, `POST /v1/invoices/{id}/unraise`, `DELETE /v1/invoices/{id}` and
  `GET /v1/invoices/{id}/pdf` for a download link.
  `POST /v1/contracts/{id}/invoices` is reserved for creating invoices and answers 501 until then.
* Payments: `GET /v1/contracts/{id}/payments`, `POST /v1/contracts/{id}/payments`,
  `GET /v1/payments/{id}`, `POST /v1/payments/{id}/allocations`.
* Dates are read and shown in your organisation's timezone, with `X-Timezone` to override per
  request. See [dates and timezones](/docs/conventions#dates-and-timezones).

### Known limitations [#known-limitations]

* Keys have no scopes; every key can use every endpoint.
* No rate limit is enforced yet. Design for 60 requests per minute per key.
* Deleting files uploaded by staff is not available.
