Developer documentation

Build on CRM City

A REST API to read and write your CRM, signed outgoing webhooks that notify your platform the moment something happens, and ingest endpoints so Shopify, Stripe or your own code can push data in. Invoices, bookings and conversations are read-only.

Getting started

Your first request in five steps

  1. Sign in and create an API key in Settings → API keys. Create one key per integration — each key is named, shown in full exactly once, and can be revoked independently without breaking your other integrations.
  2. Copy the secret key (crm_sec_…) and store it in your platform's environment variables — never hardcode it or ship it to a browser.
  3. Authenticate every request with the header X-CRM-API-Key: crm_sec_…Some clients — agent builders especially — make you pick the header name from a fixed list. Any of these work and mean the same thing: x-api-key, api-key, apikey, or Authorization: Bearer crm_sec_…
  4. The base URL is your CRM's domain: https://<your-crm-domain>/api/crm/...
  5. Smoke-test the key with GET /api/crm/me, then start syncing contacts with POST /api/crm/contacts.
# Smoke test — works with both key types
curl https://<your-crm-domain>/api/crm/me \
  -H "X-CRM-API-Key: crm_sec_xxx..."

# → { "ok": true, "tenant": { ... }, "platform": { "name": "..." }, "key": { "level": "secret" } }

Authentication

Two kinds of keys

Publishable key (crm_pub_…)

Limited, not harmless. It can call the smoke test, read the product and course catalogues, and mark an invitation as opened or accepted when it holds that invitation's link token, which also returns the invitee's name and email. It cannot write anything else, and it reads no company and no course enrolment: those name people, so they need the secret key. Anyone who sees the key can do all of this.

Secret key (crm_sec_…)

Server-side only, full access: every read and every write. Required for every write except marking an invitation opened or accepted, and for every read that names a person — contacts, companies, course enrolments, orders, invoices, bookings and certificates. Never expose it in client-side JavaScript.

Keys are stored as SHA-256 hashes — the CRM cannot show a key again after creation. Calling an endpoint that needs the secret key with a publishable one returns 403 key_level_error. Revoked keys stop authenticating immediately.

Rate limits

Per-key, per-minute budgets

Each API key has its own read and write budget per minute. Defaults:

  • 300 reads/min (GET requests)
  • 60 writes/min (POST / PATCH / DELETE requests)

Exceeding a budget returns HTTP 429 with the error code rate_limit_exceeded and the headers Retry-After: <seconds>, X-RateLimit-Limit and X-RateLimit-Remaining. Back off and retry after the indicated delay. Higher per-key limits are available on higher tiers.

Endpoint reference

Everything under /api/crm

All endpoints live under https://<your-crm-domain>/api/crm and expect the X-CRM-API-Key header. Each entry below states which key level it accepts.

Identity

GET/api/crm/me— Smoke test: confirms the key and returns its organisation + key level. Publishable or secret key.

Contacts

GET/api/crm/contacts?email=...&q=...&limit=50&order=id&cursor=...— List / search contacts. Most recently changed first by default; to go through every contact, pass order=id and follow next_cursor until it is null. Secret key.
POST/api/crm/contacts— Upsert by email — fill-blanks-only (see the flagship example below). Secret key.
GET/api/crm/contacts/:id— A single contact. Secret key.
POST/api/crm/contacts/:id/activities— Log an activity on the contact's timeline: { type, subject?, description?, occurred_at? }. Secret key.
GET/api/crm/contacts/:id/tags— The contact's tags. Secret key.
POST/api/crm/contacts/:id/tags— Add a tag: { name, color? } — created automatically if missing. color is a hex code, #rrggbb (stored in lower case); anything else is refused with 400. Secret key.
DELETE/api/crm/contacts/:id/tags?tag=name— Remove a tag (by ?tag=name or ?tag_id=uuid). Secret key.
PATCH/api/crm/contacts/:id/hierarchy— Assign the contact to a hierarchy level: { hierarchy_level_id } (null clears it). Secret key.

Companies

