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.
On this page8 sections
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.