---
title: Conventions
description: How the Signal REST API shapes requests and responses, pages through lists, reports errors and handles retries.
product: signal
section: api
order: 3
---

# Conventions

These rules apply to every endpoint unless its reference page says otherwise.

## Base URL and versioning

```
https://crm.empiraa.app/api/v1
```

The version is part of the path. Changes that would break an existing integration go into a new version; fields and endpoints may be added to `v1` at any time, so ignore fields you don't recognize.

`GET /api/v1` (no key needed) returns the API's name, version and a list of its main endpoints. A path that doesn't exist answers `404 ENDPOINT_NOT_FOUND`.

## Requests

- Send request bodies as JSON with `Content-Type: application/json`. A body that isn't valid JSON answers `400 INVALID_REQUEST` ("Invalid JSON body").
- Field names are `snake_case`, in requests and responses.
- `PATCH` changes only the fields you send. A field you leave out keeps its value. Where a field can be cleared, send `null`.
- Most endpoints ignore fields they don't recognize. The exceptions say so on their reference pages (call-offs reject unknown fields; prospect contacts, developments and plots treat unknown fields as [custom fields](#custom-fields)).

## Response envelope

Successful responses wrap the result in an envelope:

```json
{
  "data": { "id": "3f6c2a1e-8b4d-4c2a-9f1e-2b7d5a9c4e10", "name": "Contoso Pharmaceuticals" },
  "meta": { "cursor": null },
  "error": null
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `data` | object or array | The record, or the page of records for a list. |
| `meta` | object | `cursor` is always present (`null` when there's nothing more). Lists add `hasMore`, and some add `total`, `count`, `limit`, `offset` or `truncated`. |
| `error` | null | Always `null` on success. Errors use a different shape: see [Errors](#errors). |

`DELETE` requests answer `204 No Content` with no body. `POST` requests that create a record answer `201 Created`.

## IDs

IDs are opaque strings. Store and compare them exactly as returned, and don't parse them.

- Most records use UUIDs, such as `3f6c2a1e-8b4d-4c2a-9f1e-2b7d5a9c4e10`.
- Some use 25-character collision-resistant IDs that start with `c`, such as `cm3k9x2p10004qz8f7h2d1a6b` (Prospect Companies, notes, calls, bookings and the `/tasks` list).
- Reference values that Signal defines use readable IDs, such as the prospect stage `rps_new`, the activity type `rat_call` or the deal stage `rds_qualification`. Each reference page lists the ones it uses.

User IDs (in `owner_id` and similar fields) are the `id` values from [`GET /users`](reference/users-and-me.md).

## Dates and times

- Timestamps are ISO 8601 strings in UTC with milliseconds, such as `2026-11-12T09:30:00.000Z`.
- Send timestamps as ISO 8601. Include a time zone offset or `Z`; a value the API can't read answers `400 INVALID_REQUEST`.
- A few fields are calendar days and use `YYYY-MM-DD`, such as a call-off's `required_by`. Their reference pages say so.

## Money and currency

- Amounts are JSON numbers in the currency's major unit, with up to two decimal places: `48500.5` means 48,500.50.
- Deals, plots, reservations and call-offs don't carry their own currency. They're in the workspace's currency, which `GET /me` returns as `currency` (an ISO 4217 code such as `USD`).
- Account spend records carry their own three-letter `currency`. If you leave it out when creating one, it's stored as `GBP`, so always send it.

## Pagination

List endpoints return one page at a time. Most use **cursor** pagination:

| Query parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `limit` | integer | `20` | Records per page, from 1 to 100. Values outside that range are clamped. |
| `cursor` | string | none | The `meta.cursor` value from the previous page. |

Records come newest first (by creation time) unless a reference page says otherwise. When `meta.hasMore` is `true`, request the next page with `cursor` set to `meta.cursor`. When it's `false`, `meta.cursor` is `null` and you've reached the end.

```js
async function listAll(path, apiKey) {
  const results = [];
  let cursor = null;
  do {
    const url = new URL(`https://crm.empiraa.app/api/v1${path}`);
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const body = await res.json();
    results.push(...body.data);
    cursor = body.meta.hasMore ? body.meta.cursor : null;
  } while (cursor);
  return results;
}