GET/api/crm/companies?q=...&limit=50&offset=0— List / search companies by name, with their notes and addresses. Secret key.
POST/api/crm/companies— Create a company: { name, website?, industry?, address?, city?, country?, type?, notes? }. Secret key.

Deals

GET/api/crm/deals?stage_id=...&pipeline_id=...&limit=50— List deals. Secret key.
POST/api/crm/deals— Create a deal: { title, value?, currency?, pipeline_id?, stage_id?, contact_id?, ... }. Secret key.
PATCH/api/crm/deals/:id/stage— Move stage: { stage_id }. Won/lost detected from the target stage; fires deal events. Secret key.

Products

GET/api/crm/products?active=true&q=...&limit=50&offset=0— Product catalogue (active filter, name/SKU search). Publishable or secret key.
POST/api/crm/products— Create a product: { name, sku?, description?, price?, currency?, category?, url?, is_active? }. Secret key.
GET/api/crm/products/:id— A single product. Publishable or secret key.
PATCH/api/crm/products/:id— Update any subset of product fields. Secret key.
DELETE/api/crm/products/:id— Delete; products with order lines are deactivated instead (history stays true). Secret key.

Orders (sensitive)

GET/api/crm/orders?contact_id=...&status=...&limit=50&offset=0— List orders with their line items. Secret key.
POST/api/crm/orders— Create an order: contact by contact_id or email (upserted), items by product_id / sku / product_name; total computed. Fires order.placed. Secret key.

Invoices (sensitive, read-only)

GET/api/crm/invoices?status=...&contact_id=...&limit=50&offset=0— List invoices. Creation and editing stay in the app (numbering + line totals). Secret key.
GET/api/crm/invoices/:id— The invoice with its lines. Secret key.

Bookings (sensitive, read-only)

GET/api/crm/bookings?from=...&to=...&status=confirmed&limit=50&offset=0— Upcoming bookings (defaults to from now). Booking itself happens on the public /book/:slug page. Secret key.

Courses & enrollments

GET/api/crm/courses?active=true&q=...&limit=50&offset=0— Course catalogue. Publishable or secret key.
POST/api/crm/courses— Create a course: { title, description?, price?, duration_label?, is_active? }. Secret key.
GET/api/crm/courses/:id/enrollments?status=...&limit=50&offset=0— A course's enrollments, with each person's name and email. Secret key.
POST/api/crm/courses/:id/enrollments— Enroll a contact (contact_id or email → upserted). Duplicate enrollment → 409. Secret key.
PATCH/api/crm/enrollments/:id— Update { status?, progress?, notes? }; status “completed” sets completed_at and fires course.completed. Secret key.

Certificates (sensitive)

GET/api/crm/certificates?contact_id=...&course_id=...&limit=50&offset=0— Issued certificates, each with a public verification_url. Secret key.
POST/api/crm/certificates— Issue a certificate for a completed enrollment: { enrollment_id }. Only once per enrollment. Secret key.

Platform events

POST/api/crm/events— Record a platform event: { kind, email? | phone?, first_name?, payload?, occurred_at? } — upserts the contact and logs an activity. Secret key.

Groups

GET/api/crm/groups— List groups. Secret key.
POST/api/crm/groups— Create a group: { name, description?, source_type? }. Secret key.
GET/api/crm/groups/:id— The group plus its members with roles. Secret key.
DELETE/api/crm/groups/:id— Delete the group. Secret key.
GET/api/crm/groups/:id/members— Members with roles. Secret key.
POST/api/crm/groups/:id/members— Add a member: { contact_id, role? } (default role: member). Secret key.
DELETE/api/crm/groups/:id/members?contact_id=...— Remove a member. Secret key.

Hierarchy

GET/api/crm/hierarchy— The organisation's ordered hierarchy levels. Secret key.
POST/api/crm/hierarchy— FULL replace of the levels: { levels: [{ label, can_see_below?, can_manage? }] }. Secret key.
POST/api/crm/hierarchy/levels— Insert one intermediate level at a position: { position, label, ... }. Secret key.

Invitations

