CRM API & Google Sheets

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

  1. An Enterprise plan (the write API requires full CRM operations).
  2. 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.
  3. 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_… (or x-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": "…" } — plus field_errors keyed per field when a value fails validation. 401 bad key, 403 insufficient key scope or plan, 404 not found, 409 conflict (e.g. duplicate external ID), 429 rate 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/upsertthe 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 (a stage_id in 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), or updated_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.

http
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"
  }
}
json
{
  "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.id back into your source (a “Sendcore ID” column): it is the stable reference for reading updates back.
  • Do not send stage_id from the source on regular pushes — the stage belongs to the people working the pipeline. New cards enter the first stage on their own.
  • fields keys must match your field definitions' keys; values are validated by type. An unknown key fails loudly with field_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.

http
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.

javascript
// 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()));
    }
  }
}
javascript
// 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);
}
  1. In the Apps Script editor: Project settings → Script properties → add SENDCORE_API_KEY and PIPELINE_ID (from GET /api/v1/crm/pipelines).
  2. Add two time-driven triggers (clock icon → Add trigger): pushRows and pullUpdates, e.g. every 5 minutes.
  3. 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_source stable 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.

Next steps