Developer documentation

Build on CRM City

A complete 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. Everything the interface can do, the wire can do too.

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_…
  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_…)

Read-only and safe to use in frontends and browsers. It can call the smoke test, read the product and course catalogues, search companies, and track invitation opens and accepts. It cannot write CRM data or read anything sensitive.

Secret key (crm_sec_…)

Server-side only, full access: every read and every write. Required for all writes and for sensitive reads — 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/meSmoke test: confirms the key and returns tenant + key level. Publishable or secret key.

Contacts

GET/api/crm/contacts?email=...&q=...&limit=50List / search contacts. Secret key.
POST/api/crm/contactsUpsert by email — fill-blanks-only (see the flagship example below). Secret key.
GET/api/crm/contacts/:idA single contact. Secret key.
POST/api/crm/contacts/:id/activitiesLog an activity on the contact's timeline: { type, subject?, description?, occurred_at? }. Secret key.
GET/api/crm/contacts/:id/tagsThe contact's tags. Secret key.
POST/api/crm/contacts/:id/tagsAdd a tag: { name, color? } — created automatically if missing. Secret key.
DELETE/api/crm/contacts/:id/tags?tag=nameRemove a tag (by ?tag=name or ?tag_id=uuid). Secret key.
PATCH/api/crm/contacts/:id/hierarchyAssign the contact to a hierarchy level: { hierarchy_level_id } (null clears it). Secret key.

Companies

GET/api/crm/companies?q=...&limit=50&offset=0List / search companies by name. Publishable or secret key.
POST/api/crm/companiesCreate a company: { name, website?, industry?, address?, city?, country?, type?, notes? }. Secret key.

Deals

GET/api/crm/deals?stage_id=...&pipeline_id=...&limit=50List deals. Secret key.
POST/api/crm/dealsCreate a deal: { title, value?, currency?, pipeline_id?, stage_id?, contact_id?, ... }. Secret key.
PATCH/api/crm/deals/:id/stageMove 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=0Product catalogue (active filter, name/SKU search). Publishable or secret key.
POST/api/crm/productsCreate a product: { name, sku?, description?, price?, currency?, category?, url?, is_active? }. Secret key.
GET/api/crm/products/:idA single product. Publishable or secret key.
PATCH/api/crm/products/:idUpdate any subset of product fields. Secret key.
DELETE/api/crm/products/:idDelete; products with order lines are deactivated instead (history stays true). Secret key.

Orders (sensitive)

GET/api/crm/orders?contact_id=...&status=...&limit=50&offset=0List orders with their line items. Secret key.
POST/api/crm/ordersCreate 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=0List invoices. Creation and editing stay in the app (numbering + line totals). Secret key.
GET/api/crm/invoices/:idThe invoice with its lines. Secret key.

Bookings (sensitive, read-only)

