Conventions

How the Signal REST API shapes requests and responses, pages through lists, reports errors and handles retries.

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

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.

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.

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.

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

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

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