API documentation

Connect any platform to CRM City.

Your first connection in 5 steps

  1. Create an API key in Settings → API Keys.
  2. Copy the secret key (crm_sec_…). It is shown only once.
  3. Store it in your platform's environment variables (NOT hardcoded in your code).
  4. Test it with GET /api/crm/me.
  5. Start syncing contacts with POST /api/crm/contacts.

API keys

Public key (crm_pub_…)

Limited, not harmless. Used for: smoke tests, reading the product and course catalogues, and opening or accepting an invitation you hold the link token for, which returns that invitee's name and email. Companies and course enrolments name people, so they need the secret key. It cannot write contacts or create invitations. Anyone who sees the key can do the same.

Secret key (crm_sec_…)

Server-only. Used for: CRUD on contacts, activities, invitations, groups, tags, and every read that names a person: contacts, companies, course enrolments, orders, invoices, bookings and certificates. NEVER expose it in client-side JS.

Send the key in a 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_…

Endpoints

Identity & smoke test

GET/api/crm/me— Confirms the key works. Accepts pub or sec.

Contacts

GET/api/crm/contacts?email=...&q=...&limit=50&order=id&cursor=...— List / search. Most recently changed first by default. To go through every contact, pass order=id and call again with cursor set to next_cursor until it is null. Secret.
POST/api/crm/contacts— Upsert contact (dedupe by email, fill-blanks-only). Secret.
GET/api/crm/contacts/:id— A single contact. Secret.

Activities

POST/api/crm/contacts/:id/activities— Logs an activity on the timeline. Secret.

Invitations

GET/api/crm/invitations?status=...&limit=50— List invitations. Secret.
POST/api/crm/invitations— Create; returns invite_url. send: true asks for the email — refused today, until your own email account can be connected (send_error says why). Secret.
GET/api/crm/invitations/:token— Track open + (opt) redirect; returns the invitee's name and email. Pub OK — the token is what grants it.
PATCH/api/crm/invitations/:token— Mark as accepted. Pub OK.

Groups

GET/api/crm/groups— List groups. Secret.
POST/api/crm/groups— Create group (with source_type). Secret.
GET/api/crm/groups/:id— Group + members with roles. Secret.
DELETE/api/crm/groups/:id— Delete group. Secret.
GET/api/crm/groups/:id/members— Members with roles. Secret.
POST/api/crm/groups/:id/members— Add member with role. Secret.
DELETE/api/crm/groups/:id/members?contact_id=...— Remove member. Secret.

Tags & Segments

GET/api/crm/contacts/:id/tags— The contact's tags. Secret.
POST/api/crm/contacts/:id/tags— Add tag (auto-created if missing). Secret.
DELETE/api/crm/contacts/:id/tags?tag=name— Remove tag. Secret.
GET/api/crm/segments/:slug/contacts— Resolve a segment → list of contacts. Secret.

Pastoral alerts

GET/api/crm/alerts?open=1— Needs (filter by open). Secret.
POST/api/crm/alerts— Raise a pastoral need. Secret.
PATCH/api/crm/alerts/:id— Mark as resolved. Secret.

Hierarchy

GET/api/crm/hierarchy— List of the organisation's levels. Secret.
POST/api/crm/hierarchy— FULL replace of the levels. Secret.
POST/api/crm/hierarchy/levels— Insert an intermediate level. Secret.
PATCH/api/crm/contacts/:id/hierarchy— Assign contact to a level. Secret.

Webhooks OUT — CRM notifies your platform

GET/api/crm/webhooks— List subscriptions. Secret.
POST/api/crm/webhooks— Subscribe to events. Secret shown only once.
DELETE/api/crm/webhooks/:id— Unsubscribe. Secret.

Webhooks IN — third-party platforms send to CRM

POST/api/ingest/:source— Receiver for Shopify, Stripe etc. with HMAC verify. Header X-Ingest-Secret.
POST/api/ingest/generic— Catch-all for custom sources. Header X-Ingest-Secret.

Deals

GET/api/crm/deals— List deals. Secret.
POST/api/crm/deals— Create deal. Secret.
PATCH/api/crm/deals/:id/stage— Move stage (won/lost auto). Secret.

Events

POST/api/crm/events— Platform event → upsert contact + activity. Secret.

Field permissions

GET/api/crm/field-permissions— Policies per level. Secret.
PATCH/api/crm/field-permissions— Set a field_group's visibility for a level. Secret.

Backup & Export

GET/api/crm/export— JSON snapshot of your organisation's records: the same tables and exclusions as Settings → Backup & Export. Secret key only.

Companies

GET/api/crm/companies?q=...&limit=50&offset=0— List / search by name, with notes and addresses. Secret.
POST/api/crm/companies— Create company (name required, type optional). Secret.

Products

GET/api/crm/products?active=true&q=...— Product catalog (active filter, name/sku search). Pub OK.
POST/api/crm/products— Create product (sku unique per organisation). Secret.
GET/api/crm/products/:id— A single product. Pub OK.
PATCH/api/crm/products/:id— Update any subset of fields. Secret.
DELETE/api/crm/products/:id— Delete; if it has order lines → deactivate only. Secret.