GET/api/crm/bookings?from=...&to=...&status=confirmed&limit=50&offset=0Upcoming 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=0Course catalogue. Publishable or secret key.
POST/api/crm/coursesCreate a course: { title, description?, price?, duration_label?, is_active? }. Secret key.
GET/api/crm/courses/:id/enrollments?status=...&limit=50&offset=0A course's enrollments, with basic contact data. Publishable or secret key.
POST/api/crm/courses/:id/enrollmentsEnroll a contact (contact_id or email → upserted). Duplicate enrollment → 409. Secret key.
PATCH/api/crm/enrollments/:idUpdate { 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=0Issued certificates, each with a public verification_url. Secret key.
POST/api/crm/certificatesIssue a certificate for a completed enrollment: { enrollment_id }. Only once per enrollment. Secret key.

Platform events

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

Groups

GET/api/crm/groupsList groups. Secret key.
POST/api/crm/groupsCreate a group: { name, description?, source_type? }. Secret key.
GET/api/crm/groups/:idThe group plus its members with roles. Secret key.
DELETE/api/crm/groups/:idDelete the group. Secret key.
GET/api/crm/groups/:id/membersMembers with roles. Secret key.
POST/api/crm/groups/:id/membersAdd 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/hierarchyThe organisation's ordered hierarchy levels. Secret key.
POST/api/crm/hierarchyFULL replace of the levels: { levels: [{ label, can_see_below?, can_manage? }] }. Secret key.
POST/api/crm/hierarchy/levelsInsert one intermediate level at a position: { position, label, ... }. Secret key.

Invitations

GET/api/crm/invitations?status=...&limit=50List invitations. Secret key.
POST/api/crm/invitationsCreate an invitation (target_type: platform | product | group | event | general; send: true emails it immediately). Secret key.
GET/api/crm/invitations/:tokenTrack an open; with ?redirect=1 and a target_url it 302-redirects. Publishable or secret key.
PATCH/api/crm/invitations/:tokenMark as accepted (fires invitation.accepted). Called from your own site. Publishable or secret key.

Webhook subscriptions

GET/api/crm/webhooksList subscriptions. Secret key.
POST/api/crm/webhooksSubscribe: { url, events?: string[] }. The HMAC secret is returned once in the response. Secret key.
DELETE/api/crm/webhooks/:idUnsubscribe. Secret key.

Segments

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

Pastoral alerts

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

Field permissions

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

Backup & export

GET/api/crm/exportFull JSON snapshot of the tenant: { format, tables, config, generated_at }. 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 (or via POST /api/crm/webhooks) to any subset of the 18 bus 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 18 event types

contact.createdA contact was created (manual, Telegram, inbound email or public booking).
contact.updatedA contact was edited from the contact page.
invitation.acceptedAn invitation was accepted (public page or API).
invitation.expiredAn invitation passed its expiry date (daily cron).
alert.raisedA pastoral need was raised via the API.
deal.stage_changedA deal moved to another pipeline stage (UI or API).
deal.wonA deal reached a stage flagged as won.
deal.lostA deal reached a stage flagged as lost.
campaign.sentAn email campaign finished sending (manual or scheduled).
feedback.submittedA customer submitted a rating on the public feedback page.
approval.decidedAn approval request was decided (in-app or public page).
score.crossedA contact's lead score crossed a threshold, in either direction.
invoice.sentAn invoice was marked as sent.
invoice.paidAn invoice was marked as paid.
quote.acceptedA quote was accepted on its public page.
quote.declinedA quote was declined on its public page.
order.placedAn order was placed (manual, Shopify ingest or API).
product.interestA first interest signal for a contact + product pair.
course.completedAn enrollment was marked completed (UI or API).
form.submittedA public web form was submitted (contact created or enriched).

Ingest in

Third-party platforms send to CRM City

Create an ingest source in Settings → Ingest sources — each source gets a slug and a verify secret, and its own URL. 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/:sourceReceiver for Shopify, Stripe and custom platforms. Auth: ?key=<secret> query param or X-Ingest-Secret header; Shopify payloads are additionally HMAC-SHA256 verified via X-Shopify-Hmac-Sha256. Publishable or secret key.
POST/api/ingest/genericCatch-all for any custom code or automation tool. Auth: ?key=<secret> or X-Ingest-Secret header. No HMAC. Publishable or secret key.
# Generic ingest — the simple JSON shape any platform can send
curl "https://<your-crm-domain>/api/ingest/generic?key=<verify_secret>" \
  -X POST \
  -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)
  • 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 tenant
  • 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 tenant of the API key, 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_..." }
    }
  }
}

Tools (v1)

  • search_contacts / get_contact — find and read contacts (tags, lead score included)
  • upsert_contact — create or enrich by email, fill-blanks-only (same contract as the REST upsert)
  • log_activity — note/call/meeting/email on the timeline
  • add_tag — tag a contact (auto-creates the tag)
  • list_deals — open/won/lost deals with stage and contact
  • create_task — a task with a due date, optionally linked to a contact
  • list_products — the active catalog

Tools are a fixed whitelist mapped onto the same internals as the REST API — an agent can never do more than the key allows. No SSE stream in v1; responses are plain JSON.

These docs are also available inside the app under /docs when you are signed in — right next to Settings → API keys, where you create your first key.

Create your CRM and get an API key