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.
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:
GET /sync/<entity> returns records changed since a timestamp, including deletions (tombstones).POST /sync/<entity> upserts a batch of your ERP records, keyed on your own IDs.Supported entities: companies, contacts, deals, and manufacturers.
https://app.mycrm.io. Replace the host in every example below with your own.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:
| Field | Type | Notes |
|---|---|---|
| sync:read | scope | Allows GET (pull) on every sync endpoint. |
| sync:write | scope | Allows 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.
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.
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.
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).
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.
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.
Returns records changed at or after updated_since, ordered oldest-first, tombstones included.
| Field | Type | Notes |
|---|---|---|
| updated_since | string (ISO 8601) | Inclusive lower bound on updated_at. Omit to read from the beginning. |
| limit | integer | 1–500, default 100. |
| cursor | string | Opaque cursor from a previous response’s next_cursor. Takes precedence over updated_since. |
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.
Upserts a batch (max 500) of your ERP records. Each is matched on source_id.
{
"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.
{
"data": [
{ "source_id": "ERP-9001", "id": "8f3c…", "status": "created" },
{ "source_id": "ERP-9002", "id": "1ab2…", "status": "deleted" }
]
}| Field | Type | Notes |
|---|---|---|
| status: created | string | A new record was inserted. |
| status: updated | string | An existing record (same source_id) was updated. |
| status: deleted | string | An existing record was soft-deleted. |
| status: skipped | string | No write happened — see warnings (e.g. a deal that does not exist, since deals cannot be created here). |
| warnings | string[]? | Per-record notes, e.g. "not_found" or "unresolved_company:ERP-77". |
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.
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.
Required to create: name.
| Field | Type | Notes |
|---|---|---|
| name | string | Company name (required on create). |
| industry | string | |
| region | string | |
| website | string | |
| phone | string | |
| tags | string[] |
Required to create: type, first_name.
| Field | Type | Notes |
|---|---|---|
| type | enum | One of: contractor, end_client, lighting_designer, manufacturer, oem. |
| first_name | string | Required on create. |
| last_name | string | |
| string | ||
| phone | string | |
| region | string | |
| segment | string | |
| tags | string[] | |
| company | string | The company’s source_id. Resolved to the linked company; warns if not yet synced. |
Required to create: name.
| Field | Type | Notes |
|---|---|---|
| name | string | Required on create. |
| region | string | |
| product_categories | string[] | |
| oem_capable | boolean | |
| risk_rating | enum | One of: low, medium, high. |
| notes | string |
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.| Field | Type | Notes |
|---|---|---|
| description | string | ERP-owned free text. |
| tags | string[] |
On pull, deals also return read-only pipeline fields: title, stage_id, owner_id, value, currency, expected_close, company_id, and contact_id.
Frappe-style document CRUD for ERP integrations. Endpoints mirror /api/resource/{DocType} from ERPNext. DocTypes: Lead, Customer, Opportunity, Quotation.
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.
| Field | Type | Notes |
|---|---|---|
| GET /api/resource/{DocType} | List records | Query: fields, filters, limit_start, limit_page_length, order_by |
| GET /api/resource/{DocType}/{name} | Read one | name = ERP document id (stored as source_id) |
| POST /api/resource/{DocType} | Create | Lead, Customer, Quotation only — not Opportunity |
| PUT /api/resource/{DocType}/{name} | Update | Opportunity: ERP-owned fields only (description, tags) |
| DELETE /api/resource/{DocType}/{name} | Delete | Soft-delete Lead/Customer; archive Quotation. Opportunity: 403 |
name field is stored as source_id and returned as name in responses.display_name values (e.g. CRM-LEAD-2026-0001).lead_owner) resolve via SSO email within your org.opportunity link auto-create a stub deal on the Sales pipeline.Errors return a JSON body of the shape { "error": { "code", "detail?" } }.
| Field | Type | Notes |
|---|---|---|
| 400 validation_error | Bad request | Body or query failed validation; detail carries the field errors. |
| 400 invalid_json | Bad request | The request body was not valid JSON. |
| 401 unauthenticated | Unauthorized | Missing, malformed, or revoked API key. |
| 403 forbidden | Forbidden | The key lacks the required scope (sync:read / sync:write). |
| 404 not_found | Not found | Used by key-management endpoints when the target key does not exist. |
| 500 internal_error | Server error | Unexpected failure — safe to retry with backoff. |
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.