GET/api/crm/invitations?status=...&limit=50— List invitations. Secret key.
POST/api/crm/invitations— Create an invitation (target_type: platform | product | group | event | general). The response carries its invite_url. send: true (the default) asks for the email, which is refused today until your workspace can connect its own email account — the invitation is still created, sent is false and send_error says why. Secret key.
GET/api/crm/invitations/:token— Track an open; with ?redirect=1 and a target_url it 302-redirects. Returns the invitee's name and email: the token is what grants it. Publishable or secret key.
PATCH/api/crm/invitations/:token— Mark as accepted (fires invitation.accepted). Called from your own site. Publishable or secret key.

Webhook subscriptions

GET/api/crm/webhooks— List subscriptions. Secret key.
POST/api/crm/webhooks— Subscribe: { url, events?: string[] }. The HMAC secret is returned once in the response. Secret key.
DELETE/api/crm/webhooks/:id— Unsubscribe. Secret key.

Segments

GET/api/crm/segments/:slug/contacts— Resolve a segment slug to a list of contacts. Secret key.

Pastoral alerts

GET/api/crm/alerts?open=1— List needs (filter by open). Secret key.
POST/api/crm/alerts— Raise a need: { contact_id, description, urgency? }. Fires alert.raised. Secret key.
PATCH/api/crm/alerts/:id— Mark as resolved. Secret key.

Field permissions

GET/api/crm/field-permissions— Field-group visibility policies, grouped per hierarchy level. Secret key.
PATCH/api/crm/field-permissions— Set one policy: { hierarchy_level_id, field_group, visible }. Secret key.

Backup & export

GET/api/crm/export— JSON snapshot of your organisation's records: { format, tables, config, generated_at }. The same tables and the same exclusions as Settings → Backup & Export; secrets are blanked and uploaded files are listed, not included. Secret key.

/api/crm/segments/:slug/contacts accepts these standard slugs:

  • all — all active contacts
  • tag:<name> — contacts with the given tag
  • leaders — contacts with the "leader" role in any group
  • role:<role> — contacts with a specific role in any group
  • group:<id> — members of a group
  • inactive-30 — contacts with no activity for 30+ days

Examples

The flagship flow: upsert a contact by email

POST /api/crm/contacts deduplicates by email with fill-blanks-only semantics: if a contact with that email already exists, only fields that are currently empty (last_name, phone, notes) are filled in — existing values are never overwritten. That makes it safe for every platform you own to push the same person without clobbering each other's data.

POST /api/crm/contacts
X-CRM-API-Key: crm_sec_xxx...
Content-Type: application/json

{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "phone": "+44 7700 900123",
  "source": "my-shop",
  "notes": "Signed up via the summer landing page"
}

# New contact → 201
{ "data": { "id": "…", "first_name": "Jane", "email": "jane@example.com", ... }, "created": true }

# Existing contact → 200; blanks filled, nothing overwritten
{ "data": { ... }, "created": false }

Only genuinely new contacts count towards your plan's contact limit — upserts of existing contacts never do. If the limit is reached, you get 403 plan_limit.

# Place an order (contact by email — created if missing; product by SKU)
curl https://<your-crm-domain>/api/crm/orders \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "items": [{ "sku": "PRO-1", "quantity": 2 }]
  }'
# → creates the order, computes the total from the catalogue, fires "order.placed"

# Mark a course enrollment completed (the key flow for external course platforms)
curl https://<your-crm-domain>/api/crm/enrollments/ENROLLMENT_ID \
  -X PATCH \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "completed" }'
# → sets completed_at, fires "course.completed"

# Issue the certificate (response contains a public verification_url)
curl https://<your-crm-domain>/api/crm/certificates \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{ "enrollment_id": "ENROLLMENT_ID" }'
API writes fire the same event bus as the interface — an order placed over the API triggers the same automations, lead scoring and outgoing webhooks as one entered by hand.

Webhooks out

CRM City notifies your platform

