Integrations
CRM API & Google Sheets sync
The CRM is API-first: everything the interface does exists as a REST endpoint. This guide covers the API and the canonical integration pattern — keeping an external lead source (a Google Sheet, a form tool, Zapier/Make, your own backend) in sync with a pipeline, without ever creating duplicates.
Architecture: who owns which field
A healthy sync gives every field one source of truth. The pattern that works: the external source owns the acquisition data (identity, origin details) and pushes it to Sendcore; Sendcore owns the sales work (stage, owner, follow-up, notes, amounts) and the source reads it back. Avoid blind two-way sync on the same field — the last writer always wins, and it is rarely who you wanted.
The reconciliation key is the pair external_source + external_id: a stable name for the source (e.g. my_leads_sheet) and the row's own identifier. Sendcore guarantees that the same pair always maps to the same card, forever — retries, replays and repeated cron runs are safe by construction.
Prerequisites
- An Enterprise plan (the write API requires full CRM operations).
- A pipeline and your custom fields configured — see the CRM guide. Do this first: the API validates every field key you send against your definitions.
- An API key: Settings → API keys → create a key with full access. It is shown once — store it in your integration's secret storage, never in the sheet itself.
Authentication, limits, errors
- Base URL
https://www.sendcore.me— all endpoints under/api/v1/crm/. - Auth header:
Authorization: Bearer sc_live_…(orx-api-key). - Rate limits: 240 requests/min per IP, 300/min per account. A sync every few minutes fits comfortably.
- Errors are JSON:
{ "ok": false, "error": "…" }— plusfield_errorskeyed per field when a value fails validation.401bad key,403insufficient key scope or plan,404not found,409conflict (e.g. duplicate external ID),429rate limited — retry on the next run.
Endpoint reference
GET /api/v1/crm/pipelines— your pipelines with their stages (ids included). Read this at startup instead of hardcoding ids.POST /api/v1/crm/pipelines·PATCH/DELETE /api/v1/crm/pipelines/:id— create, rename/archive, delete (delete requires the pipeline to be empty).POST /api/v1/crm/pipelines/:id/stages·PUT …/stages(reorder) ·PATCH/DELETE /api/v1/crm/stages/:id.GET /api/v1/crm/fields·POST /api/v1/crm/fields·PATCH/DELETE /api/v1/crm/fields/:id— custom field definitions (DELETE archives).POST /api/v1/crm/cards/upsert— the integration endpoint: create-or-update by external ID (below).POST /api/v1/crm/cards— plain create;GET /api/v1/crm/cards/:id— one card with contact and external IDs;PATCH /api/v1/crm/cards/:id— update (astage_idin the patch moves the card).GET /api/v1/crm/cards— search; one filter is required:external_source+external_id(exact lookup),email(a contact's cards),pipeline_id(a board), orupdated_since(the sync cursor, below).
Pushing leads in: the upsert
One call per row. If the external_source/external_id pair is new, the card is created (HTTP 201, "created": true) in the pipeline's first stage, and the contact is found or created by email. If the pair is known, the card is updated — and only the fields you send are touched, so the sales work done in Sendcore is never overwritten.
POST https://www.sendcore.me/api/v1/crm/cards/upsert
Authorization: Bearer sc_live_xxxxxxxx
Content-Type: application/json
{
"external_source": "my_leads_sheet",
"external_id": "LEAD-00042",
"pipeline_id": "<pipeline uuid>",
"contact": {
"email": "anna@example.com",
"first_name": "Anna",
"last_name": "Verdi",
"phone": "+39 02 1234567"
},
"title": "Example S.r.l.",
"fields": {
"company_name": "Example S.r.l.",
"city": "Milano",
"lead_created_at": "2026-08-10T09:15:00Z"
}
}{
"ok": true,
"created": true,
"card": {
"id": "9f0e8d7c-…",
"pipeline_id": "…",
"stage_id": "…",
"contact": { "id": "…", "email": "anna@example.com", "first_name": "Anna", … },
"title": "Example S.r.l.",
"amount": null,
"follow_up_at": null,
"owner_id": null,
"fields": { "company_name": "Example S.r.l.", "city": "Milano", … },
"created_at": "…",
"updated_at": "…"
}
}- Save
card.idback into your source (a “Sendcore ID” column): it is the stable reference for reading updates back. - Do not send
stage_idfrom the source on regular pushes — the stage belongs to the people working the pipeline. New cards enter the first stage on their own. fieldskeys must match your field definitions' keys; values are validated by type. An unknown key fails loudly withfield_errors— a mapping typo surfaces immediately instead of silently dropping data.- Dates in ISO 8601 (UTC). Numbers accept comma decimals. Standard card attributes:
title,amount,priority,follow_up_at.
Reading changes back: the cursor
To bring the sales work back to your source, poll GET /api/v1/crm/cards?updated_since=<ISO>. It returns every card modified after that instant, ordered by updated_at ascending, up to 500 per call with has_more — advance your stored cursor to the last card's updated_at and repeat until has_more is false. Archived cards are included: an archive is a change your source needs to know about. Optionally scope with pipeline_id.
GET https://www.sendcore.me/api/v1/crm/cards?updated_since=2026-08-10T12:00:00Z
Authorization: Bearer sc_live_xxxxxxxx
{
"ok": true,
"has_more": false,
"cards": [
{
"id": "9f0e8d7c-…",
"stage_id": "…",
"owner_id": "…",
"amount": 4500,
"follow_up_at": "2026-08-14T07:00:00.000Z",
"archived_at": null,
"fields": { "operational_notes": "Called, waiting for a quote OK", … },
"updated_at": "2026-08-10T14:22:31.512Z"
}
]
}Complete example: Google Apps Script
A minimal, production-shaped pair of functions for a sheet with an ID column per row. Adapt the columns and the fields mapping to your own definitions. Store SENDCORE_API_KEY and PIPELINE_ID in Script properties, never in cells.
// Sheet -> Sendcore. Columns (example): A=Lead ID, B=Email, C=First name,
// D=Last name, E=Phone, F=Company, G=City, H=Sendcore ID, I=Sync status.
var BASE = 'https://www.sendcore.me';
function apiKey() {
// Script properties: File > Project settings > Script properties.
return PropertiesService.getScriptProperties().getProperty('SENDCORE_API_KEY');
}
function pushRows() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads');
var pipelineId = PropertiesService.getScriptProperties().getProperty('PIPELINE_ID');
var rows = sheet.getDataRange().getValues();
for (var r = 1; r < rows.length; r++) {
var leadId = String(rows[r][0]).trim();
var email = String(rows[r][1]).trim();
if (!leadId || !email) continue;
var payload = {
external_source: 'my_leads_sheet',
external_id: leadId,
pipeline_id: pipelineId,
contact: {
email: email,
first_name: String(rows[r][2] || ''),
last_name: String(rows[r][3] || ''),
phone: String(rows[r][4] || '')
},
fields: {
company_name: String(rows[r][5] || ''),
city: String(rows[r][6] || '')
}
};
var res = UrlFetchApp.fetch(BASE + '/api/v1/crm/cards/upsert', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + apiKey() },
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
var body = JSON.parse(res.getContentText() || '{}');
if (body.ok) {
sheet.getRange(r + 1, 8).setValue(body.card.id); // Sendcore ID
sheet.getRange(r + 1, 9).setValue('OK ' + new Date()); // Sync status
} else {
sheet.getRange(r + 1, 9).setValue('ERROR: ' + (body.error || res.getResponseCode()));
}
}
}// Sendcore -> Sheet. Reads every card modified since the last run and
// writes the sales fields back next to the matching Lead ID row.
function pullUpdates() {
var props = PropertiesService.getScriptProperties();
var cursor = props.getProperty('SYNC_CURSOR') || '1970-01-01T00:00:00Z';
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads');
var rows = sheet.getDataRange().getValues();
// Sendcore ID (column H) -> row number, for fast lookup.
var rowById = {};
for (var r = 1; r < rows.length; r++) {
if (rows[r][7]) rowById[String(rows[r][7])] = r + 1;
}
var hasMore = true;
while (hasMore) {
var res = UrlFetchApp.fetch(
BASE + '/api/v1/crm/cards?updated_since=' + encodeURIComponent(cursor),
{ headers: { Authorization: 'Bearer ' + apiKey() }, muteHttpExceptions: true }
);
var body = JSON.parse(res.getContentText() || '{}');
if (!body.ok) break;
body.cards.forEach(function (card) {
var row = rowById[card.id];
if (row) {
sheet.getRange(row, 10).setValue(card.stage_id); // J: stage
sheet.getRange(row, 11).setValue(card.follow_up_at || ''); // K: follow-up
sheet.getRange(row, 12).setValue((card.fields || {}).operational_notes || ''); // L: notes
}
cursor = card.updated_at; // advance the cursor to the last row seen
});
hasMore = body.has_more === true;
}
props.setProperty('SYNC_CURSOR', cursor);
}- In the Apps Script editor: Project settings → Script properties → add
SENDCORE_API_KEYandPIPELINE_ID(fromGET /api/v1/crm/pipelines). - Add two time-driven triggers (clock icon → Add trigger):
pushRowsandpullUpdates, e.g. every 5 minutes. - Run each function once by hand to grant permissions and verify the first sync.
Good practices
- Log the outcome per row (a “Sync status” column with OK/ERROR + timestamp) — a broken mapping shows up in the sheet, not in silence.
- Keep
external_sourcestable per source. If you sync several sheets into one pipeline, give each its own source name — same-named IDs from different sheets will never collide. - Map stage ids, not names, when translating statuses — names are free to change in the UI.
- Remember consent: CRM-created contacts are not subscribed to marketing. Campaigns to leads require explicit consent.