myCRM Sync API · v1

API reference

A REST API for keeping myCRM and your ERP in sync — both directions. Your system pulls what changed in the CRM on an incremental feed and pushes its own records back as idempotent upserts. It pairs cleanly with ERPNext or anything that can call a REST endpoint.

Overview

All endpoints live under your workspace’s API base, versioned under /api/v1/crm. Sync is pull-based: your ERP’s scheduler drives it. Two operations per entity:

  • PullGET /sync/<entity> returns records changed since a timestamp, including deletions (tombstones).
  • PushPOST /sync/<entity> upserts a batch of your ERP records, keyed on your own IDs.

Supported entities: companies, contacts, deals, and manufacturers.

The base URL is your myCRM deployment, e.g. https://app.mycrm.io. Replace the host in every example below with your own.

Authentication

Every request authenticates with a per-organisation API key. An admin generates one in Settings → API Keys; the secret is shown once at creation, so copy it then. Send it as a bearer token:

Authorization: Bearer crmk_<prefix>_<secret>

A key is scoped to a single organisation — you never pass an org id in requests. Keys carry scopes that gate what they can do:

FieldTypeNotes
sync:readscopeAllows GET (pull) on every sync endpoint.
sync:writescopeAllows POST (upsert) on every sync endpoint.

Revoke a key any time from the same screen — revoked keys immediately return 401. Rotate by generating a new key, updating your ERP, then revoking the old one.

Treat the token like a password. It grants full read/write to your organisation’s synced data. Store it in your ERP’s secret manager, never in source control.

Conventions

Identity & idempotency

Every record is keyed on your ERP’s own id, sent as source_id. myCRM stores it alongside source_system: "erpnext". Re-sending the same source_id updates the existing record in place — upserts are safe to retry and safe to replay.

Ordering

Sync companies before contacts and deals. Relationships are resolved by source_id; if a referenced company hasn’t been synced yet, the link is left empty and a warning is returned.

Pagination

The pull feed is keyset-paginated. The first call uses updated_since; each response returns a next_cursor. Keep calling with that cursor until it comes back null. limit defaults to 100 (max 500).

Tombstones

Deletions are never hidden. A pulled record with a non-null deleted_at means it was deleted in myCRM — your ERP should delete or deactivate its copy.

Field ownership

Each side owns its own fields. Your ERP writes master data (names, contact details, classifications); myCRM owns pipeline data (stage, owner, value). On upsert, any field outside an entity’s owned list is ignored — you cannot overwrite CRM-owned pipeline fields, and the CRM never overwrites the fields you own. See Entities & fields.

Pull changes

GET/api/v1/crm/sync/{entity}

Returns records changed at or after updated_since, ordered oldest-first, tombstones included.

Query parameters

FieldTypeNotes
updated_sincestring (ISO 8601)Inclusive lower bound on updated_at. Omit to read from the beginning.
limitinteger1–500, default 100.
cursorstringOpaque cursor from a previous response’s next_cursor. Takes precedence over updated_since.

Example

curl -H "Authorization: Bearer crmk_…" \
  "https://app.mycrm.io/api/v1/crm/sync/companies?updated_since=2026-06-01T00:00:00Z&limit=100"
{
  "data": [
    {
      "id": "8f3c…",
      "source_id": "ERP-9001",
      "name": "Acme Industries",
      "industry": "Manufacturing",
      "region": "EU",
      "deleted_at": null,
      "updated_at": "2026-06-12T09:30:00Z"
    },
    {
      "id": "1ab2…",
      "source_id": "ERP-9002",
      "name": "Old Vendor Co",
      "deleted_at": "2026-06-14T17:05:00Z",   // tombstone → delete your copy
      "updated_at": "2026-06-14T17:05:00Z"
    }
  ],
  "next_cursor": "eyJ1cGRhdGVkX2F0Ijoi…"
}

Fetch the next page by passing cursor=eyJ1…. When next_cursor is null, you’re caught up.

Push changes

POST/api/v1/crm/sync/{entity}

Upserts a batch (max 500) of your ERP records. Each is matched on source_id.

Request body

{
  "records": [
    { "source_id": "ERP-9001", "name": "Acme Industries", "industry": "Manufacturing" },
    { "source_id": "ERP-9002", "deleted": true }
  ]
}

Set deleted: true to soft-delete a record — it’s the only field required alongside source_id for a delete.

Response

{
  "data": [
    { "source_id": "ERP-9001", "id": "8f3c…", "status": "created" },
    { "source_id": "ERP-9002", "id": "1ab2…", "status": "deleted" }
  ]
}
FieldTypeNotes
status: createdstringA new record was inserted.
status: updatedstringAn existing record (same source_id) was updated.
status: deletedstringAn existing record was soft-deleted.
status: skippedstringNo write happened — see warnings (e.g. a deal that does not exist, since deals cannot be created here).
warningsstring[]?Per-record notes, e.g. "not_found" or "unresolved_company:ERP-77".
Non-delete upserts should carry the full set of fields you own for that record — an omitted owned field is written as empty, not “left unchanged”.