Subscribe any HTTPS URL in Settings → Webhooks OUT (or via POST /api/crm/webhooks) to any subset of the 22 events — an empty event list means all of them. Each delivery is an HTTP POST with a JSON body and these headers:

  • X-CRM-Signature: sha256=<hex> — HMAC-SHA256 of the raw request body, keyed with your subscription secret (whs_…, shown once at creation)
  • X-CRM-Event: <event name> — e.g. order.placed
  • User-Agent: CRM-City-Webhook/1.0

The body is { "event", "occurred_at", "tenant_id", "data" } — use occurred_at (ISO timestamp inside the signed payload) to reject stale replays. Failed deliveries (non-2xx or timeout after 10s) are retried with exponential backoff: 1m, 5m, 30m, 2h, 12h.

Verify the signature (Node.js)

import { createHmac, timingSafeEqual } from "crypto";

// rawBody: the EXACT request body bytes (before any JSON parsing)
// signatureHeader: the X-CRM-Signature header value
// secret: the whs_... secret returned when you created the subscription
function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? "");
  return a.length === b.length && timingSafeEqual(a, b); // timing-safe compare
}

The 22 event types

contact.created— A contact was created (manual, Telegram, inbound email or public booking).
contact.updated— A contact was edited from the contact page.
contact.birthday— A contact's birthday is today (daily cron at 07:00 UTC).
invitation.accepted— An invitation was accepted (public page or API).
invitation.expired— An invitation passed its expiry date (daily cron).
alert.raised— A pastoral need was raised via the API.
deal.stage_changed— A deal moved to another pipeline stage (UI or API).
deal.won— A deal reached a stage flagged as won.
deal.lost— A deal reached a stage flagged as lost.
campaign.sent— An email campaign finished sending (manual or scheduled).
feedback.submitted— A customer submitted a rating on the public feedback page.
approval.decided— An approval request was decided (in-app or public page).
score.crossed— A contact's lead score crossed a threshold, in either direction.
invoice.sent— An invoice was marked as sent.
invoice.paid— An invoice was marked as paid.
quote.accepted— A quote was accepted on its public page.
quote.declined— A quote was declined on its public page.
order.placed— An order was placed (manual, Shopify ingest or API).
product.interest— A first interest signal for a contact + product pair.
course.completed— An enrollment was marked completed (UI or API).
form.submitted— A public web form was submitted (contact created or enriched).
message.received— A contact wrote to you on Telegram or WhatsApp (the channel is in the payload).

Ingest in

Third-party platforms send to CRM City

Create a source in Settings → Webhooks IN — each source gets a name, which ends its own URL, and a verify secret. Incoming payloads are normalised into contacts and activities: the contact is upserted by email, gaps are backfilled, and everything is journaled (including failures).

Ingest endpoints

POST/api/ingest/:source— Receiver for Shopify, Stripe and custom platforms. Auth: X-Ingest-Secret header; Shopify payloads are additionally HMAC-SHA256 verified via X-Shopify-Hmac-Sha256. The source's verify secret, not an API key.
POST/api/ingest/generic— Catch-all for any custom code or automation tool. Auth: X-Ingest-Secret header. No HMAC. The source's verify secret, not an API key.
# Generic ingest — the simple JSON shape any platform can send
curl "https://<your-crm-domain>/api/ingest/generic" \
  -X POST \
  -H "X-Ingest-Secret: <verify_secret>" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "+44 7700 900123",
    "event": "signed_up",
    "value": 49.99,
    "currency": "GBP"
  }'
Ingest endpoints authenticate with the source's verify secret, not with API keys. Shopify sources additionally run the commerce pipeline — orders and abandoned checkouts become CRM orders and product-interest signals.

Errors

One error shape everywhere

Every error response has the same JSON envelope:

{ "error": "<code>", "message": "Human-readable explanation.", "field": "optional_field_name" }
  • 401 auth_error — missing or invalid API key (or ingest secret)
  • 403 key_level_error — this endpoint requires a secret key
  • 403 plan_limit — your plan's limit was reached (e.g. contact count)
  • 403 organization_deleted — the key's organisation has been deleted; the key works again, unchanged, if its owner restores the organisation within 30 days
  • 400 validation_error — an invalid or missing field (see field)
  • 400 invalid_body — the request body is not valid JSON
  • 404 not_found — the resource does not exist in the key's organisation
  • 409 conflict — duplicate (existing enrollment, duplicate SKU, certificate already issued)
  • 429 rate_limit_exceeded — too many requests (see Rate limits)
  • 500 db_error — internal error; safe to retry with backoff

