Integrations - SMB Sales Boost API

SMB Sales Boost can send lead data to your CRM, automation tools, and any HTTPS endpoint. Set up destinations from the Integrations tab in your dashboard, or with the endpoints below, which are also available as MCP tools (pushing your own lead data is REST only; see MCP Server). They live under /api/v1/integrations, use the same API key (Authorization: Bearer smbk_...), paid-plan requirement and rate limits as the rest of the API, and return the usual {"data": ...} and {"error", "message"} shapes. A 400 validation_error can also include a details array (see Error Handling).

Safeguards

An integration sends business contact details to an outside system automatically, on every subscribed event, until it is paused or deleted. So that nothing changes unnoticed:

Supported providers

HubSpot (hubspot)
Per-user OAuth. Creates or updates Contacts matched by email (a lead without an email creates no Contact) and Companies matched by website domain, or by exact company name when the lead has no website of its own (none, or a social or listing page such as Facebook or Yelp), and links each Contact to its Company (available when configured)
Salesforce (salesforce)
Per-user OAuth, Production or Sandbox. Creates or updates Leads matched by Email; a lead without an email creates a new Lead each time, and Company defaults to "Unknown" when the lead has no company name (available when configured)
Pipedrive (pipedrive)
Per-user OAuth; per-company API domain handled automatically. Matches Organizations by exact name and Persons by the SMB Sales Boost lead id, then by email; adds a note with the lead id on each delivery of a lead; optional Deals, see Rules worth knowing (available when configured)
Zapier (zapier)
Webhook trigger URL with HMAC signature (Live)
n8n (n8n)
Webhook node with HMAC signature (Live)
Make.com (make)
Custom webhook trigger with HMAC signature (Live)
Pipedream (pipedream)
HTTP source workflow with HMAC signature (Live)
Clay (clay)
Clay table "Import data" webhook with HMAC-signed JSON rows (Live)
Generic Webhook (generic_webhook)
Any HTTPS endpoint, signed JSON payload (Live)

HubSpot, Salesforce and Pipedrive are available when they are configured for this deployment; otherwise connecting them, or using an already connected one, returns 503 provider_unavailable. GET /api/v1/integrations/providers shows the current availability of every provider.

Events

Subscribe destinations to one or more of these events. A webhook receives each event as a signed JSON POST (see Delivery format below).

HubSpot, Salesforce and Pipedrive integrations take only lead.created and lead.updated; a subscription to any other event returns 400 validation_error. Clay integrations cannot subscribe to export.leads either (400 validation_error).

Delivery format

Each webhook delivery is an HTTPS POST with these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSMB-Sales-Boost-Webhooks/1.0
X-SMB-EventThe event type, for example lead.created
X-SMB-TimestampUnix seconds when this attempt was signed
X-SMB-Signaturet=<Unix seconds>,v1=<hex> (see Verifying signatures)
X-SMB-API-KeyOnly when the webhook has a header API key set for this destination URL (see Safeguards)

The body is an envelope; data depends on the event (shortened here):

Example message (for reference, nothing to copy)

{
  "id": "evt_1760000000000_k3j9qz",
  "type": "lead.created",
  "occurredAt": "2026-10-08T12:00:00.000Z",
  "userId": "your-user-id",
  "integrationId": 42,
  "provider": "zapier",
  "data": {
    "id": 123456,
    "databaseType": "other",
    "source": "api",
    "companyName": "Acme Plumbing",
    "website": "https://acmeplumbing.example",
    "email": "[email protected]",
    "phone": "(512) 555-0100",
    "city": "Austin",
    "state": "TX",
    "lastBuyingSignal": "2026-10-01T00:00:00.000Z",
    "buyingSignalType": ["Newly Added"]
  }
}
Deliverydata
Live lead.created / lead.updated, and a leadId push (lead.created)A flat lead summary: id, databaseType, source (dashboard, api, email_schedule or crm_push; a push has crm_push), companyName, website, email, emailSecondary, emailTier1 to emailTier6, phone, phoneSecondary, phoneTotal, address, city, state, zip, country, category, description, lastBuyingSignal and buyingSignalType (a list). lastUpdated and lastUpdatedReason carry the same two values under their older names.
Push of your own data (lead.created){"lead": {...}, "source": "manual_push"} with your own object as sent (see Push one lead)
Test (lead.created){"test": true, "message": "This is a test event from SMB Sales Boost integrations", "sample": {"id": 0, "companyName": "Acme Test Co", "website": "https://example.com"}}
export.completed, email_schedule.sent, credit.low_balanceThe fields listed under Events
export.leadsOne batch of leads (see Exported leads (batched) below)

A receiver that handles pushes and tests must read three lead.created shapes: the flat lead summary (live events and leadId pushes), your own data from a push, and the test event.

Verifying signatures

X-SMB-Signature is t=<Unix seconds>,v1=<hex>. t is the same value as X-SMB-Timestamp, and v1 is the hex HMAC-SHA256 of "<t>." + rawBody, keyed with your signing secret exactly as it was returned (use the string as is; do not hex-decode it). Check it against the raw request body before you parse the JSON, compare using a timing-safe comparison after checking that the lengths match, and reject a t older than 5 minutes (every attempt is signed with a fresh t).

Use this code only if the webhook goes to your own server. It checks that a message really came from SMB Sales Boost. Zoho Flow, Zapier, Make, n8n and Pipedream users can skip it.

// Node.js (Express): verify an SMB Sales Boost webhook before using it
const crypto = require("crypto");
const express = require("express");