Orders (e-commerce)

GET/api/crm/orders?contact_id=...&status=...— List orders with lines. Secret.
POST/api/crm/orders— Create order: contact_id or email (upsert), items by product_id/sku/product_name, total computed. Emits order.placed. Secret.

Courses & certificates

GET/api/crm/courses?active=true&q=...— Course catalog. Pub OK.
POST/api/crm/courses— Create course (title required). Secret.
GET/api/crm/courses/:id/enrollments?status=...— A course's enrollments, with each person's name and email. Secret.
POST/api/crm/courses/:id/enrollments— Enroll contact (contact_id or email → upsert). Duplicate → 409. Secret.
PATCH/api/crm/enrollments/:id— Status/progress; on completed → completed_at + course.completed event. Secret.
GET/api/crm/certificates?contact_id=...— Issued certificates, with public verification_url. Secret.
POST/api/crm/certificates— Issue a certificate for a completed enrollment (only once). Secret.

Invoices (read-only V1)

GET/api/crm/invoices?status=...&contact_id=...— List invoices. Creation stays in the app. Secret.
GET/api/crm/invoices/:id— The invoice with its lines. Secret.

Bookings (read-only V1)

GET/api/crm/bookings?from=...&to=...&status=confirmed— Upcoming bookings (default: from now). Booking happens on the public page /book/:slug. Secret.

Rate limiting

Per key, default tier free:

  • 300 reads/min (GETs)
  • 60 writes/min (POST/PATCH/DELETE)
  • Exceeding it → HTTP 429 with header Retry-After: <seconds>

Tiers pro: 1500/300. enterprise: configurable. Per-key tier changes are made directly in the DB for now; your account plan (and its limits) is managed in Settings → Billing.

Webhook signature (verification at destination)

# How to verify on your server that the request comes from CRM:
# Header: X-CRM-Signature: sha256=<hex>
# Secret: the one returned by POST /api/crm/webhooks (whs_...)

import hmac, hashlib

def verify(body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

Predefined segments

/api/crm/segments/:slug/contacts accepts 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

curl examples

Examples use crmcity.app, the domain CRM City is served from — copy one and it runs as written. If your CRM is served from your own domain, swap the host: all endpoints live under https://<your-domain>/api/crm.

# Smoke test
curl https://crmcity.app/api/crm/me \
  -H "X-CRM-API-Key: crm_sec_xxx..."

# Upsert contact (dedupe by email)
curl https://crmcity.app/api/crm/contacts \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"email":"ion@example.com","first_name":"Ion","phone":"+40712345678"}'

# Create an invitation — share its invite_url yourself; the email part is refused
# until your own email account can be connected
curl https://crmcity.app/api/crm/invitations \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "invitee_email":"vasile@example.com",
    "invitee_name":"Vasile",
    "target_type":"general",
    "target_label":"Sunday meeting",
    "message":"Come at 10:00 to the church.",
    "send":false
  }'

# Create company
curl https://crmcity.app/api/crm/companies \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Firma SRL","type":"client","city":"Cluj-Napoca"}'

# Create product
curl https://crmcity.app/api/crm/products \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Abonament Pro","sku":"PRO-1","price":49.99,"currency":"GBP"}'

# Place order (contact by email — created if missing; product by sku)
curl https://crmcity.app/api/crm/orders \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "email":"ion@example.com",
    "first_name":"Ion",
    "items":[{"sku":"PRO-1","quantity":2}]
  }'

# Enroll contact in a course (by email)
curl https://crmcity.app/api/crm/courses/COURSE_ID/enrollments \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"email":"ion@example.com","first_name":"Ion"}'

# Mark completion (the key flow for external course platforms)
curl https://crmcity.app/api/crm/enrollments/ENROLLMENT_ID \
  -X PATCH \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"status":"completed"}'

# Issue the certificate (response contains a public verification_url)
curl https://crmcity.app/api/crm/certificates \
  -X POST \
  -H "X-CRM-API-Key: crm_sec_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"enrollment_id":"ENROLLMENT_ID"}'

# Paid invoices (read-only)
curl "https://crmcity.app/api/crm/invoices?status=paid" \
  -H "X-CRM-API-Key: crm_sec_xxx..."

# Bookings for the next 7 days (read-only)
curl "https://crmcity.app/api/crm/bookings?from=2026-07-02T00:00:00Z&to=2026-07-09T00:00:00Z" \
  -H "X-CRM-API-Key: crm_sec_xxx..."

Standard errors

All errors have the form:

{ "error": "code_string", "message": "Human-readable.", "field": "optional" }
  • 401 auth_error — key missing or invalid
  • 403 key_level_error — requires a secret key
  • 403 organization_deleted — the key's organisation has been deleted; it works again if the organisation is restored within 30 days
  • 400 validation_error — invalid field (see field)
  • 404 not_found — resource does not exist in the key's organisation
  • 409 conflict — duplicate (e.g. existing enrollment, duplicated sku, certificate already issued)
  • 429 rate_limit_exceeded — too many requests (see Rate limiting)
  • 500 db_error — internal error