Base URL and authentication
Every call goes to https://app.zyflowcrm.com/api/v1, sends and receives JSON, and carries an API key in the Authorization header as a Bearer token. A key belongs to one workspace. The REST API takes API keys only; OAuth sign-in is for AI apps on the MCP server.
If one login runs several businesses (up to 10 workspaces per login), create a key in each workspace.
curl "https://app.zyflowcrm.com/api/v1/contacts?limit=5" \
-H "Authorization: Bearer $ZYFLOW_API_KEY" Without a valid key, you get 401:
{"error":true,"code":"unauthenticated","message":"Sign in, or send your API key as \"Authorization: Bearer zf_live_...\".","status":401} The same key also works as a Bearer token on the MCP server, for AI apps that can’t sign in. See Connect any MCP app.
Create and manage API keys
Create Zyflow API keys on the Connect page. Choose New key, name it after where it’s used, like “Website form”, and pick Read and write or Read only. The key starts with zf_live_ and is shown once. Changes made with it appear in History under that name, and you can revoke it instantly.
Names can be up to 60 characters. Copy the key when it appears: Zyflow keeps only a hash, so it can’t be shown again. The panel lists each key by name, first characters and last use, and revoking is instant: “Anything using this key stops working immediately.”
Active keys: Free 3, Solo 10, Team 50, Pro 200. At the limit, a new key answers 402 api_key_limit. Revoke one you no longer use.
| Access | Read, list and search | Export | Create, edit, delete or undo |
|---|---|---|---|
| Read and write | Yes | Yes | Yes |
| Read only | Yes | Yes | No. Refused before anything runs, with 403 insufficient_scope |
Before you hand out a key
- Keys don’t expire. Revoke any key you stop using.
- Any member can create a key, and can list or revoke anyone’s.
- A key acts as an agent named after it, with its creator’s role. Every member, and every member’s AI, can see and edit every customer. There are no per-person permissions yet.
- If its creator leaves the workspace, the key stops working (403 not_a_member). It isn’t revoked, though, and would work again if they rejoined. Revoke it yourself.
- Creating and revoking keys show in History but can’t be undone.
- Keys reach /api/v1 only. Account, team, billing and key routes answer 403 api_key_not_allowed.
You manage keys on the Connect page.
Keep your key on a server
Don’t call the Zyflow API from browser code. Cross-origin requests are accepted only from Zyflow’s own sites, and any key, even a read-only one, can export your records, documents and history. Keep the key in a server or serverless function, and have your page post to that.
- Never put a key in browser code, a mobile app or a public repo.
- Store it in an environment variable or your host’s secret store.
- Use one key per integration, so you can revoke one without breaking the others.
The OpenAPI reference
The full reference is generated from the code: Swagger UI at https://app.zyflowcrm.com/api/docs and the OpenAPI document at https://app.zyflowcrm.com/api/docs/json (“Zyflow API” v1.0.0). This page covers keys, conventions and examples. The reference lists every /api/v1 route, field and response.
Only /api/v1 paths accept keys. Routes are grouped by contacts, companies, deals, memories, activities, tasks, files, documents, business, insights, history, settings and data.
Don’t mix up the two addresses: this guide is /docs/api on the Zyflow site, and the generated reference is /api/docs on the app.
Conventions
Every route follows the same rules: typed ids, one change per write request, version checks, cursor paging, and saves that find the record you already have.
Typed ids
A type prefix plus 12 characters, with no look-alike symbols: ct_ contact, co_ company, dl_ deal, ac_ activity, mem_ memory, tk_ task, doc_ document, tpl_ template, fl_ file, dir_ folder, bat_ change batch.
One request, one change
Each write request runs in one transaction, so it saves in full or not at all, and returns a batchId (MCP calls it change_id). Uploads and document writes use their own short transactions around the file or the PDF render.
Versions
Records carry a version. PATCH on contacts, companies, deals and documents accepts expectedVersion; if the record changed since you read it, you get 409 version_conflict with the current record in details.current. Tasks have no version.
Paging and search
Lists return items, nextCursor and total. limit defaults to 25, up to 200; send nextCursor back as cursor. deleted=true lists the trash. Search returns up to 50 results.
Saving without duplicates
A plain POST /api/v1/contacts always creates a contact. With ?upsert=true, Zyflow finds the record you already have (by email, then phone, then exact name) and answers action: created, updated, unchanged or ambiguous. ?dryRun=true previews without writing; ?createNew=true forces a new record. Companies work the same way.
Names as people say them
customer: “Maya at Northwind” works where an id would, and GET /api/v1/resolve?q= looks a name up. An unclear name returns 409 ambiguous_reference with details.candidates, and your code picks the id.
Forgiving writes
A bad value, such as an invalid phone, is skipped with a line in warnings, and the rest saves.
Dates
Send YYYY-MM-DD. Numeric dates like 03/10 follow the workspace’s country. Phrases like “tomorrow” work in English, in the workspace’s time zone.
Errors
Every error has the same JSON shape: error, code, message, status and, sometimes, details. Branch your code on code, and show message to a person: each one says what went wrong and what to do next.
{"error":true,"code":"insufficient_scope","message":"This connection is read-only. Reconnect with write access, or use an API key that has crm:write.","status":403} | Status | code | What to do |
|---|---|---|
| 400 | validation_failed | Fix the field named in message. details lists up to 5 problems |
| 401 | unauthenticated | Send Authorization: Bearer zf_live_… |
| 402 | record_limit, api_key_limit, history_limit | A plan limit: free up records, revoke an unused key, or the change is older than the history window |
| 403 | insufficient_scope | The key is Read only. Use a Read and write key |
| 403 | insufficient_role | The route needs an owner or admin, and the key’s creator isn’t one |
| 403 | api_key_not_allowed | Keys work on /api/v1 only |
| 403 | not_a_member | The key’s creator left the workspace. Create a new key |
| 404 | not_found | Check the id and that the key belongs to the right workspace |
| 404 | documents_off | Documents are switched off in this workspace |
| 409 | version_conflict | Read the record again (it’s in details.current) and retry |
| 409 | ambiguous_reference | Pick an id from details.candidates |
| 409 | already_undone | Nothing to do. That change was already undone |
| 413 | payload_too_large | Send a smaller body or file |
| 429 | rate_limited | Wait the seconds in Retry-After, then retry |
Cost and rate limits
REST API calls are never billed, and there are no AI or API credits.2 Short burst limits per key stop runaway scripts. Over the limit, you get 429 with a Retry-After header.
History and undo
Every write returns a batchId. To reverse it, POST to /api/v1/changes/{batchId}/undo. Everything that request changed goes back in one step, fields edited again since are kept, and the undo is itself a change you can undo.1
curl -X POST "https://app.zyflowcrm.com/api/v1/changes/bat_…/undo" \
-H "Authorization: Bearer $ZYFLOW_API_KEY" Response:
{"batchId":"bat_…","undone":[…],"skipped":[]} The answer lists undone and skipped items (each skip with a reason) and its own batchId. You get 404 for a batch that isn’t in this workspace, 409 already_undone, or 402 history_limit when the change is older than your plan’s history window.
Find changes
GET /api/v1/changes returns batches (each with undoable and undoneAt) and hasMore. Filter by entityId, entityType, via (app, mcp, api, import or system), clientName (a key’s name) and since (an ISO date and time). It pages with limit, up to 100, and offset, not a cursor.
In the app, your code’s changes show in History under the API tab with the key’s name, and in “What your AI did” as “via API”.
Undo can’t reverse settings changes, creating or revoking keys, files deleted for good or an emptied trash, or deleting a workspace.
Documents: invoices, quotes and letters
POST /api/v1/documents makes an invoice, quote or letter from a template. Zyflow numbers it when it’s issued, works out totals and tax, renders the PDF and files it. To record a payment, post an amount to /documents/{id}/status. Zyflow doesn’t send documents to customers or collect payments.
- POST /api/v1/documents: kind (invoice, quote or document), contactId or companyId, items, currency, dueInDays.
- POST /api/v1/documents/preview: the document as it would print. Nothing is saved.
- GET /api/v1/documents/{id}: an id or a number, such as “INV-0007”.
- PATCH /api/v1/documents/{id}: replaces the lines as a whole and remakes the PDF.
- POST /api/v1/documents/{id}/status: issue, mark paid, accepted or declined, void or reopen. Add amount to record a payment.
- POST /api/v1/documents/{id}/convert turns a quote into an invoice; /duplicate copies it into a new draft (REST only); /render remakes the PDF. GET /api/v1/documents/{id}/pdf redirects to the PDF.
- Templates (Liquid HTML): /api/v1/document-templates.
Issued invoices and quotes are voided, not deleted. With documents switched off, these routes answer 404 documents_off.
Zyflow works out totals, tax and numbering. Unit prices come from what you or your AI enter; there’s no price list. Zyflow records payments; it doesn’t collect them, and there are no card payment links. Zyflow sends nothing to your customers.
Export your data from code
GET /api/v1/export/{type} returns contacts, companies, deals, tasks, activities or memories as CSV, or workspace.json with your settings, records, documents, change history (up to 100,000 changes) and links to every file. File contents download separately. Any key can export, including a read-only one.3
Types: contacts.csv, companies.csv, deals.csv, tasks.csv, activities.csv, memories.csv and workspace.json. Zyflow runs no scheduled backups, so schedule your own with a Read only key named “Nightly export”.
curl "https://app.zyflowcrm.com/api/v1/export/workspace.json" \
-H "Authorization: Bearer $ZYFLOW_API_KEY" \
-o "zyflow-$(date +%F).json" What the REST API doesn’t do
Zyflow has no webhooks, event subscriptions or official SDKs. To pick up changes, poll GET /api/v1/changes with since, or list records with updatedAfter. REST has no idempotency keys, so save contacts with ?upsert=true to land on the record you already have.
A poll every few minutes (store the time of your last check):
curl "https://app.zyflowcrm.com/api/v1/changes?since=2026-10-03T07:00:00Z&limit=100" \
-H "Authorization: Bearer $ZYFLOW_API_KEY" There are no native Zapier, Make or n8n apps. A tool with a generic HTTP step can call the API from its own servers, with the key stored in that tool’s credentials.
How the REST API differs from the MCP server
Both reach the same records. AI apps use the MCP server: they sign in with OAuth and call tools shaped around tasks. Your code uses the REST API with an API key and routes shaped around records. A few behaviors differ:
| Topic | REST API | MCP server |
|---|---|---|
| Sign-in | API key | OAuth sign-in, or an API key |
| Shape | Routes around records | Tools around tasks |
| Retry keys | None | idempotency_key, kept 24 hours |
| Repeat guards (same message within 15 minutes, matching open follow-up) | Yes | Yes |
| Bulk changes, up to 200 records | Preview only when you send dryRun: true | Preview by default |
| Permanent deletes | A Read and write key can delete trashed files for good (?permanent=true) and empty the file trash (DELETE /api/v1/trash) | AI apps can only move things to the trash |
| Call log | Reads aren’t logged | Every call logged by tool name |
| Sizes | Lists up to 200, search up to 50 | Lists up to 100, search up to 10 |
| Log a call and set its follow-up | Two requests | One tool call |
Examples
Create or update a contact
Run this once for 201 and created. Run it again for 200 and updated or unchanged, on the same contact.
curl -X POST "https://app.zyflowcrm.com/api/v1/contacts?upsert=true" \
-H "Authorization: Bearer $ZYFLOW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Maya Chen","company":"Northwind","emails":["maya@northwind.example"],"source":"Website form","tags":["website"]}' Response (trimmed):
{
"action": "created",
"record": { "id": "ct_…", "type": "contact", "fullName": "Maya Chen", "version": 1, "clientName": "Website form", … },
"related": { "company": { "action": "created", "id": "co_…", "name": "Northwind" } },
"warnings": [],
"batchId": "bat_…"
} - Passing company creates Northwind if it doesn’t exist yet, and that company counts as a record.4
- When action is unchanged or ambiguous, nothing was written, so there’s nothing to undo.
A website form through a small server-side relay
Your page posts the form to a small function on your server that holds the key and makes three calls: save the contact, log the message, set a follow-up. It needs Node 18 or newer and reads ZYFLOW_URL (your Zyflow address) and ZYFLOW_API_KEY from the environment. Wire handleFormPost to your host’s request handler.
// Runs on your server, never in the browser. Node 18 or newer.
const BASE = `${process.env.ZYFLOW_URL}/api/v1`;
const KEY = process.env.ZYFLOW_API_KEY; // a "Website form" key, Read and write
async function zyflow(path, body) {
const res = await fetch(BASE + path, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (res.ok === false) throw new Error(`${data.code}: ${data.message}`);
return data;
}
export async function handleFormPost(form) {
if (form.website) return { ok: true }; // honeypot: people leave this hidden field empty
const name = String(form.name ?? "").trim().slice(0, 160);
const email = String(form.email ?? "").trim().slice(0, 254);
const message = String(form.message ?? "").trim().slice(0, 5000);
if (name === "" || message === "" || email.includes("@") === false) {
return { ok: false, error: "Please fill in your name, email and message." };
}
// 1. Land on the contact you already have, matched by email
const saved = await zyflow("/contacts?upsert=true", {
name, emails: [email], source: "Website form", tags: ["website"],
});
// 2. More than one contact could match: stop, and keep the candidates for a person
if (saved.action === "ambiguous") return { ok: true, review: saved.candidates };
const contactId = saved.record.id;
// 3. Log the message on that contact
await zyflow("/activities", { contactId, kind: "message", title: "Website form", body: message });
// 4. Optional: a follow-up for tomorrow, in the workspace's time zone
await zyflow("/tasks", { contactId, title: `Reply to ${name}'s website message`, due: "tomorrow" });
return { ok: true };
} - Each request is its own change, with its own Undo in History.
- A retried post lands on the same contact. The same message on the same customer within 15 minutes, or a matching open follow-up, comes back duplicate: true and isn’t saved again.
- The relay is public: validate and length-limit every field, and keep the honeypot.
- The “Website form” key (Read and write) can do more than this relay needs, including exporting the workspace and deleting trashed files for good. Keep it in your host’s secret store, and call only these three routes.
Spot an error? Tell us