const app = express();
const SIGNING_SECRET = process.env.SMB_SIGNING_SECRET; // exactly as returned on create
const MAX_AGE_SECONDS = 5 * 60;

// Mount this route before app.use(express.json()), which would consume the raw body.
app.post("/smb-webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!Buffer.isBuffer(req.body)) return res.status(400).send("Expected a JSON body");

  // X-SMB-Signature: t=<Unix seconds>,v1=<hex>
  const parts = {};
  for (const item of (req.get("X-SMB-Signature") || "").split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return res.status(400).send("Missing signature");
  if (Math.abs(Date.now() / 1000 - t) > MAX_AGE_SECONDS) return res.status(400).send("Stale timestamp");

  // HMAC-SHA256 of "<t>." followed by the raw body bytes, keyed with the secret string
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(parts.t + ".")
    .update(req.body)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("Bad signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // answer within 8 seconds; do slow work afterwards
  console.log(event.type, event.id); // use event.id to skip duplicates
});

app.listen(3000);

Endpoints

MethodPathDescription
GET/api/v1/integrations/providersList providers and runtime availability
GET/api/v1/integrationsList your integrations with subscriptions and who created or last changed them
GET/api/v1/integrations/:idSingle integration with subscription detail
POST/api/v1/integrations/webhookCreate a webhook integration; returns the signing secret once
PATCH/api/v1/integrations/:idUpdate display name, status, subscriptions (the list you send replaces them all), destination URL, header API key or Pipedrive deal settings
DELETE/api/v1/integrations/:idDelete an integration permanently, with its subscriptions and delivery history
GET/api/v1/integrations/:id/deliveriesThe 50 newest deliveries, each with its attempt count, last status code, error and the first 1000 characters of your endpoint's response, plus up to 10 recent Stopped or Not sent rows of Exported leads (batched)
POST/api/v1/integrations/:id/deliveries/:deliveryId/retrySend the stored data of a past delivery again as a new event, in one attempt; an Exported leads (batched) delivery is queued and sent in the background
POST/api/v1/integrations/:id/testSend one test lead.created event to the real destination (on a CRM this writes a real record for the sample company Acme Test Co that is not removed), or with eventType export.leads a sample batch of Exported leads (batched)
POST/api/v1/integrations/:id/push-leadPush one lead now: leadId (1 credit if new to you) or your own lead data (free)
POST/api/v1/integrations/:provider/connectStart a HubSpot, Salesforce or Pipedrive connection, or reconnect one already connected; returns authorizationUrl
GET/api/v1/integrations/:id/field-mappingCRM field list and current mapping (?refresh=true reloads the CRM fields)
PATCH/api/v1/integrations/:id/field-mappingChange which CRM field receives each lead field (replaces the whole saved mapping)
DELETE/api/v1/integrations/:id/field-mappingReset the mapping to the defaults
POST/api/v1/integrations/:id/field-mapping/testDry-run a mapping (creates, then deletes, synthetic CRM records)
GET/api/v1/integrations/:id/pipedrive/pipelinesPipedrive pipelines and stages
GET/api/v1/integrations/:id/pipedrive/deal-fieldsPipedrive deal fields and the deal field map (?refresh=true reloads them)

Errors: 400 validation_error or bad_request, 402 insufficient_credits (push-lead), 404 not_found, 409 integration_exists (create, or a PATCH that re-points to a destination another integration of the same provider already uses) or integration_limit (at most 25 integrations per account; also on connect, see Connect a CRM), 409 conflict (PATCH only: when the integration changes while the update is being applied, for example the owner pauses it at the same moment, the update is applied again on top of that change; only when it changes again does the PATCH return 409, nothing changed, so read it again and retry. If the API key making the PATCH is revoked in that moment, it returns 401 unauthorized "This API key has been revoked" and nothing is applied), 429 (all integration endpoints share one budget per account of 60 requests per minute, counted together with the dashboard; connect is also limited to 30 per minute), 502 provider_error (the CRM's API failed) and 503 provider_unavailable. A 503 provider_unavailable is not temporary (no Retry-After; do not retry): it means the CRM is not configured for this deployment. It can come from the field-mapping, Pipedrive pipeline and deal-field endpoints, test, retry, push-lead and connect. An unknown provider in the connect path is a 404. A 500 internal_error on create, PATCH or push-lead may come after the change was saved (or a credit was taken), so check GET /api/v1/integrations and the deliveries before repeating it; a 503 service_unavailable only happens before anything is written.

Responses

Every success is wrapped in {"data": ...}. Test, retry and push answer HTTP 200 even when the destination fails; read data.status. When a webhook test or push gets a non-2xx answer, data.error holds the first 500 characters of your endpoint's response (HTTP <status> when it sent no body). Paths starting with .../ are under /api/v1/integrations/:id/.

Endpointdata
GET /api/v1/integrations/providersA list of {id, name, tagline, description, authMethod, ownerSetupRequired, defaultEvents, supportsPerLeadPush, helpText, available}, plus setupHelpUrl when set and reason when unavailable
GET /api/v1/integrations (a list), GET and PATCH /api/v1/integrations/:idAn integration: {id, provider, providerName, status, displayName, externalAccountId, authMethod, metadata, consecutiveFailures, lastFailureAt, lastSuccessAt, tokenExpiresAt, createdAt, updatedAt, createdVia, lastChangedVia, lastChangedAt, subscriptions: [{id, eventType, targetUrl, isActive, config}]}. status is connected, disabled or needs_attention. createdVia and lastChangedVia are null or, for example, {"channel": "api", "apiKey": {"id": 7, "keyPrefix": "smbk_AbCdEfG...", "name": "Zap agent", "revoked": false}}; channel is dashboard, api or mcp, and apiKey is null for a dashboard change.
POST /api/v1/integrations/webhook{"integration": {...}, "signingSecret": "..."} (see Create a webhook integration)
DELETE /api/v1/integrations/:id{"message": "Integration deleted"}
GET .../deliveriesNewest first, a list of {id, eventType, targetUrl, status, statusCode, attemptCount, latencyMs, errorMessage, responseBody, createdAt, completedAt}. CRM rows have a targetUrl such as hubspot://contacts. An export.leads row also has batchSummary (see Exported leads (batched)).
POST .../test, POST .../deliveries/:deliveryId/retry{"status": "success" or "failed", "statusCode", "latencyMs", "error"}: statusCode and latencyMs come from webhooks, and error, when present, explains the failure. A retry of an export.leads delivery answers {"status": "queued", "queuedBatches", "queuedLeads"} instead (see Exported leads (batched)).
POST .../push-lead{"status", "tracked", "creditsUsed", "result", "error"}: result is {contactId, companyId} for HubSpot (each only when written), {leadId} for Salesforce and {statusCode, latencyMs} for a webhook (none for Pipedrive)
GET .../field-mappingHubSpot {smbFields, contactFields, companyFields, cachedAt, mapping}; Salesforce {smbFields, leadFields, contactFields, accountFields, cachedAt, mapping} (only the lead scope is mapped); Pipedrive {smbFields, personFields, organizationFields, cachedAt, mapping}. CRM field lists are cached for up to 10 minutes.
PATCH and DELETE .../field-mapping{"mapping": {...}}, the mapping now saved (after DELETE every scope is empty, which means the defaults)
POST .../field-mapping/test{ok, scopes: [{scope, label, ok, issues: [{smbFieldId, targetField, message, severity}]}]}, where severity is error or warning
GET .../pipedrive/pipelinesA list of {id, name, active, stages: [{id, name, orderNr}]}
GET .../pipedrive/deal-fields{smbFields, dealFields, cachedAt, fieldMap}

Rules worth knowing

Create a webhook integration

curl -X POST https://smbsalesboost.com/api/v1/integrations/webhook \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer smbk_your_api_key_here" \
  -d '{
    "provider": "zapier",
    "displayName": "My Zap",
    "targetUrl": "https://hooks.zapier.com/hooks/catch/XXX/YYY",
    "events": ["lead.created", "export.completed"]
  }'

Body fields:

The response is {"data": {"integration": {...}, "signingSecret": "..."}}. Store the signing secret now, because it is shown only once. If the request times out, the integration may have been created: check GET /api/v1/integrations first. Repeating the same provider and targetUrl returns 409 integration_exists; a different provider or a changed URL (even a trailing slash) creates a second integration with its own secret. If the integration exists but you did not get its signing secret, delete it (DELETE /api/v1/integrations/:id, which also deletes its delivery history) and create it again.

Connect a CRM

POST /api/v1/integrations/hubspot/connect (or salesforce, pipedrive) returns {"data": {"provider", "authorizationUrl", "expiresAt"}}. Salesforce also takes {"environment": "production"} (the default) or {"environment": "sandbox"}; sending environment for HubSpot or Pipedrive returns 400. Open authorizationUrl in a browser and approve access within 10 minutes. A new CRM integration then appears in GET /api/v1/integrations with its initial events: HubSpot lead.created on (lead.updated present but off; to turn it on, PATCH subscriptions with both events, see Rules worth knowing), Salesforce lead.created, Pipedrive lead.created and lead.updated. Only the account owner should approve it: whoever approves attaches their CRM to the account. When the account already has 25 integrations, connect returns 409 integration_limit unless one of them is an integration of that CRM that the sign-in could reconnect. The browser returns through /api/integrations/<provider>/callback, which is a redirect target, not an API endpoint.

Connecting a CRM account that is already connected re-authorizes that integration in place: it gets new access, a needs_attention integration becomes connected again, a paused one stays paused, and its events, field mapping, Pipedrive deal settings and delivery history are kept. The 25-integration limit applies only to new connections. So when a CRM integration stops working because its access was removed or expired in the CRM, reconnect it: click Reconnect on the integration in the dashboard's Integrations tab, or connect again through the API or MCP and sign in to the same CRM account (for a Salesforce Sandbox integration, send {"environment": "sandbox"}). PATCH {"status": "connected"} alone does not restore access. Signing in to a different account of the same CRM creates a new integration.

If approval does not complete, nothing is connected or changed and the dashboard URL shows <provider>_error=<reason>, for example key_revoked (the API key that created the link was revoked), integration_limit (the account has 25 integrations and the CRM account you approved is not already connected), invalid_state (the link expired or was already used; start again), already_connected (rare: two approvals of the same CRM account finished at the same moment) or the CRM's own error, such as access_denied. A HubSpot connect fails, creating nothing, when HubSpot does not confirm the account, with the message "We could not confirm which HubSpot account you signed in to, so nothing was connected or changed. Please try connecting again in a minute." Check GET /api/v1/integrations to confirm a connection.

Push one lead

POST /api/v1/integrations/:id/push-lead sends one lead to this integration now, in a single attempt with no automatic retries. Send {"leadId": 123} for a lead from the database (tracked like an export), or {"lead": {...}} for your own data (untracked and free; REST only, the MCP tool push_lead_to_integration takes leadId).

Exported leads (batched)

export.leads sends every lead of each export to a webhook in batches, instead of one request per lead. Use it when the tool that receives your leads charges per run or per request, such as Zoho Flow, Zapier or Make: an export of 1,000 leads arrives as 10 messages of 100 leads. It is opt-in: switch on "Exported leads (batched)" on a webhook integration in the Integrations tab of your dashboard, or subscribe the integration to export.leads through the API or MCP.

The message

The envelope is the same as for every event: type and the X-SMB-Event header are export.leads. data holds:

Each lead says whether this export is the first time it reached your account: isNew (SMB_Is_New in the Zoho CRM format). These are the same leads that send lead.created, through any channel. Create or update records on the lead id instead of filtering on isNew: a lead with isNew: false can still be missing from your tool, for example when it first reached your account through an API search. A batch that is sent again keeps its values.

In the Standard format each lead has the fields of the flat lead summary of lead.created (see Delivery format), with source set to the export's source, plus isNew. A Standard batch (shortened):

Example: a batch in the Standard format (for reference, nothing to copy)

{
  "id": "evt_exl_3f9a1c2b7d4e5f60_1_3",
  "type": "export.leads",
  "occurredAt": "2026-10-15T12:00:00.000Z",
  "userId": "your-user-id",
  "integrationId": 42,
  "provider": "generic_webhook",
  "data": {
    "exportRef": "exp_3f9a1c2b7d4e5f60",
    "batchId": "exp_3f9a1c2b7d4e5f60:3",
    "batchNumber": 3,
    "batchCount": 3,
    "batchSize": 100,
    "leadCount": 49,
    "totalLeads": 250,
    "blacklistedCount": 0,
    "source": "api",
    "databaseType": "other",
    "exportId": 412,
    "exportedAt": "2026-10-15T12:00:00.000Z",
    "format": "standard",
    "skipped": [{"id": 123460, "reason": "removed"}],
    "leads": [
      {
        "id": 123456,
        "databaseType": "other",
        "source": "api",
        "companyName": "Acme Plumbing",
        "website": "https://acmeplumbing.example",
        "email": "[email protected]",
        "phone": "(512) 555-0100",
        "city": "Austin",
        "state": "TX",
        "lastBuyingSignal": "2026-10-01T00:00:00.000Z",
        "buyingSignalType": ["Newly Added"],
        "isNew": true
      }
    ]
  }
}

Bigin by Zoho CRM (BETA): Bigin has no Leads module, so each lead is {"smbLeadId", "isNew", "company", "contact"}. company uses the field names of Bigin Companies (module Accounts); its Account_Name is the company name, or the website's host name when there is none. contact uses the field names of Bigin Contacts and is null when the lead has no valid email, or when an earlier lead of the same batch has the same email. Its Last_Name is the company name, because Bigin requires a last name and our leads are businesses. The contact has no link to its company: the Bigin script below adds it after saving the company. The company's Description holds the lead's description, category and last buying signal, each on its own line, and the contact's lists the lead's other emails. Values are shortened to Bigin's field lengths, for example 100 characters for a company name and 30 for a city, and fields without a value are left out of company and contact. Emails are cleaned before they are checked: a mailto: prefix and angle brackets are removed, and a value that holds several addresses, separated by commas, semicolons, vertical bars or spaces, is split into single addresses. An address that is still not a plain, valid email is left out, never shortened. One lead (shortened):

Example: one lead in the Bigin by Zoho CRM format (for reference, nothing to copy)

{
  "smbLeadId": "123456",
  "isNew": true,
  "company": {
    "Account_Name": "Acme Plumbing",
    "Website": "https://acmeplumbing.example",
    "Phone": "(512) 555-0100",
    "Billing_Street": "1 Main St",
    "Billing_City": "Austin",
    "Billing_State": "TX",
    "Billing_Code": "78701",
    "Billing_Country": "US",
    "Description": "Family plumbing business."
  },
  "contact": {
    "Last_Name": "Acme Plumbing",
    "Email": "[email protected]",
    "Phone": "(512) 555-0100",
    "Mailing_Street": "1 Main St",
    "Mailing_City": "Austin",
    "Mailing_State": "TX",
    "Mailing_Zip": "78701",
    "Mailing_Country": "US",
    "Description": "Other emails: [email protected]"
  }
}

Zoho CRM (BETA): each lead is one record of the Zoho CRM Leads module. Company and Last_Name, which Zoho CRM requires, both carry the company name, or the website's host name when there is none. Email and Secondary_Email are the lead's first two valid emails, cleaned and checked as in the Bigin format; an invalid email is left out, never shortened. Each email is sent only once per batch: a later lead with an email that an earlier lead of the same batch already has is sent without that email. The lead's description, category and last buying signal are in Description, and fields without a value are left out. SMB_Lead_ID (our lead id, as text) and SMB_Is_New are meant for two custom fields that you add to Leads (see the Zoho CRM script below). One lead:

Example: one lead in the Zoho CRM format (for reference, nothing to copy)

{
  "SMB_Lead_ID": "123456",
  "Company": "Acme Plumbing",
  "Last_Name": "Acme Plumbing",
  "Email": "[email protected]",
  "Secondary_Email": "[email protected]",
  "Phone": "(512) 555-0100",
  "Website": "https://acmeplumbing.example",
  "Street": "1 Main St",
  "City": "Austin",
  "State": "TX",
  "Zip_Code": "78701",
  "Country": "US",
  "Description": "Family plumbing business.",
  "SMB_Is_New": true
}

Receiving batches

When a batch fails

Retry

Recipes

Send a sample batch

The Send sample batch button on the Exported leads (batched) card of an integration's Events tab, POST /api/v1/integrations/:id/test with {"eventType": "export.leads"}, or MCP test_integration with eventType export.leads sends one sample batch to the active export.leads subscription, using its saved URL and format (save the integration first). data starts with "test": true and has its own exportRef (exp_test_ and 8 hex characters), batch 1 of 1, two made-up leads (Acme Test Co with every field filled in, Beta Test Co with only a few) and one example skipped entry, in the subscription's format. It is sent in one attempt, must be answered within 15 seconds, uses no credits and is recorded in the deliveries. Asking for the sample batch when the integration has no active export.leads subscription answers 400 "Exported leads (batched) is not switched on for this integration. Switch it on and save, then send the sample batch." Without eventType, the test also sends the sample batch when export.leads is the integration's only active event.

Zoho Flow scripts (BETA)

Which script do I use?

Not sure which one you have? Bigin's web address starts with bigin.zoho, Zoho CRM's with crm.zoho.

These two Zoho Flow custom functions (Deluge) are a starting point, written from Zoho's documentation and not yet tested against a live Zoho account, so try them with a few leads first. Each script ignores messages that are not export.leads, skips sample batches unless you set sendSampleBatches to true, sends the records as they arrive in one call to Zoho (the Bigin script makes one call for the companies and one for the contacts), checks the result of each record (Zoho reports a refused record inside an answer that otherwise looks successful) and returns a summary that you can read in the flow's History. Because the Bigin by Zoho CRM and Zoho CRM formats leave out empty fields, an update never clears a field you filled in. A batch of 100 leads takes about 2,500 Deluge statements with the Bigin script and about 750 with the Zoho CRM script, below Zoho's limit of 5,000 per function. Before you use one, set apiDomain to your Zoho data center (https://www.zohoapis.com in the US, or the same address ending in .eu, .in, .com.au, .jp, .ca, .com.cn or .sa) and put the link name of your Zoho Flow connection in each invokeurl block. If Zoho Flow reports that the function ran too long, lower Leads per batch.

Script for Zoho CRM

Use this script only if you use Zoho CRM (its Leads module) and the integration's Format is Zoho CRM. For Bigin, use the Script for Bigin by Zoho CRM below.

Setup: first add two custom fields to the Leads module: "SMB Lead ID" (single line, with "Do not allow duplicate values" ticked) and "SMB Is New" (checkbox). The script then matches leads by SMB_Lead_ID only, so every business stays its own Lead, even when several businesses share one email; a Lead you added to Zoho CRM in another way is not matched. Without the custom fields, set useCustomFields to false: leads are then matched by Email only, so businesses that share an email become one Lead, which the last one exported overwrites, and a lead without an email is added again each time it is exported.

// SMB Sales Boost: "Exported leads (batched)" to Zoho CRM Leads
// Zoho Flow custom function (Deluge). BETA: written from Zoho's documentation,
// not yet tested against a live Zoho account.
//
// What it does: one run of your flow receives one batch (up to 100 leads) in
// the "Zoho CRM" format and creates or updates every lead of data.leads in the
// Zoho CRM Leads module, in one call to Zoho. Each lead arrives as a ready
// Leads record without empty fields, so it is sent as it is and an update
// never clears a field you already filled in.
//
// Setup:
// 1. In Zoho CRM, add two custom fields to the Leads module:
//    - "SMB Lead ID" (Single Line) with "Do not allow duplicate values" ticked.
//      Its API name must be SMB_Lead_ID.
//    - "SMB Is New" (Checkbox). Its API name must be SMB_Is_New.
//    If you do not add them, set useCustomFields to false below.
// 2. In Zoho Flow, create a connection to Zoho CRM that may create and update
//    leads (Zoho calls this permission the scope ZohoCRM.modules.ALL). Put its
//    link name in the invokeurl block below, in place of zohocrm_connection.
// 3. In Zoho Flow, create a custom function with return type map and one input
//    named payload of type map, and paste this script. In your flow, map
//    payload to the whole webhook trigger (${trigger}).
// 4. Set apiDomain below to your Zoho data center.
//
// How leads are matched (useCustomFields below):
// - true (with the two custom fields): by SMB_Lead_ID only. Every business
//   stays its own Lead, even when several businesses share one email (an
//   agency or a head office). A Lead you added to Zoho CRM in another way is
//   not matched, so that business can appear twice.
// - false: by Email only. Businesses that share an email become ONE Lead: the
//   last one exported overwrites its company name, phone and address. A lead
//   without an email is added again each time it is exported. Within one batch
//   SMB Sales Boost sends each email only once (a later lead with the same
//   email arrives without it).
//
// Size: Deluge allows 5,000 statements per function. This script runs about
// 7 statements per lead (about 10 with useCustomFields false), so about 750
// for a batch of 100 leads (about 1,050 with useCustomFields false). If Zoho
// Flow still reports that the function ran too long or too many statements,
// lower "Leads per batch" on the integration in SMB Sales Boost (for example
// to 50).

map exportLeadsToZohoCrm(map payload)
{
  // ---- Settings ----
  // Your Zoho data center: https://www.zohoapis.com (US), or the same address
  // ending in .eu, .in, .com.au, .jp, .ca, .com.cn or .sa. Use the ending of
  // your Zoho CRM address: crm.zoho.eu means https://www.zohoapis.eu
  apiDomain = "https://www.zohoapis.com";
  // true: match leads by SMB_Lead_ID (needs the two custom fields from Setup
  // step 1). false: match leads by Email ("How leads are matched" above).
  useCustomFields = true;
  // Sample batches (data.test is true) hold two made-up leads, Acme Test Co and
  // Beta Test Co. false: they are skipped, so they never reach Zoho CRM.
  // Set it to true once to test the whole flow, delete the two sample leads in
  // Zoho CRM afterwards, and set it back to false.
  sendSampleBatches = false;
  // true: your Zoho CRM workflow rules run for these leads as usual.
  runWorkflows = true;

  result = Map();

  // ---- Read the message ----
  // payload is the whole message. If the webhook trigger extracts "data",
  // payload is the batch itself.
  if(payload.get("data") != null)
  {
    if(payload.get("type") != "export.leads")
    {
      // Another event sent to the same URL: nothing to do.
      result.put("status","ignored");
      result.put("reason","Not an export.leads message");
      return result;
    }
    batch = payload.get("data").toString().toMap();
  }
  else
  {
    batch = payload;
  }
  if(batch.get("batchId") == null || batch.get("leads") == null)
  {
    result.put("status","ignored");
    result.put("reason","No batch in this message");
    return result;
  }
  if(batch.get("format") != "zoho_crm")
  {
    result.put("status","error");
    result.put("reason","This script needs the Zoho CRM format. Set Format to Zoho CRM on the integration in SMB Sales Boost.");
    return result;
  }
  if(ifnull(batch.get("test"),false).toString() == "true" && !sendSampleBatches)
  {
    result.put("status","skipped");
    result.put("reason","Sample batch (data.test is true). Set sendSampleBatches to true to write the sample leads.");
    return result;
  }

  // data.leads is normally a list; if it arrives as text, turn it into a list.
  leadsValue = batch.get("leads");
  leadsText = leadsValue.toString().trim();
  if(leadsText.startsWith("["))
  {
    leads = leadsText.toJSONList();
  }
  else
  {
    leads = leadsValue;
  }
  // Zoho takes up to 100 records per call, and a batch has at most 100 leads.
  if(leads.size() > 100)
  {
    result.put("status","error");
    result.put("reason","More than 100 leads in this batch. Set Leads per batch to 100 or less on the integration in SMB Sales Boost.");
    return result;
  }

  // ---- The records: each lead as it arrives ----
  records = List();
  if(useCustomFields)
  {
    for each leadItem in leads
    {
      records.add(leadItem.toString().toMap());
    }
  }
  else
  {
    // Without the custom fields, leave out the two fields Zoho CRM does not have.
    for each leadItem in leads
    {
      record = leadItem.toString().toMap();
      record.remove("SMB_Lead_ID");
      record.remove("SMB_Is_New");
      records.add(record);
    }
  }

  duplicateCheck = List();
  if(useCustomFields)
  {
    duplicateCheck.add("SMB_Lead_ID");
  }
  else
  {
    duplicateCheck.add("Email");
  }

  // ---- Send (one call for the whole batch) ----
  requestBody = Map();
  requestBody.put("data",records);
  requestBody.put("duplicate_check_fields",duplicateCheck);
  if(!runWorkflows)
  {
    requestBody.put("trigger",List());
  }
  response = invokeurl
  [
    url :apiDomain + "/crm/v8/Leads/upsert"
    type :POST
    parameters:requestBody.toString()
    headers:{"Content-Type":"application/json"}
    connection:"zohocrm_connection"
  ];
  answer = response.toString().toMap();
  results = answer.get("data");

  inserted = 0;
  updated = 0;
  failed = 0;
  errors = List();
  if(results == null)
  {
    // The whole call was refused, for example a connection without permission.
    failed = records.size();
    errors.add("Call refused: " + ifnull(answer.get("code"),"") + " " + ifnull(answer.get("message"),""));
  }
  else
  {
    // One result per record, in the order sent. A refused record comes back
    // inside an answer that otherwise looks successful, so check each one.
    position = 0;
    for each recordResult in results
    {
      if(recordResult.get("status") == "success")
      {
        if(recordResult.get("action") == "insert")
        {
          inserted = inserted + 1;
        }
        else
        {
          updated = updated + 1;
        }
      }
      else
      {
        failed = failed + 1;
        if(errors.size() < 20)
        {
          leadLabel = "";
          if(position < records.size())
          {
            sentRecord = records.get(position);
            leadLabel = ifnull(sentRecord.get("SMB_Lead_ID"),ifnull(sentRecord.get("Company"),""));
          }
          errors.add("Lead " + leadLabel + ": " + ifnull(recordResult.get("code"),"") + " " + ifnull(recordResult.get("message"),"") + " " + ifnull(recordResult.get("details"),"").toString());
        }
      }
      position = position + 1;
    }
  }

  // ---- Summary (shown in Zoho Flow's History) ----
  result.put("status","done");
  result.put("batch",batch.get("batchNumber").toString() + " of " + batch.get("batchCount").toString());
  result.put("batchId",batch.get("batchId"));
  result.put("leads",records.size());
  result.put("inserted",inserted);
  result.put("updated",updated);
  result.put("failed",failed);
  result.put("errors",errors);
  return result;
}
Script for Bigin by Zoho CRM

Use this script only if you use Bigin by Zoho CRM and the integration's Format is Bigin by Zoho CRM. For Zoho CRM, use the Script for Zoho CRM above.

How it works: the script creates or updates the companies, matched by Website, then the contacts, matched by Email and linked to their company. On Bigin Express or higher, tick "Do not allow duplicate values" for Website in Companies and for Email in Contacts. We have not confirmed how Bigin matches records when these fields are not unique (Bigin Free has no unique fields), so test it once: set sendSampleBatches to true, send the sample batch twice, check that Bigin shows one company per sample lead and one contact, then delete the sample records and set it back to false.

// SMB Sales Boost: "Exported leads (batched)" to Bigin by Zoho CRM
// Zoho Flow custom function (Deluge). BETA: written from Zoho's documentation,
// not yet tested against a live Zoho account.
//
// What it does: one run of your flow receives one batch (up to 100 leads) in
// the "Bigin by Zoho CRM" format. It creates or updates the Company of every
// lead of data.leads in one call to Bigin, then the Contacts (a lead has one
// only when it has a valid email) in a second call, each linked to its
// Company. Companies and contacts arrive without empty fields, so they are
// sent as they are and an update never clears a field you already filled in.
//
// Setup:
// 1. In Bigin (Express or higher), open the field settings and tick "Do not
//    allow duplicate values" for Website in Companies and for Email in
//    Contacts. The script asks Bigin to match companies by Website and
//    contacts by Email. We have not been able to confirm how Bigin matches
//    when these fields are not marked unique (Bigin Free has no unique
//    fields), so send the same sample batch twice and check that Bigin shows
//    one company and one contact per lead.
// 2. In Zoho Flow, create a connection to Bigin that may create and update
//    Companies and Contacts (Zoho calls this permission the scope
//    ZohoBigin.modules.ALL). Put its link name in BOTH invokeurl blocks
//    below, in place of bigin_connection. We have not been able to confirm
//    that a connection created in Zoho Flow includes this scope: if the
//    result shows OAUTH_SCOPE_MISMATCH or a permission error, that is the cause.
// 3. In Zoho Flow, create a custom function with return type map and one input
//    named payload of type map, and paste this script. In your flow, map
//    payload to the whole webhook trigger (${trigger}).
// 4. Set apiDomain below to your Zoho data center.
//
// Size: Deluge allows 5,000 statements per function. This script runs about
// 25 statements per lead with a contact and about 14 per lead without one, so
// about 2,500 for a batch of 100 leads. If Zoho Flow still reports that the
// function ran too long or too many statements, lower "Leads per batch" on
// the integration in SMB Sales Boost (for example to 50).

map exportLeadsToBigin(map payload)
{
  // ---- Settings ----
  // Your Zoho data center: https://www.zohoapis.com (US), or the same address
  // ending in .eu, .in, .com.au, .jp, .ca, .com.cn or .sa. Use the ending of
  // your Bigin address: bigin.zoho.eu means https://www.zohoapis.eu
  apiDomain = "https://www.zohoapis.com";
  // Sample batches (data.test is true) hold two made-up leads, Acme Test Co and
  // Beta Test Co. false: they are skipped, so they never reach Bigin.
  // Set it to true once to test the whole flow, delete the sample companies
  // and contact in Bigin afterwards, and set it back to false.
  sendSampleBatches = false;
  // true: your Bigin workflows run for these records as usual.
  runWorkflows = true;

  result = Map();

  // ---- Read the message ----
  // payload is the whole message. If the webhook trigger extracts "data",
  // payload is the batch itself.
  if(payload.get("data") != null)
  {
    if(payload.get("type") != "export.leads")
    {
      // Another event sent to the same URL: nothing to do.
      result.put("status","ignored");
      result.put("reason","Not an export.leads message");
      return result;
    }
    batch = payload.get("data").toString().toMap();
  }
  else
  {
    batch = payload;
  }
  if(batch.get("batchId") == null || batch.get("leads") == null)
  {
    result.put("status","ignored");
    result.put("reason","No batch in this message");
    return result;
  }
  if(batch.get("format") != "bigin")
  {
    result.put("status","error");
    result.put("reason","This script needs the Bigin format. Set Format to Bigin by Zoho CRM on the integration in SMB Sales Boost.");
    return result;
  }
  if(ifnull(batch.get("test"),false).toString() == "true" && !sendSampleBatches)
  {
    result.put("status","skipped");
    result.put("reason","Sample batch (data.test is true). Set sendSampleBatches to true to write the sample leads.");
    return result;
  }

  // data.leads is normally a list; if it arrives as text, turn it into a list.
  leadsValue = batch.get("leads");
  leadsText = leadsValue.toString().trim();
  if(leadsText.startsWith("["))
  {
    leads = leadsText.toJSONList();
  }
  else
  {
    leads = leadsValue;
  }
  // Bigin takes up to 100 records per call, and a batch has at most 100 leads.
  if(leads.size() > 100)
  {
    result.put("status","error");
    result.put("reason","More than 100 leads in this batch. Set Leads per batch to 100 or less on the integration in SMB Sales Boost.");
    return result;
  }

  companiesInserted = 0;
  companiesUpdated = 0;
  companiesFailed = 0;
  contactsInserted = 0;
  contactsUpdated = 0;
  contactsFailed = 0;
  contactsWithoutCompany = 0;
  errors = List();

  // ---- 1. Companies: each lead's company as it arrives ----
  leadMaps = List();
  companies = List();
  for each leadItem in leads
  {
    lead = leadItem.toString().toMap();
    leadMaps.add(lead);
    companies.add(lead.get("company").toString().toMap());
  }
  companyBody = Map();
  companyBody.put("data",companies);
  companyCheck = List();
  companyCheck.add("Website");
  companyBody.put("duplicate_check_fields",companyCheck);
  if(!runWorkflows)
  {
    companyBody.put("trigger",List());
  }
  companyResponse = invokeurl
  [
    url :apiDomain + "/bigin/v2/Accounts/upsert"
    type :POST
    parameters:companyBody.toString()
    headers:{"Content-Type":"application/json"}
    connection:"bigin_connection"
  ];
  companyAnswer = companyResponse.toString().toMap();
  companyResults = companyAnswer.get("data");

  // The company id of each lead, in the same order as the leads ("" when the
  // company was not saved). Zoho's examples return one result per record in
  // the order sent; if the count differs, contacts are saved unlinked.
  companyIds = List();
  if(companyResults == null)
  {
    companiesFailed = companies.size();
    errors.add("Companies refused: " + ifnull(companyAnswer.get("code"),"") + " " + ifnull(companyAnswer.get("message"),""));
    for each lead in leadMaps
    {
      companyIds.add("");
    }
  }
  else if(companyResults.size() != companies.size())
  {
    errors.add("Bigin returned " + companyResults.size().toString() + " company results for " + companies.size().toString() + " companies, so contacts were not linked.");
    for each lead in leadMaps
    {
      companyIds.add("");
    }
  }
  else
  {
    // A refused record comes back inside an answer that otherwise looks
    // successful, so check each one.
    position = 0;
    for each companyResult in companyResults
    {
      if(companyResult.get("status") == "success")
      {
        companyIds.add(companyResult.get("details").get("id"));
        if(companyResult.get("action") == "insert")
        {
          companiesInserted = companiesInserted + 1;
        }
        else
        {
          companiesUpdated = companiesUpdated + 1;
        }
      }
      else
      {
        companyIds.add("");
        companiesFailed = companiesFailed + 1;
        if(errors.size() < 20)
        {
          errors.add("Company of lead " + ifnull(leadMaps.get(position).get("smbLeadId"),"") + ": " + ifnull(companyResult.get("code"),"") + " " + ifnull(companyResult.get("message"),"") + " " + ifnull(companyResult.get("details"),"").toString());
        }
      }
      position = position + 1;
    }
  }

  // ---- 2. Contacts: each contact as it arrives, linked to its company by position ----
  contacts = List();
  contactLeadIds = List();
  position = 0;
  for each lead in leadMaps
  {
    // contact is null when the lead has no valid email.
    contactText = ifnull(lead.get("contact"),"").toString();
    if(contactText != "" && contactText != "null")
    {
      contact = contactText.toMap();
      companyId = companyIds.get(position);
      if(companyId != "")
      {
        contact.put("Account_Name",{"id":companyId});
      }
      else
      {
        contactsWithoutCompany = contactsWithoutCompany + 1;
      }
      contacts.add(contact);
      contactLeadIds.add(lead.get("smbLeadId"));
    }
    position = position + 1;
  }
  if(contacts.size() > 0)
  {
    contactBody = Map();
    contactBody.put("data",contacts);
    contactCheck = List();
    contactCheck.add("Email");
    contactBody.put("duplicate_check_fields",contactCheck);
    if(!runWorkflows)
    {
      contactBody.put("trigger",List());
    }
    contactResponse = invokeurl
    [
      url :apiDomain + "/bigin/v2/Contacts/upsert"
      type :POST
      parameters:contactBody.toString()
      headers:{"Content-Type":"application/json"}
      connection:"bigin_connection"
    ];
    contactAnswer = contactResponse.toString().toMap();
    contactResults = contactAnswer.get("data");
    if(contactResults == null)
    {
      contactsFailed = contacts.size();
      errors.add("Contacts refused: " + ifnull(contactAnswer.get("code"),"") + " " + ifnull(contactAnswer.get("message"),""));
    }
    else
    {
      position = 0;
      for each contactResult in contactResults
      {
        if(contactResult.get("status") == "success")
        {
          if(contactResult.get("action") == "insert")
          {
            contactsInserted = contactsInserted + 1;
          }
          else
          {
            contactsUpdated = contactsUpdated + 1;
          }
        }
        else
        {
          contactsFailed = contactsFailed + 1;
          if(errors.size() < 20)
          {
            leadLabel = "";
            if(position < contactLeadIds.size())
            {
              leadLabel = ifnull(contactLeadIds.get(position),"");
            }
            errors.add("Contact of lead " + leadLabel + ": " + ifnull(contactResult.get("code"),"") + " " + ifnull(contactResult.get("message"),"") + " " + ifnull(contactResult.get("details"),"").toString());
          }
        }
        position = position + 1;
      }
    }
  }

  // ---- Summary (shown in Zoho Flow's History) ----
  result.put("status","done");
  result.put("batch",batch.get("batchNumber").toString() + " of " + batch.get("batchCount").toString());
  result.put("batchId",batch.get("batchId"));
  result.put("leads",leads.size());
  result.put("companiesInserted",companiesInserted);
  result.put("companiesUpdated",companiesUpdated);
  result.put("companiesFailed",companiesFailed);
  result.put("contactsInserted",contactsInserted);
  result.put("contactsUpdated",contactsUpdated);
  result.put("contactsFailed",contactsFailed);
  result.put("contactsWithoutCompany",contactsWithoutCompany);
  result.put("errors",errors);
  return result;
}

API Documentation Sections

Introduction | Programmatic Purchase | Authentication | Rate Limits | Long-Running Requests | User Profile | Enrichments | Leads | Filter Presets | Keyword Lists | Email Schedules | Export Formats | Export History | Lead Export History | Account Settings | AI Features | Export Blacklist | Error Handling | Credits & Subscription | MCP Server | Integrations

View Full API Documentation


Ready to Find Your Next Customers? Get access to growing local business leads updated every 5 minutes. See plans starting at $49/mo →