A complete sync loop

A scheduled job on the ERP side, per entity, in order (companies → contacts → deals → manufacturers):

# 1. PULL — read everything changed since your stored watermark
cursor = null
loop:
  res = GET /sync/companies?updated_since=<watermark>&cursor=<cursor>
  for row in res.data:
      if row.deleted_at: deactivate ERP copy of row.source_id
      else:              upsert ERP copy from row
  cursor = res.next_cursor
  if cursor is null: break
# advance watermark to the max updated_at you saw (it is inclusive — dedupe is harmless)

# 2. PUSH — send your changed records back (idempotent)
POST /sync/companies { "records": [ …changed ERP rows… ] }

Persist a per-entity updated_since watermark. Because pulls are inclusive and pushes are idempotent, overlap on either side is harmless — you’ll never double-create.

Entities & fields

Fields below are the ones you own — writable on upsert and returned on pull. Pulled records also include id, source_id, deleted_at, created_at, and updated_at.

companies create + update

Required to create: name.

FieldTypeNotes
namestringCompany name (required on create).
industrystring
regionstring
websitestring
phonestring
tagsstring[]

contacts create + update

Required to create: type, first_name.

FieldTypeNotes
typeenumOne of: contractor, end_client, lighting_designer, manufacturer, oem.
first_namestringRequired on create.
last_namestring
emailstring
phonestring
regionstring
segmentstring
tagsstring[]
companystringThe company’s source_id. Resolved to the linked company; warns if not yet synced.

manufacturers create + update

Required to create: name.

FieldTypeNotes
namestringRequired on create.
regionstring
product_categoriesstring[]
oem_capableboolean
risk_ratingenumOne of: low, medium, high.
notesstring

deals update only

myCRM owns the pipeline, so deals cannot be created via the API. An upsert for a source_id that doesn’t already exist returns status: skipped with warnings: ["not_found"]. You can update the fields you own on deals that are already linked.
FieldTypeNotes
descriptionstringERP-owned free text.
tagsstring[]

On pull, deals also return read-only pipeline fields: title, stage_id, owner_id, value, currency, expected_close, company_id, and contact_id.

Resource API (ERP partners)

Frappe-style document CRUD for ERP integrations. Endpoints mirror /api/resource/{DocType} from ERPNext. DocTypes: Lead, Customer, Opportunity, Quotation.

Authentication

Either format works with the same org API key:

Authorization: Bearer crmk_<prefix>_<secret>
# or Frappe-style (prefix:secret from key creation screen):
Authorization: token <prefix>:<secret>

Requires resource:read / resource:write scopes.

Endpoints

FieldTypeNotes
GET /api/resource/{DocType}List recordsQuery: fields, filters, limit_start, limit_page_length, order_by
GET /api/resource/{DocType}/{name}Read onename = ERP document id (stored as source_id)
POST /api/resource/{DocType}CreateLead, Customer, Quotation only — not Opportunity
PUT /api/resource/{DocType}/{name}UpdateOpportunity: ERP-owned fields only (description, tags)
DELETE /api/resource/{DocType}/{name}DeleteSoft-delete Lead/Customer; archive Quotation. Opportunity: 403

Identity

  • ERP's name field is stored as source_id and returned as name in responses.
  • CRM generates internal display_name values (e.g. CRM-LEAD-2026-0001).
  • Owner fields (lead_owner) resolve via SSO email within your org.
  • Quotations without an opportunity link auto-create a stub deal on the Sales pipeline.
Configure integration defaults under Settings → Integrations (pipeline, naming prefixes, auto-create deals).

Errors

Errors return a JSON body of the shape { "error": { "code", "detail?" } }.

FieldTypeNotes
400 validation_errorBad requestBody or query failed validation; detail carries the field errors.
400 invalid_jsonBad requestThe request body was not valid JSON.
401 unauthenticatedUnauthorizedMissing, malformed, or revoked API key.
403 forbiddenForbiddenThe key lacks the required scope (sync:read / sync:write).
404 not_foundNot foundUsed by key-management endpoints when the target key does not exist.
500 internal_errorServer errorUnexpected failure — safe to retry with backoff.

Versioning

The API is versioned in the path (/api/v1/). Additive changes — new fields, new entities — won’t break existing integrations, so read defensively and ignore fields you don’t recognise. Breaking changes ship under a new version prefix.

Need an entity or field that isn’t here yet? It’s a small addition — ask your myCRM administrator to request it.