MCP

Let AI agents operate your CRM

CRM City ships a native Model Context Protocol (MCP) server. Point any MCP-capable AI agent (Claude, or your own) at it and the agent can search contacts, enrich them, log activities, tag, create tasks and read your deals and products — always inside the organisation the API key belongs to, with the same rate limits as the REST API.

Connect (streamable HTTP)

Endpoint:  POST https://<your-crm-domain>/api/mcp
Header:    X-CRM-API-Key: crm_sec_...   (secret key required)

Example client config (Claude Code):
{
  "mcpServers": {
    "crm-city": {
      "type": "http",
      "url": "https://<your-crm-domain>/api/mcp",
      "headers": { "X-CRM-API-Key": "crm_sec_..." }
    }
  }
}

The tools

Each key decides which of these it may call — Settings → API Keys → Tools. A switched-off tool is not listed to the agent and is refused if called.

  • search_contacts / get_contact — find and read contacts (tags, lead score included)
  • get_contact_context — the full picture before writing: history, cadence, what was last said
  • upsert_contact — create or enrich by email, fill-blanks-only (same contract as the REST upsert)
  • update_contact — replace details on a contact; the previous value is written to the timeline first, and the tool is off until you switch it on per key
  • reschedule_followup — set or move the date a contact should next be reached; the previous date goes to the audit log, not the timeline, so routine rescheduling does not bury the notes worth reading
  • escalate_to_human — when someone asks to speak to a person, this logs the ask on their timeline and rings the bell for the account owner, with a link straight to the contact. It sends the person nothing: the agent still answers them, and tells them a human has been told
  • delete_contact — a recoverable delete: the contact moves to the recycle bin, where a person can restore it. Nothing is removed on a timer
  • log_activity — note/call/meeting/email on the timeline
  • add_tag — tag a contact (auto-creates the tag)
  • create_task — a task with a due date, optionally linked to a contact
  • complete_task — tick off a task: a one-off one is closed, a repeating one moves to its next date, exactly as when you tick it in the app
  • list_deals — open/won/lost deals with stage and contact
  • list_invoices — invoices with number, status, total and dates; filter by contact or status. Never returns the invoice's public link token
  • get_invoice — one invoice by id or number, with every line item
  • list_receivables — unpaid invoices past their due date, oldest first, with days late and a total per currency
  • list_campaign_results — what a campaign did: sent, delivered, opens, clicks, and the outcomes recorded per recipient with the money attached
  • list_products — the active catalog
  • list_flows — your automations and campaigns
  • list_upcoming_occasions — birthdays and dates worth a message
  • draft_personal_message — writes into drafts; it cannot send
  • list_conversations — the inbox: who wrote to the bot, newest first
  • get_conversation_messages — what the person actually said, oldest first
  • send_telegram_message — replies in an existing Telegram thread. Off by default on every key, and it takes no phone number or username, so it cannot start a thread with someone who has not written to you first
  • send_email — sends from a department box (sales, marketing, support…) to a contact already in your CRM, once your workspace can connect its own email account. Today every call is refused and logged as not sent: CRM City no longer sends a workspace's email to its contacts from its own account. Off by default on every key, and it takes no email address, only a contact id, so it cannot reach someone you have not added. It refuses rather than send when nothing would receive a reply, writes the send on the contact's timeline before it leaves, and respects the recipient's preferences and suppression
  • get_help — the manual, so the agent answers from it instead of guessing

Tools are a fixed whitelist mapped onto the same internals as the REST API — an agent can never do more than the key allows, and each tool can be switched off per key in Settings → API Keys. Read tools are on unless you switch them off; a tool that sends to a person is off until you switch it on. No SSE stream in v1; responses are plain JSON.

There is more in the getting-started guide, and the whole user manual is open too — no account needed for either.

Create your CRM and get an API key