 

# SMB Sales Boost Tools & Integrations

Send your leads and exports to HubSpot, Salesforce, Pipedrive, Zapier, Make, n8n, Clay, Zoho Flow or any HTTPS webhook.

Building on the API or MCP? See the [API Documentation](/docs/api) and the [MCP Server](/docs/api/mcp-server).

## Table of Contents

[Overview](#overview) | [Safeguards](#safeguards) | [Supported providers](#supported-providers) | [Events](#events) | [Delivery format](#delivery-format) | [Verifying signatures](#verifying-signatures) | [Exported leads (batched)](#exported-leads-batched) | [Zoho Flow scripts (BETA)](#zoho-flow-scripts)

## Overview

SMB Sales Boost can send lead data to your CRM, automation tools, and any HTTPS endpoint: new and updated leads, finished exports, every lead of an export in batches, scheduled emails and low credit balances. This page explains what each provider does, the events you can subscribe to, what a webhook receives and how to check its signature, and how to import your exports in batches with Zoho Flow, Zapier, Make or n8n.

You can set up and manage integrations in three ways:

* **Dashboard:** the Integrations tab in your dashboard connects HubSpot, Salesforce and Pipedrive, and adds webhooks for Zapier, n8n, Make, Pipedream, Clay or any HTTPS endpoint.
* **REST API:** the endpoints under `/api/v1/integrations`, described in the [Integrations API](/docs/api/integrations) reference.
* **MCP server:** the integration tools of the [MCP Server](/docs/api/mcp-server) let an AI assistant manage your integrations with the same API key.

The REST API and the MCP server need an active paid subscription (Starter, Growth, Scale, Platinum or Enterprise); see the [API introduction](/docs/api/introduction).

**Building on the API or MCP?** See the [API Documentation](/docs/api), the [Integrations API](/docs/api/integrations) (endpoints, responses, rules, pushing one lead, creating a webhook, connecting a CRM) and the [MCP Server](/docs/api/mcp-server).

---

## 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:

* When an API key or MCP client creates a webhook, connects a new CRM account, changes where an integration sends data, adds or re-activates an event, or re-enables an integration (including a successful test or retry, or a reconnect, that turns a needs\_attention integration back on), the account owner is emailed for each change. Each account gets up to 10 of these emails an hour for destination changes and 10 for other changes; past that, one alert is sent and further changes are combined into summary emails.
* The dashboard shows which API key created or last changed each integration, for example "Created via API key smbk\_AbCdEfG..." or, when the change came through the MCP server, "Created via MCP with API key smbk\_AbCdEfG...". A change in the dashboard replaces that record only when it adds a destination URL the integration never had. Pausing an integration (also when you revoke its key), and resuming it, switching on more events or switching on a URL it already had in the dashboard, keep it.
* When you revoke an API key in Dashboard > API, you can also pause every integration that key created or last changed (off by default). Paused integrations can be re-enabled in the Integrations tab or with `PATCH /api/v1/integrations/:id` and `{"status": "connected"}`. A CRM connect link created with a key that is later revoked stops working: nothing is connected or changed.
* The webhook signing secret is returned only once, when the webhook is created.
* A header API key stored on a webhook (sent as `X-SMB-API-Key`) is only sent to the active destination URLs it was set for: it is bound to them when it is set, and editing the integration in the dashboard binds it again to the integration's active destinations on the same origin. A URL on a switched-off subscription is never bound, so switching that subscription on later does not send the key there until the owner edits the integration in the dashboard or `apiKey` is sent again. Pointing such a webhook at a new destination URL with an API key requires `apiKey` in the same request (a new value, or `null` to stop sending it), even when the new URL is on the same host: services like Zapier, Make and Clay put every customer's hooks on one host.

---

## 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 in the Integrations API (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.

To connect a CRM, map its fields or create Pipedrive Deals through the API, see [Connect a CRM](/docs/api/integrations#connect-a-crm) and [Rules worth knowing](/docs/api/integrations#rules-worth-knowing) in the Integrations API.

---

## Events

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

* `lead.created` \- one event per lead, the first time a lead reaches your account (dashboard or API export, API search, email schedule, lead reservoir or push), with that lead's business contact details. Leads you get through a URL enrichment run send no `lead.created` or `lead.updated`; the next time such a lead reaches your account it sends `lead.updated`.
* `lead.updated` \- one event per lead when a lead you already received reaches your account again with a newer Last Buying Signal than when you last received it (an export or email schedule that includes it, a push to another integration, or a `GET /api/v1/leads` page or MCP `search_leads` call that returns it), or when a [Lead Export History](/docs/api/lead-export-history) refresh finds that newer data. Receiving an unchanged lead again sends nothing. The push target itself receives the push as `lead.created` (see [Push one lead](/docs/api/integrations#push-one-lead)), a refresh sends nothing for a lead on your export blacklist, and `GET /api/v1/leads/preview` and re-exports from Lead Export History that do not refresh send nothing. Pipedrive integrations subscribe to it by default (HubSpot has it off).
* `export.completed` \- one event per finished lead export: an export from the Leads tab in the dashboard, `POST /api/v1/leads/export` (and the MCP tool `export_leads`), a scheduled email that delivered leads (sent in addition to `email_schedule.sent`) and an export from your lead reservoir. It is not sent for a re-export or refresh from [Lead Export History](/docs/api/lead-export-history), a push to an integration, a URL enrichment run, an export of 0 leads or an export that was refused (for example 402 insufficient\_credits). `data`: `count` (the number of leads the export delivered), `databaseType`, `source` (`dashboard`, `api`, `email_schedule` or `reservoir`), `exportId` (the [Export History](/docs/api/export-history) id of the export's first file, for `GET /api/v1/export-history/:id/download` or `/download-url`; null when the export could not be added to Export History, and always null for `email_schedule`), `scheduleId` and `scheduleName` (only when `source` is `email_schedule`) and `sampleLeads` (up to 5 of the exported leads, each with `id`, `companyName`, `website`, `email`, `phone`, `city` and `state`). Example `data`: `{"count": 250, "databaseType": "other", "source": "api", "exportId": 412, "sampleLeads": [{"id": 1, "companyName": "Acme Dental", "website": "https://acme.example.com", "email": "info@acme.example.com", "phone": "+1 555 010 0001", "city": "Austin", "state": "TX"}]}`
* `export.leads` \- Exported leads (batched), opt-in (never switched on by default): every lead of each export that sends `export.completed` (the same four sources), including leads you received before, sent to webhooks in batches of up to 100 leads per message, so a tool that charges per run, such as Zoho Flow, Zapier or Make, counts one run per batch instead of one per lead. Leads on your export blacklist are left out. Each lead has `isNew` (`SMB_Is_New` in the Zoho CRM format): `true` the first time it reached your account, the same leads that send `lead.created`; create or update records on the lead id and never filter on it. Webhooks only: HubSpot, Salesforce, Pipedrive and Clay integrations cannot subscribe to it. `data`: `exportRef`, `batchId` (use it to ignore duplicates), `batchNumber`, `batchCount`, `batchSize`, `leadCount` (this batch), `totalLeads` (the whole export), `blacklistedCount`, `source`, `databaseType`, `exportId`, `scheduleId` and `scheduleName` (only for `email_schedule`), `exportedAt`, `format`, `skipped` and `leads`; a sample batch also has `test: true`. Batches can arrive out of order: use `batchCount` to know when an export is complete. See [Exported leads (batched)](#exported-leads-batched) below.
* `email_schedule.sent` \- a scheduled lead email was sent. `data`: `scheduleId`, `scheduleName`, `leadCount`, `creditableLeadCount` and `sampleLeads` (up to 5 leads, each with `id`, `companyName`, `website`, `email`, `phone`, `city` and `state`).
* `credit.low_balance` \- sent after each credit charge while your total remaining credits (monthly, permanent and temporary) are at or below the larger of 50 and 10% of your monthly credit allowance, so it can arrive many times; accounts without a monthly allowance never get it. `data`: `remainingMonthly`, `remainingPermanent`, `remainingTemporary`, `remainingTotal`, `monthlyAllowance`, `threshold`.

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:

| Header          | Value                                                                                                   |
| --------------- | ------------------------------------------------------------------------------------------------------- |
| Content-Type    | application/json                                                                                        |
| User-Agent      | SMB-Sales-Boost-Webhooks/1.0                                                                            |
| X-SMB-Event     | The event type, for example lead.created                                                                |
| X-SMB-Timestamp | Unix seconds when this attempt was signed                                                               |
| X-SMB-Signature | t=<Unix seconds>,v1=<hex> (see [Verifying signatures](#verifying-signatures))                           |
| X-SMB-API-Key   | Only when the webhook has a header API key set for this destination URL (see [Safeguards](#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": "info@acmeplumbing.example",
    "phone": "(512) 555-0100",
    "city": "Austin",
    "state": "TX",
    "lastBuyingSignal": "2026-10-01T00:00:00.000Z",
    "buyingSignalType": ["Newly Added"]
  }
}
```

| Delivery                                                           | data                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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](/docs/api/integrations#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\_balance        | The fields listed under [Events](#events)                                                                                                                                                                                                                                                                                                                                                                                     |
| export.leads                                                       | One batch of leads (see [Exported leads (batched)](#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.

* Answer with a 2xx status within 8 seconds (35 seconds for an `export.leads` batch). Anything else is a failed attempt: a 3xx (redirects are not followed), a 4xx, a 5xx, a timeout or a connection error.
* A live webhook event is tried up to 5 times, waiting about 1, 2, 4 and 8 seconds (plus up to 1 second) between attempts. Every attempt sends the same body and `id` with a new `X-SMB-Timestamp` and signature, so use `id` to ignore duplicates. The delivery appears in `GET /api/v1/integrations/:id/deliveries` once its last attempt has finished.
* Tests, pushes, manual retries and deliveries to HubSpot, Salesforce and Pipedrive are one attempt each. A test's `id` starts with `evt_test_`; a manual retry sends a new `id` (`evt_retry_...`) with `occurredAt` set to the time of the retry.
* Each lead is its own request, except in `export.leads` batches (see [Exported leads (batched)](#exported-leads-batched) below, which also explains how batches are retried). Webhook deliveries are paced to about 90 per minute per integration and at most 5 run at once, so a large export reaches the webhook gradually and events can arrive in a slightly different order. Webhook deliveries still waiting are kept through server restarts and updates and continue a few minutes later. While an integration needs attention they are held, up to 7 days after the event, and continue once it is re-enabled, so fix the destination first. Deliveries to HubSpot, Salesforce and Pipedrive are sent at most 3 at a time per integration, and HubSpot calls are paced to HubSpot's rate limits, so a large export reaches the CRM gradually. Pausing an integration, or switching an event off, also stops the deliveries still waiting to be sent to it.

---

## 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);
```

---

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

* **What is sent:** every lead of each export that sends `export.completed`: a Leads-tab export, `POST /api/v1/leads/export` or MCP `export_leads`, a scheduled email that delivered leads, and a lead reservoir export. Leads you received before are included, so a batch can also update records you already have. Leads on your export blacklist are left out, and each batch is checked against your blacklist again when it is sent. Batches use no credits: the export itself was charged.
* **Batch size:** up to 100 leads per message by default. Set "Leads per batch" (10 to 100) on the integration's Events tab, or `config.batchSize` through the API. The batch size is fixed when the export is made; the format is read when each batch is sent.
* **Format:** Standard (the lead fields of `lead.created` plus `isNew`), Bigin by Zoho CRM (each lead as a Bigin Company with its Contact) or Zoho CRM (each lead as a record of the Zoho CRM Leads module). Set "Format" on the Events tab, or `config.format` (`standard`, `bigin` or `zoho_crm`).
* **Webhooks only:** HubSpot, Salesforce and Pipedrive integrations cannot subscribe to it, and neither can Clay: Clay adds one table row per message, so a whole batch would land in one row. Use `lead.created` for Clay.
* **With New lead exported:** if your flow imports the batches, switch "New lead exported" (`lead.created`) off on the same integration. Otherwise each new lead arrives twice, once on its own and once in a batch, and the batches wait behind the single leads. `lead.created` also covers leads from API searches, pushes and email schedules.
* **No single leads:** an integration whose only active event is `export.leads` never receives single leads. A push to it is refused (see [Push one lead](/docs/api/integrations#push-one-lead)), and its test sends a sample batch.

### The message

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

* `test`: `true`, only in a sample batch (see [Send a sample batch](#send-a-sample-batch)).
* `exportRef`: the same for every batch of one export, for example `exp_3f9a1c2b7d4e5f60`.
* `batchId`: `<exportRef>:<batchNumber>`, one per batch of the export. Use it to ignore a batch you already processed.
* `batchNumber` and `batchCount`: this is batch `batchNumber` (counting from 1) of `batchCount`.
* `batchSize`: the batch size the integration had when the export was made.
* `leadCount`: the leads in this batch. `totalLeads`: the leads of the whole export, across all its batches.
* `blacklistedCount`: leads of a scheduled email or lead reservoir export that were left out because they match your export blacklist (0 for Leads-tab and API exports, which leave them out of the export itself).
* `source` (`dashboard`, `api`, `email_schedule` or `reservoir`), `databaseType` (`other`), `exportId` (as in `export.completed`), `scheduleId` and `scheduleName` (only for `email_schedule`) and `exportedAt` (when the export was made).
* `format`: `standard`, `bigin` or `zoho_crm`.
* `skipped`: the leads of this batch that were left out, each `{"id": 123460, "reason": "removed"}`. `removed`: the lead was deleted from our database after the export, for example after a data removal request. `no_data`: the exported data of the lead is no longer available. `blacklisted`: the lead matches your export blacklist now. `too_large`: the message would have been larger than about 1.5 MB, so the largest leads were left out. A batch whose leads were all left out is not sent.
* `leads`: the leads, in the format chosen. In the Standard format a field without a value is `null`; the Bigin by Zoho CRM and Zoho CRM formats leave such fields out, so an update in Zoho never clears a field you filled in.

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](#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": "info@acmeplumbing.example",
        "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": "info@acmeplumbing.example",
    "Phone": "(512) 555-0100",
    "Mailing_Street": "1 Main St",
    "Mailing_City": "Austin",
    "Mailing_State": "TX",
    "Mailing_Zip": "78701",
    "Mailing_Country": "US",
    "Description": "Other emails: sales@acmeplumbing.example"
  }
}
```

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": "info@acmeplumbing.example",
  "Secondary_Email": "sales@acmeplumbing.example",
  "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

* Route on the `X-SMB-Event` header or the body's `type`, and ignore the events you do not handle: the same URL can receive the integration's other events.
* Process `data.leads` in one step: one function, one bulk request, or one loop inside a single step. A flow that runs once per lead, for example Zoho Flow with "Trigger flow for each entry" ticked, is billed per lead again, and batching saves nothing.
* Batches can arrive out of order (a Retry or a server restart can send a batch again later), and batches of other exports can arrive in between. Use `exportRef`, `batchNumber` and `batchCount` to know when every batch of an export has arrived.
* Use `data.batchId` to ignore a batch you already processed: the same batch can arrive again, for example after a timeout, a server restart or a Retry. Sample batches are the exception: `data.test` is `true`, every sample has a new `batchId`, and its leads are made up, so do not import them as real leads.
* Answer with a 2xx status within 35 seconds (15 seconds for a sample batch). If your processing takes longer, answer first and process afterwards.
* The batches of an export go to each URL one at a time, in batch order: the next batch is sent once your endpoint has accepted the one before. Each batch also takes its turn in the integration's usual pace of about 90 requests a minute, shared with its other events. A batch usually takes about 1-3 seconds, depending on how fast your endpoint answers, so a large export of 1,000 batches takes roughly 20-50 minutes to reach one URL.

### When a batch fails

* A batch is tried up to 5 times in a row, about 1, 2, 4 and 8 seconds apart. A timeout, or a 502 or 504 answer that took longer than 10 seconds, ends this series at once: your endpoint may still be working on the batch, and sending it again right away could bill you twice.
* When a series fails, the batch is tried again the same way after about 2 minutes, then 15 minutes, then 60 minutes. The export's later batches wait meanwhile and are not sent, so an endpoint that is down receives only that one batch, and the send stops about 1 hour 15 minutes after the batch first failed. Other exports and events of the integration are still sent in the meantime. Nothing appears in the deliveries while a batch waits.
* A 429 answer, or a 503 with a `Retry-After` header, is not counted as a failure: the batch waits as long as `Retry-After` asks (60 seconds when it gives no time, at most 15 minutes) and is tried again. A batch waits like this up to 12 times; one more such answer after that counts as a failed series, and the count of waits starts again.
* When the 4th series fails, or the 2nd series that ended in a timeout, sending this export to that URL stops. The deliveries show one failed row, for example "Stopped at batch 13 of 50 after repeated failures (last error: HTTP 500). Batches 13-50 (3,800 leads) were not sent. Retry this delivery to send them." It counts as one failed delivery toward `needs_attention`, and the export's remaining batches wait for a Retry. If Exported leads (batched) has two URLs on one integration, each URL has its own send: a stop or a Retry at one URL never affects the other.
* Pausing the integration, switching Exported leads (batched) off, or changing its destination so that the event has several URLs cancels the batches not sent yet, with one "Not sent" row per export and URL, for example "Not sent: batches 13-50 (3,800 leads) of this export were cancelled because the integration was paused. Re-enable it, then retry this delivery to send them." A batch that is being sent at that moment is included in that row; if your endpoint accepts it, it counts as sent and a Retry does not send it again. Exports made while the integration is paused are not sent to it.
* While the integration needs attention, its batches are held and continue on their own once it is re-enabled. Batches still held 7 days after the export are cancelled with one "Not sent" row.
* A batch that was being sent when our server restarted is sent again after the restart, about 10 minutes after that attempt began, with the same envelope `id` and `batchId`.

### Retry

* Retry (the Retry button in the Deliveries tab, `POST /api/v1/integrations/:id/deliveries/:deliveryId/retry` or MCP `retry_integration_delivery`) never sends a batch during the request. It queues the batches, which are then sent in the background like any other batch, and answers `{"status": "queued", "queuedBatches": 38, "queuedLeads": 3800}`. `queuedBatches` 0 means the batch is already waiting to be sent.
* Retry on a Stopped or Not sent row queues every batch of that export that was not sent to that URL. They are sent one at a time again, starting right away, even a batch that was still waiting for its next try. Retry on any other batch row queues that one batch again, with the same `batchId` and a new envelope `id`. A retried batch goes to the URL that Exported leads (batched) uses now on the integration, which can differ from the URL of the original delivery. Each batch is built again when it is sent, in the integration's current format, and leads deleted or blacklisted since the export are listed in `skipped`.
* The integration must be connected: on a paused or `needs_attention` integration, Retry answers 400 "Re-enable this integration first (PATCH status connected). Held batches then continue on their own, and you can retry this delivery to resume a stopped send." Exported leads (batched) must also be switched on, or Retry answers 400 "Exported leads (batched) is switched off for this integration. Switch it on again, then retry this delivery to send the batches."
* Stopped and Not sent rows of the last 30 days stay in `GET /api/v1/integrations/:id/deliveries` after the 50 newest deliveries (up to 10 of them), and their batches are kept for 30 days from that row, so a Retry of the row can send them during that time.
* In `GET .../deliveries`, each `export.leads` row also has `batchSummary`: `{"exportRef", "batchNumber", "batchCount", "leadCount", "skippedCount", "sendStopped", "notSent"}`. `sendStopped` marks a Stopped row and `notSent` a Not sent row. A delivery row keeps the lead ids of its batch, not the lead details.

### Recipes

* **Zoho Flow, for Zoho CRM or Bigin (BETA):** you need a paid Zoho Flow plan, because the batch is written by a custom function and Zoho Flow offers custom functions on paid plans only.  
  1. In Zoho Flow, create a flow with the Webhook trigger and payload format JSON. Copy its URL exactly as shown and keep it private: the zapikey in it works like a password.
  2. In SMB Sales Boost, add a Generic Webhook integration with that URL, switch on Exported leads (batched), set Format to Zoho CRM or Bigin by Zoho CRM, switch New lead exported off and save.
  3. Click Send sample batch, so the trigger receives a message.
  4. In the trigger's settings, do NOT tick "Trigger flow for each entry" (it would run the flow once per lead), and do not extract part of the payload.
  5. Add one Custom function step with the script for your CRM: the [Script for Bigin by Zoho CRM](#zoho-flow-script-bigin) if you use Bigin, or the [Script for Zoho CRM](#zoho-flow-script-zoho-crm) if you use Zoho CRM (both below). Map its input `payload` to the whole trigger (`${trigger}`).
  6. Switch the flow on and export a few leads. Our Deliveries tab shows success as soon as Zoho Flow accepts a batch; whether Zoho saved each record shows in the function's result in the flow's History.
* **Zapier:** a "Catch Hook" trigger (Webhooks by Zapier), then one "Code by Zapier" step that loops over `data.leads` and sends them to your tool's bulk API, or an action that accepts a list of line items. Zapier does not document whether Catch Hook splits a list nested inside the message, such as `data.leads`, so do not count on it running once per lead. Use the Standard format.
* **Make:** a "Custom webhook" trigger, an Iterator over `data.leads`, an Array aggregator that builds the records your tool's bulk endpoint expects, and one HTTP "Make a request" module. Modules between the Iterator and the aggregator run once per lead, so keep them to a minimum.
* **n8n:** a Webhook node, then Split Out on `data.leads` when the next node handles one item at a time (the items stay in the same execution), or one HTTP Request node that sends `data.leads` to a bulk endpoint.

### 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?**

* You use **Bigin by Zoho CRM**: copy the [Script for Bigin by Zoho CRM](#zoho-flow-script-bigin) and set the integration's Format to Bigin by Zoho CRM.
* You use **Zoho CRM**: copy the [Script for Zoho CRM](#zoho-flow-script-zoho-crm) and set the integration's Format to Zoho CRM.

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](#zoho-flow-script-bigin) 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](#zoho-flow-script-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 reference

* [API Documentation](/docs/api): every REST endpoint, authentication, rate limits and error codes
* [Integrations API](/docs/api/integrations): the `/api/v1/integrations` endpoints
* [MCP Server](/docs/api/mcp-server): connect an AI assistant, and the list of its tools
* [MCP Tools and Permissions](/docs/api/tools-and-permissions): the listing tool set for AI apps in app directories, and what each tool reads, changes or spends

---

**Ready to Find Your Next Customers?** Get access to growing local business leads updated every 5 minutes. [See plans starting at $49/mo →](/?utm%5Fsource=content&utm%5Fmedium=cta)