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
- 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.
- Copy the secret key (
crm_sec_…) and store it in your platform's environment variables — never hardcode it or ship it to a browser. - Authenticate every request with the header
X-CRM-API-Key: crm_sec_… - The base URL is your CRM's domain:
https://<your-crm-domain>/api/crm/... - Smoke-test the key with
GET /api/crm/me, then start syncing contacts withPOST /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.
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
/api/crm/me— Smoke test: confirms the key and returns tenant + key level. Publishable or secret key.Contacts
/api/crm/contacts?email=...&q=...&limit=50— List / search contacts. Secret key./api/crm/contacts— Upsert by email — fill-blanks-only (see the flagship example below). Secret key./api/crm/contacts/:id— A single contact. Secret key./api/crm/contacts/:id/activities— Log an activity on the contact's timeline: { type, subject?, description?, occurred_at? }. Secret key./api/crm/contacts/:id/tags— The contact's tags. Secret key./api/crm/contacts/:id/tags— Add a tag: { name, color? } — created automatically if missing. Secret key./api/crm/contacts/:id/tags?tag=name— Remove a tag (by ?tag=name or ?tag_id=uuid). Secret key./api/crm/contacts/:id/hierarchy— Assign the contact to a hierarchy level: { hierarchy_level_id } (null clears it). Secret key.Companies
/api/crm/companies?q=...&limit=50&offset=0— List / search companies by name. Publishable or secret key./api/crm/companies— Create a company: { name, website?, industry?, address?, city?, country?, type?, notes? }. Secret key.Deals
/api/crm/deals?stage_id=...&pipeline_id=...&limit=50— List deals. Secret key./api/crm/deals— Create a deal: { title, value?, currency?, pipeline_id?, stage_id?, contact_id?, ... }. Secret key./api/crm/deals/:id/stage— Move stage: { stage_id }. Won/lost detected from the target stage; fires deal events. Secret key.Products
/api/crm/products?active=true&q=...&limit=50&offset=0— Product catalogue (active filter, name/SKU search). Publishable or secret key./api/crm/products— Create a product: { name, sku?, description?, price?, currency?, category?, url?, is_active? }. Secret key./api/crm/products/:id— A single product. Publishable or secret key./api/crm/products/:id— Update any subset of product fields. Secret key./api/crm/products/:id— Delete; products with order lines are deactivated instead (history stays true). Secret key.Orders (sensitive)
/api/crm/orders?contact_id=...&status=...&limit=50&offset=0— List orders with their line items. Secret key./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)
/api/crm/invoices?status=...&contact_id=...&limit=50&offset=0— List invoices. Creation and editing stay in the app (numbering + line totals). Secret key./api/crm/invoices/:id— The invoice with its lines. Secret key.Bookings (sensitive, read-only)
/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
/api/crm/courses?active=true&q=...&limit=50&offset=0— Course catalogue. Publishable or secret key./api/crm/courses— Create a course: { title, description?, price?, duration_label?, is_active? }. Secret key./api/crm/courses/:id/enrollments?status=...&limit=50&offset=0— A course's enrollments, with basic contact data. Publishable or secret key./api/crm/courses/:id/enrollments— Enroll a contact (contact_id or email → upserted). Duplicate enrollment → 409. Secret key./api/crm/enrollments/:id— Update { status?, progress?, notes? }; status “completed” sets completed_at and fires course.completed. Secret key.Certificates (sensitive)
/api/crm/certificates?contact_id=...&course_id=...&limit=50&offset=0— Issued certificates, each with a public verification_url. Secret key./api/crm/certificates— Issue a certificate for a completed enrollment: { enrollment_id }. Only once per enrollment. Secret key.Platform events
/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
/api/crm/groups— List groups. Secret key./api/crm/groups— Create a group: { name, description?, source_type? }. Secret key./api/crm/groups/:id— The group plus its members with roles. Secret key./api/crm/groups/:id— Delete the group. Secret key./api/crm/groups/:id/members— Members with roles. Secret key./api/crm/groups/:id/members— Add a member: { contact_id, role? } (default role: member). Secret key./api/crm/groups/:id/members?contact_id=...— Remove a member. Secret key.Hierarchy
/api/crm/hierarchy— The organisation's ordered hierarchy levels. Secret key./api/crm/hierarchy— FULL replace of the levels: { levels: [{ label, can_see_below?, can_manage? }] }. Secret key./api/crm/hierarchy/levels— Insert one intermediate level at a position: { position, label, ... }. Secret key.Invitations
/api/crm/invitations?status=...&limit=50— List invitations. Secret key./api/crm/invitations— Create an invitation (target_type: platform | product | group | event | general; send: true emails it immediately). Secret key./api/crm/invitations/:token— Track an open; with ?redirect=1 and a target_url it 302-redirects. Publishable or secret key./api/crm/invitations/:token— Mark as accepted (fires invitation.accepted). Called from your own site. Publishable or secret key.Webhook subscriptions
/api/crm/webhooks— List subscriptions. Secret key./api/crm/webhooks— Subscribe: { url, events?: string[] }. The HMAC secret is returned once in the response. Secret key./api/crm/webhooks/:id— Unsubscribe. Secret key.Segments
/api/crm/segments/:slug/contacts— Resolve a segment slug to a list of contacts. Secret key.Pastoral alerts
/api/crm/alerts?open=1— List needs (filter by open). Secret key./api/crm/alerts— Raise a need: { contact_id, description, urgency? }. Fires alert.raised. Secret key./api/crm/alerts/:id— Mark as resolved. Secret key.Field permissions
/api/crm/field-permissions— Field-group visibility policies, grouped per hierarchy level. Secret key./api/crm/field-permissions— Set one policy: { hierarchy_level_id, field_group, visible }. Secret key.Backup & export
/api/crm/export— Full JSON snapshot of the tenant: { format, tables, config, generated_at }. Secret key./api/crm/segments/:slug/contacts accepts these standard slugs:
all— all active contactstag:<name>— contacts with the given tagleaders— contacts with the "leader" role in any grouprole:<role>— contacts with a specific role in any groupgroup:<id>— members of a groupinactive-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" }'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.placedUser-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.created— A contact was created (manual, Telegram, inbound email or public booking).contact.updated— A contact was edited from the contact page.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).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
/api/ingest/:source— Receiver 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./api/ingest/generic— Catch-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"
}'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 (seefield) - 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 timelineadd_tag— tag a contact (auto-creates the tag)list_deals— open/won/lost deals with stage and contactcreate_task— a task with a due date, optionally linked to a contactlist_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