const contacts = await listAll("/contacts", "YOUR_API_KEY");
```

Pass the cursor back exactly as you received it, with the same filters. Some cursors are record IDs and some are offsets; treat them all as opaque.

**Offset pagination.** `GET /prospect-companies` uses `limit` (default 50, up to 200) and `offset` (default 0) instead, and returns `total`, `limit`, `offset` and `hasMore` in `meta`. See [Prospect Companies](reference/prospect-companies.md).

**Unpaged lists.** `GET /users` and `GET /prospect-sources` return everything in one response.

## Filtering and sorting

Filters are query parameters, listed on each endpoint's reference page (for example `GET /activities?deal_id=...` or `GET /property/plots?status=available`). Filters combine with AND. Matching is exact. There is no free-text search and no sort parameter: each list has a fixed order, newest first unless stated.

## Custom fields

Workspaces can add custom fields to records in **Settings → CRM → Custom Fields**. Each field has an **Internal name**, which is its key in the API.

Custom fields are available through the API on prospect contacts, developments and plots:

- **Reading.** Those records include a `custom_fields` object, keyed by internal name. Every field defined for that record type is present; a field with no value is `null`.
- **Writing.** On create and update, send values in a `custom_fields` object, or as top-level fields named after the internal name. Keys are matched to internal names (a `camelCase` key is also tried as `snake_case`). Keys that match no field are ignored.

```json
{
  "first_name": "Maria",
  "email": "maria@fabrikam.example",
  "custom_fields": {
    "budget_band": "50k-100k",
    "decision_date": "2026-12-01",
    "wants_demo": true
  }
}
```

Values by field type:

| Field type | JSON value |
| --- | --- |
| Text, text area, dropdown, radio, URL, file | string |
| Number, monetary | number |
| Date | ISO 8601 string (read back as a full timestamp) |
| Checkbox | boolean (`true` or `"true"` count as checked) |
| Multi-select | array of strings |

A value that can't be stored is skipped and the rest of the request still succeeds, so read the record back if you need to be sure.

## Assigned To and owner fields

`owner_id` holds the user a record belongs to. On prospect contacts and Prospect Companies it's the record's **Assigned To**. Use a user `id` from [`GET /users`](reference/users-and-me.md), or `null` for nobody.

- An API key stands for the workspace, not a person, so the API never guesses an owner. Leave `owner_id` out and the record has nobody on it.
- `owner_id` (and an activity's `assigned_user_id`, and a note's `created_by`) must name an active member of the workspace. Otherwise the request answers `404 NOT_FOUND` and nothing is written.
- On an update, sending back the `owner_id` a record already has is always accepted, even if that person has since left, so you can write a whole record back.

## Linked records

A record you link (`account_id`, `contact_id`, `deal_id`, `prospect_id`) must be in your workspace. One that isn't, including another workspace's, answers `404 NOT_FOUND` ("Account not found", "Deal not found" and so on), exactly as a record that doesn't exist would, and nothing is written. Send `null` to unlink.

## Errors

Errors use the problem details format (RFC 9457) with `Content-Type: application/problem+json`:

```json
{
  "type": "https://signal.empiraa.com/errors/not-found",
  "title": "Deal not found",
  "status": 404,
  "code": "NOT_FOUND"
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `type` | string | A URI that names the error, built from `code`. |
| `title` | string | A human-readable summary. For validation errors it names the field, such as "name is required". |
| `status` | integer | The HTTP status code. |
| `code` | string | A stable, machine-readable code. Branch on this, not on `title`. |
| `detail` | string | More detail, when there is any. |

The `type` value is an identifier only. It doesn't open a page.

### Error codes

| HTTP status | `code` | Meaning |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | The body isn't valid JSON, a required field is missing, or a field has the wrong type or value. `title` says which. |
| 400 | `NO_QUESTIONNAIRE_FIELDS` | A Deck was requested from a questionnaire, but the prospect contact has no questionnaire fields ticked. |
| 401 | `MISSING_API_KEY` | No key was sent. |
| 401 | `INVALID_API_KEY` | The key doesn't exist, was revoked or has expired. |
| 402 | `WORKSPACE_PAYMENT_REQUIRED` | The workspace's subscription doesn't allow changes right now. Writes only. |
| 403 | `INSUFFICIENT_SCOPE` | The key lacks the endpoint's scope. `detail` names it. |
| 403 | `PLAN_FEATURE_REQUIRED` | The workspace's plan doesn't include the feature (Marketing). Uses a different body; see below. |
| 404 | `NOT_FOUND` | The record, or a record the request refers to, doesn't exist in this workspace. |
| 404 | `ENDPOINT_NOT_FOUND` | No endpoint exists at that path. |
| 405 | `METHOD_NOT_ALLOWED` | The endpoint doesn't accept that method. The `Allow` header lists the ones it does. |
| 409 | `CONFLICT` | The record's current state doesn't allow the change (for example, deleting a development that has call-offs, or recording a payment before an invoice). |
| 409 | `IDEMPOTENCY_IN_PROGRESS` | Another request with the same `Idempotency-Key` is still running. Uses a different body; see [Idempotency](#idempotency). |
| 422 | `INVALID_STAGE` | A Deal's `stage_id` isn't one of its pipeline's stages. |
| 500 | `SERVER_ERROR` | Something went wrong on Signal's side. Retry later. |
| 500 | `CREATE_FAILED` | A Deck couldn't be created. Retry later. |
| 500 | `PERSONALISE_FAILED` | A Deck's personalization step failed. Retry later. |
| 503 | `FEATURE_CHECK_ERROR` | Signal couldn't check the workspace's plan. Retry shortly. Uses a different body; see below. |

Plan checks on `GET /marketing/campaigns` answer with a plain JSON body instead of problem details:

```json
{
  "error": "Marketing is not available for your workspace yet.",
  "code": "PLAN_FEATURE_REQUIRED",
  "feature": "marketing",
  "planName": "Launch"
}
```

`503 FEATURE_CHECK_ERROR` has the same shape, with `error` and `code` only. Read `code` in all cases.

## Rate limits

Signal allows 1,000 requests a minute for each API key. Past that it answers `429` with a `Retry-After` header that says how many seconds to wait. Keep integrations well behaved:

- Page through large lists with `limit=100` rather than many small requests.
- On `429`, wait for the `Retry-After` time, then retry.
- Retry `5xx` responses with exponential backoff (for example 1 s, 2 s, 4 s), and send an `Idempotency-Key` on creates you retry.
- Use [webhooks](webhooks.md) instead of polling for changes.

## Idempotency

Network failures can leave you unsure whether a create succeeded. Send an `Idempotency-Key` header with a unique value (a UUID works well), and Signal makes sure retries with the same key create the record only once.

```bash
curl -X POST https://crm.empiraa.app/api/v1/deals \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8b0f6c1e-2d4a-4f7b-9c3e-5a1d7e9b2c40" \
  -d '{"name": "Contoso renewal", "account_id": "3f6c2a1e-8b4d-4c2a-9f1e-2b7d5a9c4e10", "stage_id": "rds_qualification"}'
```

These endpoints honor `Idempotency-Key`:

- `POST /deals`
- `POST /prospects`
- `POST /activities`
- `POST /calls`
- `POST /call-logs`
- `POST /bookings`

Other endpoints ignore the header.

How it works:

- The first response for a key is stored for **24 hours** and returned unchanged, with the same status code, to any retry with that key.
- Keys are scoped to your workspace, so they can't collide with another customer's.
- If a retry arrives while the first request is still running, it waits up to 5 seconds for the result. If the first request still hasn't finished, the retry answers `409` with code `IDEMPOTENCY_IN_PROGRESS`. Retry again shortly.
- Server errors (`5xx`) aren't stored, so you can retry them with the same key.
- Requests that fail validation before the record is written (for example `400` or `404` responses) aren't stored either; fix the request and retry.
- Use a new key for each new record. Reusing a key returns the first record, even if the body is different.

The `409 IDEMPOTENCY_IN_PROGRESS` response uses the envelope rather than problem details:

```json
{
  "data": null,
  "meta": {},
  "error": {
    "code": "IDEMPOTENCY_IN_PROGRESS",
    "message": "A request with this Idempotency-Key is still being processed. Retry shortly."
  }
}
```

## CORS

The API answers cross-origin requests from any origin:

| Header | Value |
| --- | --- |
| `Access-Control-Allow-Origin` | `*` |
| `Access-Control-Allow-Methods` | `GET, POST, PATCH, DELETE, OPTIONS` |
| `Access-Control-Allow-Headers` | `Authorization, X-API-Key, Content-Type` |
| `Access-Control-Max-Age` | `86400` |

Every endpoint answers `OPTIONS` preflight requests with `204 No Content`.

Calling the API from a browser exposes your API key to anyone who uses the page, so call it from your server instead. `Idempotency-Key` isn't in the allowed headers, so browsers can't send it cross-origin.

