SMB Sales Boost REST API Documentation
Base URL: https://smbsalesboost.com/api/v1
Response Format: JSON (application/json)
Authentication: Bearer token (API key)
Table of Contents
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
Introduction
The SMB Sales Boost API provides RESTful access to our lead database, enrichments, export tools, filter management, AI features, credits and billing, and CRM and webhook integrations. The same API key also works with our MCP server. All API endpoints return JSON (file downloads aside) and, apart from the purchase endpoints, require authentication via Bearer token.
Quick Start
- Base URL
https://smbsalesboost.com/api/v1- Response Format
- JSON (
application/json) - Authentication
- Bearer token (API key)
- Protocol
- HTTPS only
Plan Requirement
API access is available for users on any paid plan (Starter, Growth, Scale, Platinum, or Enterprise). View plans to get started.
Programmatic Purchase
Purchase a subscription entirely via API - no web signup or onboarding required. This is ideal for AI agents and automated workflows that need programmatic access to SMB Sales Boost.
How It Works
(1) Call POST /api/v1/purchase with your email and desired plan, (2) complete checkout at the returned Stripe Checkout URL, (3) receive your API key by email at that address. The API never returns the key itself.
If that email already has an SMB Sales Boost account, the purchase must first be confirmed from the email sent to that address; the API key is emailed after that.
14-Day Free Trial
Starter, Growth and Scale start with a 14-day free trial by default. A payment method is required, nothing is charged until the trial ends, and the trial includes 50% of the plan's monthly credits; the plan price is then charged automatically unless the subscription is cancelled first. Send "trial": false to skip the trial and pay at checkout. Platinum and Enterprise never trial. One trial per person: the email is checked at purchase and the payment method after checkout. All charges are final; there are no refunds.
Step 1: Initiate Purchase
POST /api/v1/purchase- Create a new subscription purchase session. Returns
{ data: { checkoutUrl, expiresAt, plan, trial, message } }. The API key is not returned: it is emailed to the purchase address once checkout is complete. No authentication required. Rate limited to 5 requests per hour per IP.
Parameters: email (required, string), plan (required, string - "starter", "growth", "scale", "platinum", or "enterprise"), trial (optional, boolean, default true - only false skips the free trial and charges the plan price at checkout).
The trial object in the response reports eligible, days, credits, endsAt, note and, when there is no trial, a reason: not_requested, plan_not_eligible, trial_already_used or currently_subscribed.
Errors: 400 invalid_email or invalid_plan, 403 forbidden or disposable_email, 409 already_subscribed (also while that email is on a free trial; use change-plan instead), 410 plan_retired, 503 when payment processing is unavailable.
Step 2: Complete Checkout
Open the checkoutUrl in a browser to complete checkout on Stripe's hosted page. The session expires after 24 hours. After checkout, the system automatically provisions your account and generates an API key.
Step 3: Receive Your API Key by Email
Your API key (prefixed smbk_) is emailed to the purchase address once checkout is complete. Store it securely. If that address already has an SMB Sales Boost account, confirm the purchase from the email sent to that address first; the key follows.
POST /api/v1/claim-key is kept for backward compatibility only. It needs a claim token, which current purchases no longer issue, so wait for the emailed API key instead.
Account Setup (Highly Recommended)
Programmatic purchases skip the onboarding wizard. To get the most out of SMB Sales Boost, configure your account via the API before searching or exporting leads:
PATCH /api/v1/me- Set your company name and websitePOST /api/v1/ai/suggest-categories- Get AI-powered category suggestionsPOST /api/v1/ai/generate-keywords- Generate targeting keywords
Authentication
All API requests require a valid API key sent as a Bearer token in the Authorization header. Generate your API key from the Dashboard → API tab (available on all paid plans).
Example Request
curl -H "Authorization: Bearer smbk_your_api_key_here" \
https://smbsalesboost.com/api/v1/me
API Key Format
All API keys are prefixed with smbk_ for easy identification.
Security Best Practice
Never expose your API key in client-side code, public repositories, or browser requests. Store it securely as an environment variable.
Rate Limits
API requests are rate-limited to ensure fair usage. Rate limit information is included in response headers.
Rate Limit Tiers
- General Endpoints
- 600 requests per minute per API key
- Export Endpoints
- 5 requests per minute per API key
- AI Endpoints
- 5 requests per minute per API key
Integration endpoints (/api/v1/integrations) also share one budget per account of 60 requests per minute, counted together with the dashboard's Integrations tab and every API key of the account (REST and MCP). Starting a CRM connection is also limited to 30 per minute per account.
The MCP endpoint (POST /mcp) accepts up to 60 requests per minute per client IP address. Each MCP tool call also counts against the limits of the REST endpoints it calls.
Rate Limit Headers
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the window |
X-RateLimit-Remaining | Remaining requests in the current window |
X-RateLimit-Reset | When the window resets, as an ISO 8601 date-time |
Retry-After | Seconds to wait before retrying (only on 429) |
The X-RateLimit-* headers describe the most specific limit that applies to the request: the 5 per minute limit on export and AI endpoints, the per-account integration budget on integration endpoints (60 per minute, or 30 on CRM connect), and the 600 per minute limit on every other request made with an API key. On a rate-limit 429 (error Rate limit exceeded) they describe the limit that was hit. The 600 per minute limit still counts every request made with an API key. The purchase endpoints, which take no API key, have their own per-IP limits instead (see Programmatic Purchase).
429 Responses
A rate-limited request gets {"error": "Rate limit exceeded", "message": "...", "retryAfter": 42}, where retryAfter matches the Retry-After header. The lead search endpoints (GET /api/v1/leads, GET /api/v1/leads/preview, POST /api/v1/leads/export and the email schedule trigger) can also return 429 lead_query_capacity_busy with Retry-After: 10 when search capacity is busy. Both 429s carry the X-RateLimit-* headers, so tell them apart by the body's error field (Rate limit exceeded or lead_query_capacity_busy). Both are refused before anything is charged, so retry after the delay.
Long-Running Requests
Every request reaches the API through an edge network that gives our server about 100 seconds to start answering. Work that can take longer therefore runs as a background job, and your request never has to stay open while it runs. This applies to POST /api/v1/leads/export, POST /api/v1/email-schedules/:id/trigger, POST /api/v1/ai/suggest-categories and the three Lead Export History POST endpoints (refresh, refresh-and-export and re-export). Input errors (4xx) still answer at once.
- Send the request as usual. If the work finishes within about 75 seconds you get the normal answer, exactly as before. The answer always starts within about 90 seconds of the request's arrival.
- Otherwise the answer is 202 Accepted with a
jobId, aLocationheader and astatusUrl. The work keeps running. - Poll
GET /api/v1/jobs/{jobId}with the same API key (any key of the same account works) until it no longer answers 202. It then returns exactly what the original request would have returned: the same status code, headers and body, including an error answer.
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/3f2a9c1e5b7d4a608e1f2c3b4d5e6f70
Retry-After: 5
{
"jobId": "3f2a9c1e5b7d4a608e1f2c3b4d5e6f70",
"status": "running",
"kind": "api_leads_export",
"phase": "searching",
"progress": { "done": 2000, "total": 7000, "label": "Loading leads" },
"elapsedMs": 75012,
"statusUrl": "/api/v1/jobs/3f2a9c1e5b7d4a608e1f2c3b4d5e6f70",
"retryAfterSeconds": 5,
"message": "Still working in the background. GET statusUrl with the same API key until it no longer answers 202; it then returns exactly what this request would have returned (kept up to 60 minutes after the job finishes, 24 hours for a schedule send). Poll statusUrl instead of repeating this request: a repeat with the same Idempotency-Key header returns this same job, a repeat without one may run the work again."
}
curl -H "Authorization: Bearer smbk_your_api_key_here" \
-H "Prefer: wait=30" \
https://smbsalesboost.com/api/v1/jobs/3f2a9c1e5b7d4a608e1f2c3b4d5e6f70
Headers
| Header | Description |
|---|---|
Prefer (request) | wait=N (0-75 seconds, counted from when the request arrived) answers within N seconds: the finished result, or 202. respond-async answers 202 at once. Default: up to 75 seconds. Use a shorter wait when your HTTP client times out sooner. On GET /api/v1/jobs/{jobId}, wait=N (0-60) waits for the job before answering 202 again (default: answer at once). |
Idempotency-Key (request) | 8-64 letters, numbers, underscores or dashes, accepted by every endpoint above. Send one with every export and schedule trigger. Repeating the request with the same key never runs it again: it returns the running job, its stored answer, or 410 job_result_expired once that answer is no longer kept. The key stays with the job for up to about 25 hours after it finished (about 48 hours for a schedule trigger); a server restart forgets it. An answer of 409, 429 or 503, and a 402 insufficient_credits from an export, does not use up the key, so a retry with it runs the request. Every other answer comes back again for the same key: after an error answer, or when you change any argument, use a new key. The same key with a different request answers 409 idempotency_key_reused. Without a key, an identical request returns the job that is still running. |
Location, Retry-After | On a 202: where to poll, and how many seconds to wait before the next poll |
X-SMBSB-Job-Id, X-SMBSB-Job-Status | The job id is on every answer from a job. The status is sent on 202 answers (running) and on GET /api/v1/jobs/{jobId} answers (completed, failed or, on a 410, expired); a request that finished within the wait carries X-SMBSB-Job-Id only. |
Preference-Applied | Echoes the Prefer value that was honored |
How Long Results Are Kept
Results are normally kept 60 minutes after a job finishes (24 hours for a schedule trigger). They can be dropped sooner under heavy load, and each account keeps at most 500 finished jobs (the oldest go first). After that GET /api/v1/jobs/{jobId} answers 410 job_result_expired for about a day, with a message that says where to look, and then 404 job_not_found. Never repeat an export or a schedule trigger just because its answer is gone: check what it already did first. Export files stay in Export History for 90 days and the leads are in Lead Export History; a re-run with maxCredits 0 returns leads you already paid for at no charge, while a normal re-run picks new leads first and charges for them. For a schedule send, check lastSent in GET /api/v1/email-schedules. A server restart interrupts running jobs and forgets kept answers and keys: what they already saved stays saved, and leads you already received are never charged twice.
Limits
Each account can run up to 3 exports, 5 schedule triggers, 3 category suggestions and 2 of each Lead Export History request at once; more answer 429 job_capacity_busy with Retry-After: 30. The request itself still counts toward its endpoint's rate limit (a retry that returns a running job too), while polls count only toward the normal limit of 600 requests per minute per key (see Rate Limits).
MCP
Over MCP, the same work returns {status: "running", jobId} and the get_job tool fetches the result (see MCP Server). The full contract is in the OpenAPI spec (tag Background Jobs).
User Profile
Manage your user profile including subscription details and account settings.
Endpoints
GET /api/v1/me- Retrieve your user profile including subscription details and account settings.
PATCH /api/v1/me- Update your profile. Only the fields provided will be updated. Allowed fields: firstName, lastName, companyName, companyWebsite.
Response Fields
The user profile response includes: id, email, firstName, lastName, companyName, companyWebsite, smbType (always "other", the Main database), subscriptionPlan, subscriptionStatus, isSubscribed, freeLeadsUsed, onboardingCompleted, targetCategories, targetLocations, monthlyCredits, monthlyCreditsUsed, monthlyCreditsRemaining, permanentCredits, totalCreditsRemaining, and creditOverageRate.
Enrichments
Enrich a list of website URLs you already have. Each URL is checked against our database first; anything we do not already have is fetched live. We never add the URLs you submit, or the results we find for them, to our lead database, which we keep curated so it stays clean and focused on high-quality leads.
What each URL costs
- 1 credit
- A URL in our database that you have not bought before, including its buying signals and category enrichments.
- 0.1 credit
- A URL we do not have and fetch live for you.
- 0 credits
- A URL you have already exported, one we got no data for, or one without the contact details you asked for.
A URL found in our database is never fetched live, so lowering databaseMatchPercent cannot be used to buy database records at the live price.
Starting a run
POST /api/v1/enrichments with { "urls": [...] }, up to 100,000 URLs after deduplication by host. Send the full URL including any path - a deep link often carries better contact data than the root.
- maxCredits
- Most credits this run may spend. Omit to allow your whole available balance, which is the default.
- databaseMatchPercent
- 0-100, default 100. Caps what share of maxCredits may go to NEW database matches. Unused database budget flows to live fetching, never the reverse. Records you have already paid for are always returned free and are not affected by this.
- contactTypes
phone,emailoreither: the minimum contact details a result must carry to be delivered and charged.phonereturns every result with a phone number, including those that also have an email;emailreturns every result with an email, including those that also have a phone number. Defaulteither, meaning a phone number OR an email - the widest setting, not the most restrictive.- emailResults
- Email you a download link when the file is ready. Defaults to your account setting. The file is always downloadable, so there is no flag to disable the download.
Runs are asynchronous
Submitting returns a run id immediately and a large list can take hours. Poll GET /api/v1/enrichments/:id until status is completed or partial, then call GET /api/v1/enrichments/:id/download for a short-lived presigned URL.
Credits are charged in two stages - database matches when they are matched, live results when the file is published - so credits.final tells you whether the total is settled.
Every URL is accounted for
The file has one row per URL you submitted, each with a status column. Only the first two cost anything.
- matched_paid
- In our database, new to you. 1 credit.
- scraped
- Fetched live. 0.1 credit.
- matched_free
- In our database, you already bought it. Free.
- scrape_failed
- No data came back for it - unreachable, or nothing usable found. Free.
- skipped_db_budget
- A database match outside your credit limit. Free.
- skipped_scrape_budget
- A live result outside your credit limit. Free.
- matched_no_contact
- Matched, but without the contact type you asked for. Free.
- scraped_no_contact
- Fetched, but without the contact type you asked for. Free.
- skipped_invalid
- Not a usable URL. Free.
Leads
Search, filter, and export leads from our database. Supports keyword filtering, location filtering, industry filtering, and pagination.
Lead Database
Every lead comes from the Main database (API value other), which covers small and medium businesses of all types. Contact info includes both phone numbers and email addresses. The database parameter is optional. Every value is accepted and resolves to the Main database, so existing integrations keep working unchanged.
How Search Works: Two-Phase Query Model
Phase 1 - Include (OR Logic)
All positive keywords and include terms are combined with OR logic. A lead matches if any positive keyword matches in any of the OR group search columns.
- Default OR group columns
- Registered URL, Crawled URL, Company Name, Categories (AI Enrichment)
Phase 2 - Exclude (AND Logic)
Negative keywords are checked against the same columns as positive keywords. A lead is excluded if any negative keyword matches in any of those columns.
URL Space-to-Wildcard
For URL columns, spaces in search terms are automatically replaced with % wildcards. For example, "dental clinic" becomes %dental%clinic% to match URLs like example.com/dental-clinic.
AI Category Estimation
Leads in the Main database are progressively enriched by AI with estimated business type categories. Each lead may include an aiCategoryEstimation field containing an array of 1-3 category names, or null if not yet classified.
Endpoints
GET /api/v1/leads- Search and filter leads. Requires at least one positive filter (positiveKeywords, nameIncludeTerms, etc.). While another export, search or push of your account is still recording its charge, the search waits up to 30 seconds, then answers 429
charge_in_progress(Retry-After: 30) with nothing charged. GET /api/v1/leads/preview- Preview leads with masked contact info (masked, not hashed: for example
(512) ***-**42andhel*@domain.com). Accepts the same filters as GET /api/v1/leads, including the contact term filters (emailPrimaryInclude, phonePrimaryInclude and the rest) and the company-column include, exclude and empty filters. Does not consume credits. Leads you already purchased come back unmasked (contactExported: true); pass excludePurchased=true to return only not-yet-purchased leads. POST /api/v1/leads/export- Export filtered leads to CSV, JSON, or XLSX. Subject to export rate limits. Supports credit-optimized ordering via maxCredits and maxResults parameters. Pass excludePurchased=true to export only not-yet-purchased leads. Each file's
datais plain UTF-8 text for csv and json files and base64 for xlsx files. Pass inline=false (body field or ?inline=false) to omit each file's body and receive a per-file exportId plus downloadUrl instead - the recommended shape for large exports. XLSX is capped at 20,000 rows per file; for larger runs use a csv export format, or an xlsx format with splitFiles true, splitMode max_rows, maxRowsPerFile of 20,000 or less and includeTotal false. Any xlsx export that would put more than 20,000 rows in one file (a single file, a split part, or the includeTotal file, which holds every lead) returns 400 export_too_large after the leads are charged and recorded as received. Do not simply repeat it: an export picks new leads first, so a repeat can charge for different leads. Re-run it with maxCredits 0 (and without excludePurchased) and a fixed format, which returns the leads you already have at no cost, or re-export them with POST /api/v1/lead-export-history/re-export. An export that is not finished within about 75 seconds answers 202 Accepted with a jobId and keeps running: the charge, your lead export history and the Export History files are written whether or not you collect the response. PollGET /api/v1/jobs/{jobId}for the full response, kept for up to 60 minutes after the export finishes (then 410job_result_expiredfor about a day, then 404job_not_found; the files stay in Export History for 90 days). Send anIdempotency-Keyheader with each export: repeating it with the same key returns the same job instead of exporting again. To get the result of a running export poll the job, never send the export again: a new export after this one finished (or with other arguments) picks new leads first and charges for them; an identical request while it runs returns the same job. If the answer is lost, check Export History first; a re-run with maxCredits 0 returns leads you already paid for at no charge. A 402insufficient_creditsdoes not use up the key: buy credits, then retry. A 429charge_in_progress(Retry-After: 30) means another export, lead search or push of your account was still recording its charge; nothing was charged. See Long-Running Requests. GET /api/v1/leads/other/schema-types- Returns a sorted list of all distinct website schema types found in the Main database. Use these values with the websiteSchemaFilter parameter on GET /api/v1/leads. The list is kept up to date in the background, so this normally answers at once; right after a server restart it waits up to 55 seconds (
Prefer: wait=N, counted from the request's arrival, sets a shorter wait) and can then answer 503schema_types_warmingwith Retry-After: 60 while the list is rebuilt.
Key Parameters
positiveKeywords (required), negativeKeywords, orColumns, stateInclude, stateExclude, cityInclude, cityExclude, zipInclude, zipExclude, streetInclude, streetExclude, minNumLocations, maxNumLocations, minRatingValue, maxRatingValue, minReviewCount, maxReviewCount (numeric ranges), buyingSignalTypeFilter, lastBuyingSignalFrom, lastBuyingSignalTo, sortBy, sortOrder, page, limit, database, and various column-specific include/exclude terms. Empty/non-empty filters per column are also supported via streetEmptyFilter, cityEmptyFilter, stateEmptyFilter, zipEmptyFilter plus phonePrimaryEmptyFilter, phoneTotalEmptyFilter, emailPrimaryEmptyFilter, emailTier1EmptyFilter through emailTier6EmptyFilter, descriptionEmptyFilter, and the new firmographic and technology columns such as employeeCountEmptyFilter, foundingDateEmptyFilter, isChainEmptyFilter, priceRangeEmptyFilter, legalNameEmptyFilter, industryNaicsEmptyFilter, dunsEmptyFilter, founderEmptyFilter, founderEmailEmptyFilter, founderPhoneEmptyFilter, openingHoursEmptyFilter, areaServedEmptyFilter, offersEmptyFilter, brandEmptyFilter, sloganEmptyFilter, logoUrlEmptyFilter, socialProfilesEmptyFilter, platformEmptyFilter, bookingOpsCrmSoftwareEmptyFilter, reputationChatSoftwareEmptyFilter, marketingEmailSoftwareEmptyFilter, adPixelsEmptyFilter, paymentFinancingSoftwareEmptyFilter, emailPrimaryMxEmptyFilter, emailDomainMxEmptyFilter, emailSecurityEmptyFilter, addressSourceEmptyFilter. The firmographic and technology columns from employeeCount through paymentFinancingSoftware also support include/exclude term filters (e.g. employeeCountInclude/employeeCountExclude, founderEmailInclude/founderEmailExclude); founder email and phone values stay masked until a lead is unlocked or exported. The Email Primary Provider, Website Email Provider, Website Email Security, and Address Source columns have no include/exclude terms; they use exact-value multi-choice matching via emailPrimaryMxValues, emailDomainMxValues, emailSecurityValues, and addressSourceValues - each a JSON array of exact values matched with IN semantics. Number of Job Postings uses minOpenPositions/maxOpenPositions, and the short description uses descriptionIncludeTerms/descriptionExcludeTerms. Each empty filter accepts 'any' | 'only_empty' | 'exclude_empty'.
Buying Signal Type Filtering
Every lead carries a Buying Signal Type list describing why its Last Buying Signal timestamp last changed. Filter on it with buyingSignalTypeFilter (comma-separated values). Values: Newly Added, Phone Primary, Total Phones, Email Primary, Tier 1 Emails, Tier 2 Emails, Address Full, plus per-source social profile signals - Instagram, LinkedIn, Facebook, YouTube, TikTok, X (Twitter), Google Maps, Yelp, Pinterest, and Other Social - recorded when a social profile for that source is added or changed for a business. Social profile changes also bump the lead's Last Buying Signal timestamp, so recently-active businesses surface under lastBuyingSignalFrom / lastBuyingSignalTo date filters. Last Buying Signal is also the default sort (sortBy=lastBuyingSignal).
The former names updateReasonFilter, lastUpdatedFrom, lastUpdatedTo and sortBy=lastUpdated are still accepted (ignored when the new name is also sent), and lead responses still include a deprecated Last Updated key with the same value as Last Buying Signal. Default-format export files (POST /api/v1/leads/export, Export History downloads, email schedule attachments) name these columns Last Buying Signal and Buying Signal Type in their CSV/XLSX headers and JSON keys; they no longer have Last Updated / Update Reason columns. To keep the old header names, export with a custom export format (formatId) that maps lastUpdated and lastUpdatedReason to the names you need.
Credit-Optimized Export Parameters
The export endpoint supports additional parameters for controlling credit usage:
maxLeads- Cap total leads exported. Leads over the cap, and new leads cut because credits ran short, are counted in the response's overflowCount and kept in the lead reservoir while it has room (one pool per account, shared with your email schedules, up to your plan's monthly credit allowance; leads that do not fit are not kept), then added to your next export that also sets maxLeads.
maxResults- Cap total results returned (both new and previously-exported leads). New leads are prioritized first, then previously-exported leads fill remaining slots.
maxCredits- Cap credits spent on this export. Only new (credit-consuming) leads count toward this cap. The export is also capped at your spendable balance, so the effective cap is the smaller of the two. Set to 0 to only receive previously-exported leads at no credit cost.
excludePurchased- When true, export only leads you have not purchased yet (already-exported leads, matched by lead id or shared contact point, are skipped). Credit and dedup math are unchanged. Default false.
On credit plans, GET /api/v1/leads and POST /api/v1/leads/export always return leads in credit-optimized order: new leads (credit-consuming) sorted first, then previously-exported leads, each group sorted by Last Buying Signal descending (or your custom sort). New leads are cut to the smaller of maxCredits and your spendable balance (credit balance plus any overage-budget headroom); when the balance was the limit the response has the X-Credits-Capped: true header, so omitting maxCredits caps the request at your balance instead of failing. 402 insufficient_credits only happens if a concurrent request spends the balance between the check and the charge.
What Costs a Credit
Each new lead returned by GET /api/v1/leads or POST /api/v1/leads/export costs 1 credit. Charging is sticky: a lead you have already received through any channel (dashboard, API, MCP or email schedule) is never charged again, even if its data changed later, and a new lead that shares a primary phone or primary email with a lead you already received is also free.
Purchased-Lead Reveal on Preview
The preview endpoint reveals leads you have already purchased: any preview lead that matches a prior export (by lead id or by a shared phone or email contact point) is returned unmasked with contactExported: true, the same as on the dashboard. Leads you have not bought stay masked. Pass excludePurchased=true on preview to return only not-yet-purchased leads. A shared contact point here includes the lead's Total Phones and Email Tier values, while charging compares primary phone and primary email only, so a lead revealed only through one of those lists can still cost a credit.
Filter Presets
Save and manage reusable filter configurations. Create, update, delete, and list your saved filter presets for quick access to your most-used search criteria.
Endpoints
GET /api/v1/filter-presets- List all your saved filter presets.
POST /api/v1/filter-presets- Create a new filter preset. Requires a name and filters object.
DELETE /api/v1/filter-presets/:id- Delete a filter preset by ID.
Preset Object
Each preset includes: id, name, filters (containing positiveKeywords, stateInclude, etc.), and createdAt timestamp.
Keyword Lists
Manage keyword lists for targeted lead searching. Create lists of include and exclude keywords to refine your lead searches and automate filtering.
Endpoints
GET /api/v1/keyword-lists- List all your keyword lists.
POST /api/v1/keyword-lists- Create a new keyword list. Requires name (trimmed, 1-255 characters), type (positive/negative) and keywords (a non-empty array of strings); sourceCategories is optional (an array of strings, or null). Any other field is ignored, and invalid input returns 400 validation_error.
PUT /api/v1/keyword-lists/:id- Update an existing keyword list.
DELETE /api/v1/keyword-lists/:id- Delete a keyword list by ID.
Keyword List Object
Each list includes: id, name, type (positive or negative), keywords array, and createdAt timestamp.
Email Schedules
Set up automated email delivery of leads matching your saved filters. Configure delivery frequency, filter presets, export format preferences, and multi-recipient splitting.
Endpoints
GET /api/v1/email-schedules- List all your email export schedules.
POST /api/v1/email-schedules- Create a new email schedule. Requires name, filterPresetId, intervalValue (a whole number from 1 to 720), intervalUnit and recipients. isActive defaults to true: an active schedule sends its first email within about 15 minutes; send false to create it paused.
PATCH /api/v1/email-schedules/:id- Update an existing email schedule. All fields are optional; intervalValue must be a whole number from 1 to 720.
DELETE /api/v1/email-schedules/:id- Delete an email schedule by ID.
POST /api/v1/email-schedules/:id/trigger- Manually trigger an active email schedule to send immediately. A send that is not finished within about 75 seconds answers 202 Accepted with a jobId and keeps running;
GET /api/v1/jobs/{jobId}then returns this response (kept for up to 24 hours). Send anIdempotency-Keyheader: repeating the request with the same key returns the same send instead of sending again (the key is kept with the job for up to about 48 hours; a server restart forgets it, so then check lastSent first), and triggering the same schedule while a trigger of it is still running returns that job. A 409schedule_run_in_progressmeans the schedule's regular run is sending it right now: nothing was sent, so check lastSent when it finishes and trigger it again only if needed.
Schedule Object Fields
Each schedule includes: id, name, filterPresetId, exportFormatId, intervalValue, intervalUnit, isActive, pauseReason (null, "user", or "insufficient_credits"), recipients (array of objects with email and optional splitParts), distributionMode, fullCopyRecipients, maxLeadsPerEmail, lastSent, totalSentCount, createdAt.
Max Leads Per Email
maxLeadsPerEmail (optional, 1-100,000, null for no cap) limits how many leads one email carries. New leads that do not fit, and new leads your credits could not cover, are kept in the lead reservoir (one pool per account, shared by your schedules and API exports, up to your plan's monthly credit allowance; when it is full, more leads are not kept) and sent with a later email. On credit plans, leads you already received that do not fit are not kept. Schedules pause only when no credits are left to spend.
Combined File Feature
When file splitting is enabled, you can configure a combined file that includes an assignee column and file name column, sent to dedicated combined recipients.
Export Formats
Customize which columns are included in your lead exports. Create and manage export format templates for consistent data delivery. Supports CSV, JSON, and XLSX file types.
Endpoints
GET /api/v1/export-formats- List all your export format templates.
GET /api/v1/export-formats/:id- Get a specific export format by ID.
POST /api/v1/export-formats- Create a new export format with field mappings.
PATCH /api/v1/export-formats/:id- Update an existing export format.
DELETE /api/v1/export-formats/:id- Delete an export format by ID.
POST /api/v1/export-formats/:id/set-default- Set an export format as the default.
Export Format Fields
Each format includes: id, name, fileType, startRow, fieldMappings, isDefault, splitFiles, splitMode, maxRowsPerFile, numberOfParts, includeTotal, includeHeaders, databaseType (always "other", the Main database), combinedAssigneeColumnName, combinedFileNameColumnName, createdAt.
File Splitting Options
max_rows- Split by max rows per file (requires maxRowsPerFile, min 10)
equal_parts- Split into equal parts (requires numberOfParts, min 2)
parts_max_rows- Number of parts with max rows each (requires both)
XLSX files hold at most 20,000 rows each. An export that would put more than 20,000 rows in one xlsx file (a single file, a split part, or the includeTotal file, which holds every lead) returns 400 export_too_large, so an xlsx format for large exports needs splitFiles true, splitMode max_rows, maxRowsPerFile of 20,000 or less and includeTotal false.
Export History
View your past exports with details including date, row count, filters used, and export format. Each export file is kept for 90 days and can be downloaded again at any time in that window. After that, re-export the leads from your lead-level export history at no credit cost.
Endpoints
GET /api/v1/export-history- List your export history with file details including fileName, fileType, leadCount, fileSize, formatName, databaseType, createdAt, expiresAt (when the file stops being downloadable), downloadAvailable, and unavailableReason.
GET /api/v1/export-history/:id/download- Download a previously exported file by its history ID. Returns the raw file. Files can be downloaded for 90 days after the export; an expired or unavailable file returns 410.
GET /api/v1/export-history/:id/download-url- Get a short-lived direct download link for the file (valid for 15 minutes). Fetch the link without an Authorization header.
Lead Export History
Lead Export History has one entry per lead you have received, through any channel, with a snapshot of the lead's data from its most recent export or refresh. Refreshing and re-exporting these leads is always free: no credits are charged for leads you already received, even if their data has changed since.
Sticky export rule: once a lead is in your export history, it is free to re-export or refresh at any time. Credits are charged only for leads that are new to you (see Credits & Subscription).
Endpoints
GET /api/v1/lead-export-history- List your entries, newest export first, with pagination and optional filters. Each entry is one lead, with the time and channel of its most recent export and its stored snapshot (fields below). An invalid limit, offset or date returns 400.
GET /api/v1/lead-export-history/ids- Every tracking id matching the filters (databaseType, dateFrom, dateTo, exportMethod), newest first, with no pagination:
{"data": [1001, 1002], "totalCount": 2}. Use it to select entries for a bulk refresh or re-export. POST /api/v1/lead-export-history/refresh- Replace the stored snapshots with the latest live data. No credits charged. Send
trackingIdsor afiltersobject (databaseType, dateFrom, dateTo, exportMethod) to refresh every matching entry, at most 5,000 entries per request (afiltersobject that matches more than 5,000 entries, or none, returns 400). An invalidfiltersvalue also returns 400bad_request: adateFromordateTothat is not a valid date with a year from 1 to 9999 (for example 'filters.dateFrom must be a valid date (e.g. 2026-08-18)'), or adatabaseTypeorexportMethodthat is not a string. When atrackingIdsarray is sent, the filters are ignored and not checked. Returns{"data": {"results": [...], "refreshed": 2}}. In each result,changedsays whether the lead was updated since its snapshot (by its last-updated date),changedFieldslists the changed fields (kept until a refresh-and-export clears them), andrefreshedis false for an id that is not in your history or a lead that is no longer in the database. A refresh that finds newer data for a lead sendslead.updatedto your integrations, unless the lead is on your export blacklist. Repeated tracking ids are refreshed once. POST /api/v1/lead-export-history/re-export- Download the selected entries again as one file built from their stored snapshots (live data only for an entry that has no snapshot; nothing is refreshed). No credits charged.
trackingIdsis required (up to 5,000);formatIdis optional (one of your export formats; omitted or not found means a CSV with the default columns). Returns the file itself, not a JSON envelope. A file that is not ready within about 75 seconds comes with a 202 Accepted and a jobId instead; pick it up withGET /api/v1/jobs/{jobId}, where it is kept for up to 60 minutes after it is ready (it is never saved to Export History; after that, run the free re-export again). POST /api/v1/lead-export-history/refresh-and-export- Refresh the snapshots with live data, clear their changed-field markers, then download them as one file. No credits charged. Send
trackingIdsor afiltersobject (at most 5,000 entries, with the same 400 for an invalidfiltersvalue, as for refresh), plus an optionalformatId(as for re-export). Everything is checked before any entry changes: a 400 or 404 response leaves the entries unchanged (snapshots,leadLastUpdatedandlastChangedFields). Like refresh, it sendslead.updatedto your integrations for each lead it finds newer data for, unless the lead is on your export blacklist. Returns a file like re-export; its name starts withrefreshed-export-. A file that is not ready within about 75 seconds comes with a 202 Accepted and a jobId instead; pick it up withGET /api/v1/jobs/{jobId}, where it is kept for up to 60 minutes after it is ready (it is never saved to Export History). The snapshots are refreshed when the job finishes; the changed-field markers are cleared only when the file is delivered to you, so a file you never collect leaves them set. After the 60 minutes, run this request again (it is free) or get the same rows free with re-export.
List Parameters
| Parameter | Description |
|---|---|
limit | Number of entries per page (1-200, default 50) |
offset | Pagination offset (default 0) |
databaseType | Filter by database: 'other' (the Main database). Any value is accepted and resolves to the Main database. Omit to list every export. |
dateFrom | Only entries whose most recent export is on or after this date (YYYY-MM-DD) |
dateTo | Only entries whose most recent export is on or before this date (YYYY-MM-DD; the whole day counts) |
exportMethod | Filter by the channel of each lead's most recent export: 'dashboard', 'api' (REST API and MCP), 'email_schedule', 'crm_push' (pushed to an integration from the dashboard, with POST /api/v1/integrations/:id/push-lead or with the MCP tool push_lead_to_integration) or 'enrichment' (a database match paid for in an enrichment run) |
Example Response
{
"data": [
{
"id": 1001,
"userId": "usr_abc123",
"leadId": 42,
"databaseType": "other",
"exportedAt": "2026-05-01T10:00:00.000Z",
"leadLastUpdated": "2026-04-28T08:00:00.000Z",
"exportMethod": "crm_push",
"leadSnapshot": {
"wCompanyName": "Acme Roofing",
"wPhonePrimary": "(555) 123-4567",
"wEmailPrimary": "[email protected]"
},
"lastChangedFields": null,
"lead": {
"wCompanyName": "Acme Roofing",
"wPhonePrimary": "(555) 123-4567",
"wEmailPrimary": "[email protected]"
}
}
],
"totalCount": 245,
"limit": 50,
"offset": 0
}
Entry Fields
id- the tracking id that the ids, refresh, re-export and refresh-and-export endpoints useuserId,leadId,databaseType- your account, the lead and its databaseexportedAt,exportMethod- when and through which channel the lead was most recently exportedleadLastUpdated- the lead's last-updated date as of the snapshot (refresh compares it to spot changes)leadSnapshot- the lead's data as stored at its most recent export or refresh (can be null on older entries); it uses the database field names (for example wCompanyName), not the names GET /api/v1/leads returns, and is shortened in the examplelead- the snapshot, or the live lead data when no snapshot is stored (null if neither exists)lastChangedFields- the lead fields a refresh found changed, or null
Notes
- One entry per lead: a later export of the same lead (including an integration push) updates its exportedAt, exportMethod and snapshot instead of adding an entry.
- Re-export and refresh-and-export return 404 when none of the ids match an entry with lead data, and 400 when some selected entries come from an older export that cannot be combined with the rest (export those separately).
- Re-export sends nothing to your integrations. Refresh and refresh-and-export send one
lead.updatedfor each lead they find newer data for, to your integrations subscribed to it, unless the lead is on your export blacklist (see Integrations). - The three POST endpoints count against the export rate limit (5 per minute per API key; see Rate Limits).
- The three POST endpoints run as background jobs: when the work is not finished within about 75 seconds they answer 202 Accepted with a jobId, and
GET /api/v1/jobs/{jobId}then returns exactly the response they would have sent (see Long-Running Requests). Files that finish after a 202 are kept for pickup for up to 60 minutes and are never saved to Export History.
Account Settings
Every account uses the Main database (API value other). The database settings endpoint is kept for backward compatibility with existing integrations.
Endpoints
GET /api/v1/settings/database- Get the lead database your account uses. currentDatabase is always "other" (the Main database).
Response Fields
currentDatabase (always "other"), canSwitch (boolean), daysRemaining (number), lastSwitchedAt (ISO timestamp or null).
AI Features
Access AI-powered tools including keyword suggestions, category recommendations, and website analysis. Leverage machine learning to optimize your prospecting strategy.
Rate limit: AI endpoints are limited to 5 requests per minute.
Endpoints
POST /api/v1/ai/suggest-categories- Get AI-powered category suggestions based on your business type and target market. Requires companyName, companyDescription, and productService. Nothing is charged. When the suggestions are not ready within about 75 seconds (a website analysis can take a while) the answer is 202 Accepted with a jobId;
GET /api/v1/jobs/{jobId}then returns this response (normally kept 60 minutes after it finishes; after that, run the request again for free). POST /api/v1/ai/generate-keywords- Trigger AI keyword generation based on your account's target categories and business profile. This is an asynchronous operation - keywords will be generated in the background and saved to your keyword lists.
Category Suggestion Parameters
Required: companyName, companyDescription, productService. Optional: companyWebsite, excludeCategories. Suggestions are drawn from the Main database's categories.
Export Blacklist
Manage your export blacklist to keep specific domains out of your exports and out of integration pushes: pushing a blacklisted database lead (POST /api/v1/integrations/:id/push-lead with a leadId, or the MCP tool push_lead_to_integration) returns 400 before anything is charged or sent, and a Lead Export History refresh sends no lead.updated for a blacklisted lead. Your own data pushed with {"lead": {...}} is not checked. Add and remove entries to prevent duplicate outreach.
Endpoints
GET /api/v1/export-blacklist- List all your blacklist entries with entryType (domain), value, and createdAt.
POST /api/v1/export-blacklist- Add entries to your export blacklist. Requires an entries array with entryType and value for each entry.
DELETE /api/v1/export-blacklist/:id- Remove a blacklist entry by ID.
Entry Types
domain- Block all leads from a specific domain (e.g., competitor.com)
Error Handling
All errors follow a consistent format with an error code and a human-readable message. Validation errors (400 validation_error, or 400 invalid_request from POST /api/v1/enrichments) can also include a details array with one entry per problem, each with a path (the field, as an array of keys such as ["status"]) and a message.
Error Response Format
{
"error": "bad_request",
"message": "No valid fields provided. Allowed: firstName, lastName, companyName, companyWebsite"
}
HTTP Status Codes
| Status | Meaning |
|---|---|
| 200 | OK - Request succeeded |
| 201 | Created - Resource created successfully |
| 202 | Accepted - a long-running request is still running in the background: poll GET /api/v1/jobs/{jobId} (see Long-Running Requests), which then returns exactly the original response |
| 400 | Bad Request - Invalid parameters or missing required fields |
| 401 | Unauthorized - Invalid or missing API key |
| 402 | Payment Required - insufficient_credits: lead searches and exports are capped at your spendable balance, so they return it only when a concurrent request spent the balance first; POST /api/v1/integrations/:id/push-lead returns it whenever a lead that is new to you cannot be paid from your balance or overage budget. Also a declined card on a purchase |
| 403 | Forbidden - API access not available on your plan |
| 404 | Not Found - Resource does not exist (job_not_found on GET /api/v1/jobs/{jobId}: an unknown id, a job that finished more than about a day ago, or a job a server restart interrupted; check what it already did before running anything again) |
| 409 | Conflict - the request conflicts with the current state (for example integration_exists, integration_limit, conflict when an integration changed while your update was being applied, or a plan change still in progress). On long-running requests also idempotency_key_reused (the Idempotency-Key was already used with a different request) and, on the email schedule trigger, schedule_run_in_progress (its regular run is sending it right now; nothing was sent) |
| 429 | Too Many Requests - Rate limit exceeded, or lead_query_capacity_busy (tell them apart by the body's error field; both carry the X-RateLimit-* headers); retry after the Retry-After header. Long-running requests can also answer job_capacity_busy (too many running at once, Retry-After: 30), and GET /api/v1/leads and POST /api/v1/leads/export charge_in_progress (another export, search or push of your account is still recording its charge, Retry-After: 30); nothing was charged or sent |
| 410 | Gone - job_result_expired: the long-running request finished, but its answer is no longer kept (answered for about a day, then 404 job_not_found); the message says where to look, so do not simply repeat the request |
| 500 | Internal Server Error - Something went wrong on our end (job_timeout when a long-running request ran past its time limit) |
| 502 | Bad Gateway - provider_error: a connected CRM's API failed (integration field and pipeline endpoints) |
| 503 | Service Unavailable - temporary; retry after the Retry-After header (except provider_unavailable on integration endpoints, which has no Retry-After and is not temporary). Includes schema_types_warming on GET /api/v1/leads/other/schema-types right after a server restart (Retry-After: 60) |
Long-Running Requests
Exports, schedule triggers, category suggestions and the three Lead Export History POST endpoints can answer 202 Accepted with a jobId and finish in the background. See Long-Running Requests for polling GET /api/v1/jobs/{jobId}, Idempotency-Key retries, limits and how long results are kept.
Credits & Subscription
All paid plans (Starter, Growth, Scale, Platinum, and Enterprise) use a credit-based system where each new lead queried or exported costs 1 credit. Charging is sticky: a lead you have already received through any channel (dashboard, API, MCP or email schedule) is never charged again, even if its data changed later, and a new lead that shares a primary phone or primary email with a lead you already received is also free. Lead searches and exports are always capped at your spendable balance (credit balance plus any overage-budget headroom) instead of failing, with the X-Credits-Capped: true header when the balance was the limit; for them, 402 insufficient_credits only happens if a concurrent request spends the balance between the check and the charge. Pushing a database lead to an integration (POST /api/v1/integrations/:id/push-lead with a leadId, or the MCP tool push_lead_to_integration) is not capped; the same sticky rules apply. A lead you already received is free, even at a 0 balance. A new lead costs 1 credit, taken from your balance or, when the balance is 0 and your overage budget is enabled with room left, billed as overage; if neither can cover it, the push returns 402 insufficient_credits and nothing is pushed. A pushed lead counts as received (exportMethod crm_push in Lead Export History), so it is never charged again. On all plans, unused credits roll over each billing cycle and never expire while your subscription is active - but the remaining balance is forfeited when your subscription ends, and expires when a plan downgrade takes effect at renewal. Use these endpoints to purchase additional credits or manage your subscription.
Credit balance: Your current credit balance is always available via GET /api/v1/me - see the monthlyCredits, monthlyCreditsRemaining, permanentCredits, and totalCreditsRemaining fields.
Endpoints
POST /api/v1/purchase-credits- Purchase additional credits using your saved payment method (charged off-session via Stripe). Purchased credits roll over month to month while your subscription is active, but the remaining balance is forfeited when your subscription ends and expires when a plan downgrade takes effect at renewal. Minimum 100 credits. Pricing: Starter 10¢/credit, Growth 7.5¢/credit, Scale 5¢/credit, Platinum 3¢/credit, Enterprise 2¢/credit. Available on all paid plans.
GET /api/v1/overage-budget- Get your overage budget configuration and usage: an opt-in spending limit that lets lead usage continue past a zero credit balance. Extra credits accrue at your plan's overage rate and are charged to your card on file once per day. The budget applies per billing cycle and resets at renewal.
PATCH /api/v1/overage-budget- Enable, disable, or edit the overage budget. budgetType is 'dollars' (default) or 'credits'; budgetAmount is a whole number of dollars or credits per billing cycle (max 5x your plan's monthly credit allocation, or its dollar equivalent). Requires an active credit plan and a payment method on file. Disabling clears the config; accrued unbilled overage is still collected by the daily billing runs once it reaches Stripe's $0.50 minimum (smaller remaining balances are written off).
POST /api/v1/subscription/cancel- Cancel your subscription at the end of the current billing period. Your access continues until the period ends. Any remaining credit balance is forfeited when the subscription ends.
Purchase Credits Parameters
creditCount (integer, minimum 100) or dollarAmount (number, minimum 1) - send one, not both. idempotencyKey (optional, 8-64 letters, digits, _ or -; also accepted as the Idempotency-Key header) - reuse it when retrying the same purchase so the card is not charged twice.
MCP Server
SMB Sales Boost provides a remote Model Context Protocol (MCP) server that lets AI assistants and LLMs interact with your account directly. The REST API endpoints are available as MCP tools (pushing your own lead data to an integration is REST only), enabling AI-powered lead search, export, account management and integration workflows such as connecting a CRM (when available), creating webhooks and sending leads to them.
What is MCP?
The Model Context Protocol is an open standard that enables AI models to securely access tools and data sources. With our MCP server, you can connect Claude, ChatGPT, or any MCP-compatible AI assistant to search, filter, and export leads, and to send them to your CRM or webhooks, through natural language.
Connection Details
- Endpoint
https://smbsalesboost.com/mcp- Transport
- Streamable HTTP (stateless)
- Method
- POST
- Auth
- Bearer token (your API key) in Authorization header
Client Configuration
Add this to your MCP client configuration (e.g., Claude Desktop, Cursor, or any MCP-compatible tool):
{
"mcpServers": {
"smb-sales-boost": {
"type": "streamable-http",
"url": "https://smbsalesboost.com/mcp",
"headers": {
"Authorization": "Bearer smbk_your_api_key_here"
}
}
}
}
Available Tools (72)
The AI assistant can call these tools on your behalf using natural language. Key tools include: get_profile, update_profile, search_leads, export_leads, list_filter_presets, create_filter_preset, list_keyword_lists, create_keyword_list, list_email_schedules, create_email_schedule, list_export_formats, create_export_format, list_export_history, ai_suggest_categories, ai_generate_keywords, list_export_blacklist, purchase_credits, get_auto_top_up, update_auto_top_up, get_overage_budget, update_overage_budget, and more.
A tool whose work is still running after about 40 seconds returns {status: "running", jobId}; call get_job with that jobId (each call waits up to 40 seconds) until it returns the result, exactly as the original tool would have returned it. Over the REST API the same work answers 202 Accepted and is followed with GET /api/v1/jobs/{jobId} (see Long-Running Requests). Results are normally kept 60 minutes after the job finishes (24 hours for a schedule send) and can expire sooner under heavy load or after a server restart; after that get_job reports job_result_expired for about a day, then job_not_found. Never repeat an export or a send then: check list_export_history / list_lead_export_history or list_email_schedules first (export files stay in Export History for 90 days). To get the result of a running export call get_job, never export_leads again: a new export_leads call after this one finished (or with other arguments) picks new leads first and charges for them; an identical call while it runs returns the same job. export_leads and trigger_email_schedule take an optional idempotencyKey (8-64 letters, numbers, underscores or dashes): reuse the same key only to repeat the identical call after a timeout, a lost connection or a {status: "running"} answer, and the server returns the same job instead of exporting or sending again (a schedule send's key is kept with the job for up to about 48 hours; a server restart forgets it). After an error answer (for example export_too_large) or when you change any argument, use a new key; a busy or in-progress answer (409, 429, 503) and an insufficient_credits 402 do not use up the key.
Integration tools (17):
list_integration_providers- List integration providers (CRMs and webhook tools) and whether each is availablelist_integrations- List your integrations with their events and who created or last changed themget_integration- Get one integrationcreate_webhook_integration- Create a webhook (Zapier, n8n, Make, Pipedream, Clay or any public HTTPS URL) that receives the events you subscribe it to; returns a signing secret once and emails the account ownerupdate_integration- Rename, pause or re-enable an integration, or change its event subscriptions (the list you send replaces the current one), destination URLs, header API key or Pipedrive deal settings; a HubSpot, Salesforce or Pipedrive integration takes only lead.created and lead.updated. Re-enabling it, adding or re-activating an event, or changing where data goes emails the account ownerdelete_integration- Permanently delete an integration with its subscriptions and delivery history; nothing new is sent to it. Records already sent stay in the destination, and CRM access is not revoked inside the CRM. To stop sending without deleting, pause it with update_integrationlist_integration_deliveries- List the 50 newest deliveries of an integrationretry_integration_delivery- Send a past delivery again, once, as a new event to the URL it first went to (or to the connected CRM); no credits charged. A paused integration returns 400; a success on a needs_attention integration turns it back on and emails the account ownertest_integration- Send one test lead.created event (a sample lead for Acme Test Co) to the real destination; on a CRM this creates real records that are not removed. WitheventTypeexport.leadsit sends the sample batch of Exported leads (batched) instead (two made-up leads in the subscription's format; it needs an activeexport.leadssubscription). No credits charged. A paused integration returns 400; a success on a needs_attention integration turns it back on and emails the account ownerpush_lead_to_integration- Push one database lead (by leadId) to a connected integration now: free if you already received it or a lead with the same primary phone or primary email, otherwise 1 credit, or overage when your balance is 0 and your overage budget has room (402 insufficient_credits if neither can pay). Pushing your own lead data is REST onlyconnect_crm- Get a HubSpot, Salesforce or Pipedrive authorization URL for the account owner to open in a browser and approve within 10 minutes; once approved, a new connection sends new leads to that CRM automatically and the account owner is emailed. Approving a CRM account that is already connected re-authorizes that integration in place, keeping its events, field mapping and delivery historyget_integration_field_mapping- Get a CRM's field list and the current field mappingupdate_integration_field_mapping- Choose which CRM field receives each lead field (replaces the saved mapping as a whole)reset_integration_field_mapping- Reset a CRM field mapping to the defaultstest_integration_field_mapping- Dry-run a field mapping (creates, then deletes, synthetic CRM records)list_pipedrive_pipelines- List Pipedrive pipelines and stages for deal creationlist_pipedrive_deal_fields- List Pipedrive deal fields and the current deal field map
With the 17 integration tools (list_integration_providers through list_pipedrive_deal_fields), the assistant can send lead data (business contact details) to outside systems, so it should confirm the destination with you first. 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. Changes made through MCP are labeled with the channel and the API key used, for example "Created via MCP with API key smbk_AbCdEfG..." in the Integrations tab. See Integrations for the endpoints, events and error codes.
Requirements
MCP access uses the same API key authentication as the REST API. An active paid subscription (Starter, Growth, Scale, Platinum, or Enterprise) is required. The MCP endpoint accepts up to 60 requests per minute per client IP address, and each tool call also counts against the rate limits of the REST endpoints it calls. Usage tracking works the same as for the REST API.
Errors
Most tools report a failed call as the API's message followed by the HTTP status and error code, for example (HTTP 409, integration_exists) or (HTTP 402, insufficient_credits), plus up to 5 validation details, so the error codes under Error Handling and Integrations apply to MCP too. When the API sends no error code, only the status is shown (a rate-limited call ends in (HTTP 429)). The purchase, claim_key and start_enrichment tools return the API's message only. re_export_lead_export_history and refresh_and_export_lead_export_history return the file's text for csv and json formats, and a short description (file name, type and size) for an xlsx file, which is binary.
Integrations
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:
- 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/:idand{"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 orapiKeyis sent again. Pointing such a webhook at a new destination URL with an API key requiresapiKeyin the same request (a new value, ornullto 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 (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).
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 nolead.createdorlead.updated; the next time such a lead reaches your account it sendslead.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 aGET /api/v1/leadspage or MCPsearch_leadscall that returns it), or when a Lead Export History refresh finds that newer data. Receiving an unchanged lead again sends nothing. The push target itself receives the push aslead.created(see Push one lead), a refresh sends nothing for a lead on your export blacklist, andGET /api/v1/leads/previewand 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 toolexport_leads), a scheduled email that delivered leads (sent in addition toemail_schedule.sent) and an export from your lead reservoir. It is not sent for a re-export or refresh from 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_scheduleorreservoir),exportId(the Export History id of the export's first file, forGET /api/v1/export-history/:id/downloador/download-url; null when the export could not be added to Export History, and always null foremail_schedule),scheduleIdandscheduleName(only whensourceisemail_schedule) andsampleLeads(up to 5 of the exported leads, each withid,companyName,website,email,phone,cityandstate). Exampledata:{"count": 250, "databaseType": "other", "source": "api", "exportId": 412, "sampleLeads": [{"id": 1, "companyName": "Acme Dental", "website": "https://acme.example.com", "email": "[email protected]", "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 sendsexport.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 hasisNew(SMB_Is_Newin the Zoho CRM format):truethe first time it reached your account, the same leads that sendlead.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,scheduleIdandscheduleName(only foremail_schedule),exportedAt,format,skippedandleads; a sample batch also hastest: true. Batches can arrive out of order: usebatchCountto know when an export is complete. See Exported leads (batched) below.email_schedule.sent- a scheduled lead email was sent.data:scheduleId,scheduleName,leadCount,creditableLeadCountandsampleLeads(up to 5 leads, each withid,companyName,website,email,phone,cityandstate).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) |
X-SMB-API-Key | Only 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"]
}
}
| 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) |
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 |
export.leads | One 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.
- Answer with a 2xx status within 8 seconds (35 seconds for an
export.leadsbatch). 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
idwith a newX-SMB-Timestampand signature, so useidto ignore duplicates. The delivery appears inGET /api/v1/integrations/:id/deliveriesonce its last attempt has finished. - Tests, pushes, manual retries and deliveries to HubSpot, Salesforce and Pipedrive are one attempt each. A test's
idstarts withevt_test_; a manual retry sends a newid(evt_retry_...) withoccurredAtset to the time of the retry. - Each lead is its own request, except in
export.leadsbatches (see 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);
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/v1/integrations/providers | List providers and runtime availability |
GET | /api/v1/integrations | List your integrations with subscriptions and who created or last changed them |
GET | /api/v1/integrations/:id | Single integration with subscription detail |
POST | /api/v1/integrations/webhook | Create a webhook integration; returns the signing secret once |
PATCH | /api/v1/integrations/:id | Update display name, status, subscriptions (the list you send replaces them all), destination URL, header API key or Pipedrive deal settings |
DELETE | /api/v1/integrations/:id | Delete an integration permanently, with its subscriptions and delivery history |
GET | /api/v1/integrations/:id/deliveries | The 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/retry | Send 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/test | Send 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-lead | Push one lead now: leadId (1 credit if new to you) or your own lead data (free) |
POST | /api/v1/integrations/:provider/connect | Start a HubSpot, Salesforce or Pipedrive connection, or reconnect one already connected; returns authorizationUrl |
GET | /api/v1/integrations/:id/field-mapping | CRM field list and current mapping (?refresh=true reloads the CRM fields) |
PATCH | /api/v1/integrations/:id/field-mapping | Change which CRM field receives each lead field (replaces the whole saved mapping) |
DELETE | /api/v1/integrations/:id/field-mapping | Reset the mapping to the defaults |
POST | /api/v1/integrations/:id/field-mapping/test | Dry-run a mapping (creates, then deletes, synthetic CRM records) |
GET | /api/v1/integrations/:id/pipedrive/pipelines | Pipedrive pipelines and stages |
GET | /api/v1/integrations/:id/pipedrive/deal-fields | Pipedrive 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/.
| Endpoint | data |
|---|---|
GET /api/v1/integrations/providers | A 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/:id | An 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 .../deliveries | Newest 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-mapping | HubSpot {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/pipelines | A list of {id, name, active, stages: [{id, name, orderNr}]} |
GET .../pipedrive/deal-fields | {smbFields, dealFields, cachedAt, fieldMap} |
Rules worth knowing
subscriptionson PATCH replaces the integration's whole subscription set, so send the complete list you want (at most 20 items). Each item is{"eventType": "lead.created", "targetUrl": "https://...", "isActive": true, "config": {...}}: onlyeventTypeis required,isActivedefaults totrueandconfigto null. An event you leave out is removed and stops being sent, an empty list removes them all, and of two items with the same event and URL only the first is kept. Subscription ids change wheneversubscriptionsordefaultTargetUrlis sent. To add or switch on one event, read the integration first (GET /api/v1/integrations/:id) and send back every subscription you want to keep, withisActive: falseon the ones that should stay off. For example, to switch on HubSpotlead.updated, send{"subscriptions": [{"eventType": "lead.created"}, {"eventType": "lead.updated"}]}. On a HubSpot, Salesforce or Pipedrive integration, an item with any other event returns400 validation_errorand nothing is saved (see Events).configis used only byexport.leads: null or{"batchSize": 100, "format": "standard"}, wherebatchSizeis a whole number from 10 to 100 andformatisstandard,biginorzoho_crm; any other key or value returns400 validation_error. Anexport.leadsitem sent withoutconfigkeeps its current settings, and null resets them to the defaults. For other events, send back theconfigyou read, or leave it out.defaultTargetUrlis for webhook integrations only (400 on a CRM). Sent withoutsubscriptions, it re-points every existing subscription, inactive ones too. Sent withsubscriptions, it fills in each subscription that has notargetUrl. A subscription with neither keeps that event's current URL, or takes the integration's first URL for a new event.statuson PATCH takesconnectedordisabled.disabledpauses all sending;connectedre-enables a disabled orneeds_attentionintegration and resets its failure count.needs_attentionis set only by SMB Sales Boost, after 5 failed deliveries in a row. One failed delivery is, for example, a live webhook event whose 5 attempts all failed, a failed CRM write, a failed push, a retry that could not reach the destination, or a test the destination rejected. A successful live delivery, test, retry or push resets the count, and a disabled integration never changes toneeds_attention. Sendingneeds_attentionreturns 400 (unless the integration already has that status). Events that happen while an integration is disabled orneeds_attentionare not queued and are never sent later. Exported leads (batched) is the exception forneeds_attention: the batches of an export made meanwhile are held for up to 7 days and sent once the integration is re-enabled.GET /api/v1/integrations/:idshowsconsecutiveFailures,lastFailureAtandlastSuccessAt.- Live events never use an inactive subscription, and webhook tests and pushes do not either: they use the active subscription for the event, else any active subscription with a URL; with none they fail with "No active subscription for this integration" (a CRM test or push goes to the connected CRM). They never use an
export.leadssubscription: a webhook whose only active event isexport.leadstakes no single leads, so its test sends a sample batch and a push to it is refused. A new URL counts as a destination change (owner email) even on an inactive subscription, and so does switching on a subscription whose URL was not active before. apiKeyon PATCH applies only to Clay integrations and to webhook integrations that already send a header API key (400 otherwise, and on a CRM). A new value replaces the key;nullor an empty string stops sending it. Pointing such an integration at a new destination URL (any URL it does not already use, even on the same host) through the API or MCP requiresapiKeyin the same request, or the PATCH returns 400 and nothing changes. The integration and its subscriptions are saved together; a500may come after the change was saved, so checkGET /api/v1/integrations/:idbefore repeating a PATCH.- Test and retry never charge credits and never add a lead to your Lead Export History, even when the retried delivery was a push; both answer 200 with
data.status(see Responses).POST /api/v1/integrations/:id/deliveries/:deliveryId/retrytakes the numericidfromGET .../deliveries(not theevt_id in the webhook body) and re-sends that delivery's stored data, to the URL it was first sent to or to the connected CRM, as a new event: a newid,occurredAtset to the time of the retry, and one attempt. A test or retry that is sent is recorded as a new delivery (the original stays), and one that fails counts towardneeds_attention, whether the destination rejected it or could not be reached (for example a timeout). On a CRM, a test writes a real record for the sample company Acme Test Co that is not removed;POST .../field-mapping/testcleans up after itself. On a paused integration, test and retry return 400 ("Integration is paused. Re-enable it first (PATCH status connected)."); a successful test or retry on aneeds_attentionintegration turns it back toconnected, which counts as re-enabling it. A retry of anexport.leadsdelivery works differently: it is queued and sent in the background, needs a connected integration (not paused and notneeds_attention) with Exported leads (batched) switched on, goes to the URL that event uses now, and can send the rest of a stopped export; see Exported leads (batched). - Field mapping: each CRM takes its own scopes (HubSpot
contactandcompany, Salesforcelead, Pipedrivepersonandorganization). Each scope maps an SMB field id (the ids insmbFields) to the CRM field's internal name, for example{"contact": {"phone": "mobilephone"}}. PATCH replaces the whole saved mapping: any field or scope of this CRM that you leave out goes back to its default, so read the mapping first and send every override you want to keep. Unknown SMB field ids are dropped (the response shows what was saved). A scope of another CRM, or a PATCH with no scope at all, returns 400validation_errorand changes nothing; use DELETE to reset the mapping.POST .../field-mapping/testtakes an optional{"mapping": {...}}to test a mapping before saving it; without it, the saved mapping is tested. The field-mapping endpoints return 400 on a webhook integration, and the Pipedrive endpoints on any other provider. pipedriveDealson PATCH (Pipedrive only, 400 otherwise) turns on Deal creation:{"enabled": true, "pipelineId": 1, "stageId": 2, "defaultValue": 0, "defaultCurrency": "USD", "expectedCloseDays": 30, "fieldMap": {}}, wheredefaultValueis 0 or more,defaultCurrency3 to 8 characters,expectedCloseDays0 to 3650, andfieldMapmaps an SMB field id to a deal field key fromGET .../pipedrive/deal-fields. Fields you send are merged into the saved settings; afieldMapyou send replaces the saved one.enabled: trueneedspipelineIdandstageIdin the same request, even when they are already saved (take them fromGET .../pipedrive/pipelines). For each lead, an open Deal already linked to the lead, or else an open Deal of the Person, is updated; otherwise a new Deal is created (a won or lost Deal is never reused).- DELETE is permanent: the integration's subscriptions, field mapping, Pipedrive deal settings and delivery history are removed and cannot be restored. Records already sent stay in the destination, and deleting a CRM integration does not remove SMB Sales Boost's access inside the CRM (revoke it there if needed). A webhook created again gets a new id and signing secret, and a CRM must be connected again in a browser. To stop sending without losing anything, PATCH
{"status": "disabled"}instead.
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:
provider(required):zapier,n8n,make,pipedream,clayorgeneric_webhook. CRMs are connected withPOST /api/v1/integrations/<crm>/connectinstead (see Connect a CRM).targetUrl(required): a public https URL, up to 2048 characters; localhost and private network addresses return 400.events(required): one or more event names (duplicates are ignored; at most 20 entries).export.leadsis not available forclay(400 validation_error); to set its batch size and format, PATCH the subscription'sconfigafterwards.displayName(optional): 1 to 100 characters; defaults to the provider name.apiKey(optional, any webhook provider, up to 500 characters): a secret sent as theX-SMB-API-Keyheader only to thistargetUrl, for example a Clay table API key. It is stored encrypted and never returned. Set it here: a later PATCH acceptsapiKeyonly for Clay and for webhooks that already send one.
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).
- Cost: A lead you already received is free, even at a 0 balance. A new lead costs 1 credit, taken from your balance or, when the balance is 0 and your overage budget is enabled with room left, billed as overage; if neither can cover it, the push returns 402 insufficient_credits and nothing is pushed. A new lead that shares a primary phone or primary email with a lead you already received is also free (see Credits & Subscription). The credit is taken before the lead is sent: if the destination then fails, the response is a 200 with status
failedandtracked: true, the credit stays used, and because the lead now counts as received, pushing the sameleadIdagain is free. - Refused before anything is charged or sent: an integration whose status is not
connected(a disabled orneeds_attentionintegration returns 400 until you PATCH{"status": "connected"}; unlike test and retry, push does not work onneeds_attention), aleadIdthat matches your export blacklist (400), an unknownleadId(404),leadIdandleadsent together (400), and a webhook with no active subscription, or whose only active event isexport.leads(a 200 with statusfailed,tracked: falseandcreditsUsed: 0; for the second case the error is, for example, "Integration "My Zap" has no active event that takes single leads. Exported leads (batched) only receives exports."). Your own data is not checked against the blacklist. - Your own data: use the keys
email,firstName,lastName,phone,companyName,website,industry,cityandstateso CRMs can map it (the database field names, such aswCompanyNameandwEmailPrimary, also work: they are the names Lead Export HistoryleadSnapshotuses, not the display namesGET /api/v1/leadsreturns, such asCompany Name). CRMs match and create records as described under Supported providers; for example HubSpot writes a Contact only when there is an email. - What arrives: a webhook gets one
lead.createdevent, even for a lead you received before. AleadIdpush to a webhook sends the same flat lead summary as a livelead.createdevent, withsourcecrm_push. A push of your own data sends{"lead": <your object as sent>, "source": "manual_push"}; see Delivery format. For aleadIdpush, the target integration gets the lead once, from the push itself; it is left out of the backgroundlead.created/lead.updatedevents. Your other integrations still get them: a lead you had not received before sendslead.createdto your other integrations subscribed tolead.created, and re-pushing a lead you already received sendslead.updatedto your other integrations subscribed tolead.updatedonly when the lead changed since you last received it (a newer Last Buying Signal). A push of your own data goes only to the target integration. - Where it is recorded: every push is recorded in the integration's deliveries and counts toward
needs_attentionlike any other delivery. That covers every push that is sent, through the API, MCP or the dashboard (its per-lead push, or a push of selected leads from the Leads tab, where each lead counts as one delivery), to any provider, HubSpot and Salesforce included; seeGET /api/v1/integrations/:id/deliveries. AleadIdpush that passes the checks is recorded in Lead Export History withexportMethodcrm_push, even if the delivery then fails, and adds 1 tototalLeadsExportedandmonthlyLeadsExportedinGET /api/v1/me(a free re-push too);exportMethodshows the most recent channel, so a later search or export of the lead changes it. A push never creates a file in Export History, and a push refused before anything is sent is not charged and not recorded anywhere. - Errors: a
503 service_unavailableonly happens before anything is charged or sent, so it is safe to retry. A500may come after a credit was taken: checkGET /api/v1/meand the deliveries before pushing the same lead again. The response shape is under Responses.
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/exportor MCPexport_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.batchSizethrough 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.createdplusisNew), 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, orconfig.format(standard,biginorzoho_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.createdfor 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.createdalso covers leads from API searches, pushes and email schedules. - No single leads: an integration whose only active event is
export.leadsnever receives single leads. A push to it is refused (see 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).exportRef: the same for every batch of one export, for exampleexp_3f9a1c2b7d4e5f60.batchId:<exportRef>:<batchNumber>, one per batch of the export. Use it to ignore a batch you already processed.batchNumberandbatchCount: this is batchbatchNumber(counting from 1) ofbatchCount.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_scheduleorreservoir),databaseType(other),exportId(as inexport.completed),scheduleIdandscheduleName(only foremail_schedule) andexportedAt(when the export was made).format:standard,biginorzoho_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 isnull; 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), 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
- Route on the
X-SMB-Eventheader or the body'stype, and ignore the events you do not handle: the same URL can receive the integration's other events. - Process
data.leadsin 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,batchNumberandbatchCountto know when every batch of an export has arrived. - Use
data.batchIdto 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.testistrue, every sample has a newbatchId, 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-Afterheader, is not counted as a failure: the batch waits as long asRetry-Afterasks (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
idandbatchId.
Retry
- Retry (the Retry button in the Deliveries tab,
POST /api/v1/integrations/:id/deliveries/:deliveryId/retryor MCPretry_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}.queuedBatches0 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
batchIdand a new envelopeid. 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 inskipped. - The integration must be connected: on a paused or
needs_attentionintegration, 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/deliveriesafter 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, eachexport.leadsrow also hasbatchSummary:{"exportRef", "batchNumber", "batchCount", "leadCount", "skippedCount", "sendStopped", "notSent"}.sendStoppedmarks a Stopped row andnotSenta 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.
- 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.
- 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.
- Click Send sample batch, so the trigger receives a message.
- 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.
- Add one Custom function step with the script for your CRM: the Script for Bigin by Zoho CRM if you use Bigin, or the Script for Zoho CRM if you use Zoho CRM (both below). Map its input
payloadto the whole trigger (${trigger}). - 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.leadsand 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 asdata.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.leadswhen the next node handles one item at a time (the items stay in the same execution), or one HTTP Request node that sendsdata.leadsto 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 and set the integration's Format to Bigin by Zoho CRM.
- You use Zoho CRM: copy the Script for 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 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;
}
Ready to Find Your Next Customers? Get access to growing local business leads updated every 5 minutes. See plans starting at $49/mo →