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.

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#

  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/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 (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