smb-sales-boost ยท diff
git:20261008.3286d86 to git:20261008.fa5561d
5 added, 5 removed. Audit A to A.
---
name: smb-sales-boost
description: Find, preview and export growing local businesses (SMB leads) from the SMB Sales Boost database through its REST API - search by keyword, location and buying signals, preview for free, export CSV/JSON/XLSX, re-download past exports, enrich lists of website URLs, manage saved searches, keyword lists, email schedules, credits and the subscription, and set up CRM and webhook integrations (HubSpot, Salesforce, Pipedrive, Zapier, n8n, Make, Pipedream, Clay, any webhook URL) that send lead data, including business contact details, outside SMB Sales Boost automatically. Requires SMB_SALES_BOOST_API_KEY (smbk_...) and python3. Searches, exports and enrichments spend credits; purchases, plan changes, auto top-up and overage billing charge real money; integrations send lead data to third-party systems; so always confirm with the user first. Exported files contain business contact details (PII).
metadata:
{
"openclaw":
{
"requires": { "env": ["SMB_SALES_BOOST_API_KEY"], "bins": ["python3"] },
"primaryEnv": "SMB_SALES_BOOST_API_KEY",
"homepage": "https://smbsalesboost.com",
},
}
---
> **Note for AI agents:** This file and the other files in this skill folder may be truncated when opened. Always read them fully, in chunks if necessary, so that no rule, parameter or example is missed. Full parameter and field tables are in `REFERENCE.md` in this folder.
# SMB Sales Boost Skill
SMB Sales Boost is a B2B lead database of small and medium-sized businesses in the United States, built for sales teams that want to reach growing local businesses. Every lead carries a **Last Buying Signal**: the date something changed that makes the business worth contacting now (it was newly added, got a new primary phone, phone list, primary email, Tier 1 or Tier 2 email list or full address, or added or changed a social profile). **Buying Signal Type** says which of these happened.
This skill lets you search, preview and export those leads, re-download past exports, enrich lists of website URLs, manage the account, and connect the user's CRM or automation tools so leads flow there automatically, all through the REST API with the bundled `smb_api.py` script.
- **Base URL:** `https://smbsalesboost.com/api/v1`
- **One database:** every request is served from the Main database (`other`). Any `database` value you send resolves to it, so you never need to choose one.
- **Who can use it:** accounts with an active or trialing Starter, Growth, Scale, Platinum or Enterprise subscription. Anyone else gets `403 forbidden`. New customers can subscribe through the API (see **Signing up through the API**).
- **Reference files:** `REFERENCE.md` (every parameter, field, status and error), `openapi.json` (the published OpenAPI 3.1 spec; where it differs from this skill, follow this skill, which was checked against the live API).
## Setup
1. The user creates an API key in the dashboard (Dashboard > API, https://smbsalesboost.com/dashboard?tab=api). Keys start with `smbk_`.
2. The key must be in the `SMB_SALES_BOOST_API_KEY` environment variable. If it is not set, ask the user to set it. Do not ask them to paste the key into the chat, and never print, log or write the key to a file.
3. Run every call through the bundled script (Python 3.8 or newer, standard library only, nothing to install):
```bash
python3 smb_api.py METHOD ENDPOINT [--params '{json}'] [--body '{json}'] [options]
```
Run it from this skill's folder, or use its full path (for example `python3 ~/.claude/skills/smb-sales-boost/smb_api.py GET /me`). The script prints JSON to stdout and notes (rate-limit headers, warnings) to stderr. Do not hand-build `curl` commands; the script encodes parameters correctly, saves files safely and enforces the confirmation rules below.
**Quoting user text safely:** `--params` and `--body` take JSON inside single quotes. If any value contains a single quote (for example a company name like `Joe's Bakery`), write the JSON to a file with your file tool and pass `--params-file PATH` or `--body-file PATH` instead. Never paste user text into a shell command unquoted.
**Windows:** run the script with `py -3 smb_api.py` (or `python smb_api.py`) and always pass JSON through `--params-file` or `--body-file`. Command Prompt does not treat single quotes as quotes, and Windows PowerShell 5.1 removes the inner double quotes, so inline JSON fails there. The script reads UTF-8 files (with or without a byte-order mark) and UTF-16 files that start with a byte-order mark.
## Rules for every session
1. **Real money: confirm first.** These calls charge, or authorize charges to, the card on file: `POST /purchase` (subscription checkout), `POST /purchase-credits`, `POST /subscription/change-plan` (an upgrade or an early trial activation charges immediately), and `PATCH /auto-top-up` or `PATCH /overage-budget` with `enabled: true` (automatic future charges). Before any of them, tell the user exactly what will happen and what it costs (amount and date), and wait for an explicit yes. **All charges are final; there are no refunds.** The script refuses these calls unless you add `--confirm`; add it only after the user has approved that exact action. Adding credits (a purchase, an upgrade, an early trial activation or a trial switch to a larger plan) or enabling the overage budget also restarts every email schedule that was paused for insufficient credits (with the overage budget, their leads can be billed to the card), so check `GET /email-schedules` and tell the user first.
2. **Credits: preview first, cap every spend.** `GET /leads`, `POST /leads/export` and `POST /enrichments` spend credits. Run `GET /leads/preview` first (it is free) to check the match count and quality, keep `limit` small (on `GET /leads` the script accepts only one whole number from 1 to 1000; it refuses `0`, which the API reads as 100, and a `limit` given twice), and always pass `maxCredits` (a whole number of 0 or more). The script refuses `GET /leads` and `POST /leads/export` without `maxCredits` unless you add `--confirm` (only after the user agreed to an uncapped spend), and it refuses a `maxCredits` of `null`, a negative number, a decimal, `true`/`false` or text, because the API would treat those as "no cap". After `GET /leads` or `POST /leads/export`, tell the user `creditsUsed` and the remaining balance: `creditsRemaining`, or `totalCreditsRemaining` from `GET /me` when the page came back empty (the field then shows 0). `POST /enrichments` returns neither: report `credits.total` from `GET /enrichments/{id}` (final once `credits.final` is true) and the balance from `GET /me`.
3. **Email to real people: confirm first.** An active email schedule emails real recipients and spends credits, and `POST /email-schedules/{id}/trigger` sends immediately. Create schedules with `"isActive": false`, review them with the user, then activate. The script requires `--confirm` for creating an active schedule, activating one, triggering one, and changing the recipients, preset or `maxLeadsPerEmail` of a schedule that is not paused in the same call. Editing a keyword list (`PUT /keyword-lists/{id}`) or turning on auto-refine also changes what the schedules whose preset uses that list send, so check `GET /filter-presets` and pause those schedules first.
- 4. **Destructive calls: confirm first.** `POST /ai/generate-keywords` deletes all of the user's keyword lists before generating new ones. Every `DELETE` is permanent, and deleting a filter preset also deletes the email schedules that use it. `POST /subscription/cancel` cancels the subscription. The script requires `--confirm` for all of these.
+ 4. **Destructive calls: confirm first.** `POST /ai/generate-keywords` deletes all of the user's keyword lists before generating new ones. Every `DELETE` is permanent. Deleting a filter preset also deletes every email schedule that uses it, so first list those schedules (`GET /email-schedules`, the ones whose `filterPresetId` is the preset's id) and name them to the user. When the user only wants something to stop, offer to pause it instead of deleting it: a paused email schedule (`PATCH /email-schedules/{id}` with `{"isActive": false}`) keeps its list of recipients who unsubscribed, and a paused integration (`PATCH /integrations/{id}` with `{"status": "disabled"}`) keeps its delivery log, its signing secret and its dashboard label; both can be resumed later. `POST /subscription/cancel` cancels the subscription. The script requires `--confirm` for all of these.
5. **Lead data to third parties: confirm first.** An integration sends lead data (business names, phone numbers, email addresses and the other lead fields) to a system outside SMB Sales Boost (a CRM, an automation tool or any webhook URL), and it keeps doing so automatically on every subscribed event, for leads the user gets through the dashboard, the API or email schedules, until it is disabled or deleted. Before you create a webhook integration, start a CRM connection, point an integration at a new URL, add or re-activate events, re-enable it, change the header `apiKey` it sends, push a lead, send a test event, retry a delivery or change or test a CRM field mapping, describe the destination (provider, host, which events and which data) and wait for the user's explicit yes. The script requires `--confirm` for all of these. When an API key creates an integration, connects a CRM, changes where an integration sends data, adds events or re-enables one, the account owner is emailed for each change. During bursts of more than 10 changes in an hour, further changes are combined into summary emails. Tell the user to expect it. The `signingSecret` returned when a webhook integration is created is shown only once: give it to the user, and never log it, save it to a file or repeat it elsewhere. See **Integrations**.
6. **Never blindly repeat a call that may have charged or sent data.** If `GET /leads`, `POST /leads/export`, `POST /enrichments`, a schedule trigger, a plan change, a credit purchase, creating a webhook integration or pushing a lead fails with a 5xx, a timeout or a dropped connection, it may still have completed. Follow the `advice` field the script prints (check `GET /me`, `GET /export-history`, `GET /enrichments`, `GET /email-schedules`, `GET /integrations` or `GET /integrations/{id}/deliveries` first). The script retries a `429` or `503` by itself (after `Retry-After`, up to 60 seconds), except on calls that change or send through an integration (every call under `/integrations` other than a `GET`): it returns those errors with advice, so check `GET /integrations` or the delivery log, then repeat the call once if the change or delivery is not there. A `503 provider_unavailable` is never worth repeating.
7. **Protect personal data.** Results and files contain business phone numbers and email addresses. Keep files in the output folder, do not paste full lead lists into public channels, and only show the user what they asked for.
8. **Stay on documented endpoints.** Use only the endpoints in this skill and `REFERENCE.md`. API key management, billing details and undoing a cancellation are dashboard-only (see **Not available through the API**).
9. **Data accuracy.** Contact details come from public sources and are not verified; revenue, employee and similar figures are estimates. Say so if the user is about to rely on them.
## Plans and credits
Every plan is credit-based. One credit buys one new lead.
| Plan | Price / month | Monthly credits | Extra credits | Max credits per purchase | 14-day free trial |
|---|---|---|---|---|---|
| Starter | $49 | 500 | $0.10 each | 2,500 | Yes, 250 trial credits |
| Growth | $149 | 2,000 | $0.075 each | 10,000 | Yes, 1,000 trial credits |
| Scale | $499 | 10,000 | $0.05 each | 50,000 | Yes, 5,000 trial credits |
| Platinum | $1,999 | 100,000 | $0.03 each | 500,000 | No |
| Enterprise | $4,999 | 250,000 | $0.02 each | 1,250,000 | No |
How charging works:
- **1 credit per new lead** returned by `GET /leads` or exported by `POST /leads/export`.
- **Free:** leads you have already received through any channel (dashboard, API, email schedules, enrichments), and new leads that share a primary phone or primary email with a lead you already received. Previews are always free.
- **Automatic cap:** on every charged call, new leads come first and are cut to `min(maxCredits, your spendable balance)`; already-received leads fill the remaining slots for free. If your balance was the limit, the response has the header `X-Credits-Capped: true` (the script prints a note). Leads cut by a cap are dropped, not moved to the next page (exception: on `POST /leads/export` with `maxLeads`, they go to the reservoir and are added to your next `maxLeads` export, where new leads cost credits).
- **Spending order:** temporary (promotional) credits, then the monthly allowance, then permanent credits (rolled-over and purchased), then the optional overage budget (billed to the card daily).
- **Unused monthly credits roll over** into the permanent balance while the subscription stays active. Downgrading makes the whole remaining balance expire at the next renewal; cancelling forfeits it when the subscription ends.
- **Auto top-up** (if the user enabled it) buys credits automatically when the permanent balance drops below a threshold, which charges the card.
- `GET /me` is the authoritative balance: `totalCreditsRemaining` (temporary + monthly remaining + permanent), plus `monthlyCredits`, `monthlyCreditsUsed`, `monthlyCreditsRemaining`, `permanentCredits`, `temporaryCreditsRemaining` and `creditOverageRate` (cents per credit).
**Free trial:** Starter, Growth and Scale start with a 14-day free trial. A payment method is required and nothing is charged until day 14 (the user's bank may show a temporary authorization that disappears within a few days); on day 14 the plan price is charged automatically unless the subscription is cancelled first. Trial credits are 50% of the plan's allowance and the full allowance is granted when the trial converts. One trial per person, checked by email address and payment method. During a trial the API works normally, except that auto top-up and the overage budget cannot be enabled. While `GET /me` shows `trial.isTrialing: true`, the plan price has not been charged yet (credits bought with `POST /purchase-credits` during the trial are charged immediately); `trial.firstChargeAmountCents` (the plan's list price; a discount on the account can make the actual charge lower) is charged on `trial.endsAt` unless the user cancels first.
## Finding leads: the standard workflow
1. **Translate the request into filters.** Use several wildcard keyword variations (they are OR'ed): "dentists in Texas" becomes `positiveKeywords: ["*dental*", "*dentist*", "*orthodont*"]` and `stateInclude: ["TX"]`.
2. **Preview (free):** `GET /leads/preview`. Read `data.pagination.total` for the match count and look at a few results (contacts are masked).
3. **Agree on the spend:** tell the user how many leads match and that each new lead costs 1 credit.
4. **Get the leads:** either `GET /leads` (results come back in the response, one page at a time) or `POST /leads/export` (a CSV, JSON or XLSX file; better for more than a few dozen leads). Always pass `maxCredits` (the script refuses these calls without it unless you add `--confirm`). `GET /leads` does not apply the user's export blacklist (preview and export do), so leads from blacklisted domains can come back and are charged like any other new lead. If the user keeps an export blacklist, use `POST /leads/export`.
5. **Report:** show results in a short table and state `creditsUsed` and the balance (`creditsRemaining`, or `GET /me` if the page was empty).
```bash
# 1-2. Preview: free, contacts masked
python3 smb_api.py GET /leads/preview --params '{"positiveKeywords":["*dental*","*dentist*","*orthodont*"],"stateInclude":["TX"],"limit":10}' --compact
# 4a. Search: returns up to 25 leads, never spends more than 25 credits
python3 smb_api.py GET /leads --params '{"positiveKeywords":["*dental*","*dentist*","*orthodont*"],"stateInclude":["TX"],"limit":25,"maxCredits":25}' --compact --out tx-dentists
# 4b. Export: a CSV file of up to 500 leads, never more than 500 credits
python3 smb_api.py POST /leads/export --body '{"filters":{"positiveKeywords":["*dental*","*dentist*","*orthodont*"],"stateInclude":["TX"]},"maxResults":500,"maxCredits":500}'
```
`--compact` prints only the key fields of each lead; `--out NAME` also saves the full response as `NAME.json` in the output folder. Use both for larger searches so the full records do not flood the conversation.
## Search parameters
`GET /leads` and `GET /leads/preview` take the same filters as query parameters. With the script, give lists as real JSON arrays; it encodes each parameter in the format the API expects. The most useful ones:
| Parameter | What it does |
|---|---|
| `positiveKeywords` | Keywords to include (OR). Matched as case-insensitive substrings against Registered URL, Crawled URL, Company Name and AI Categories. `*` is a wildcard: `*auto*repair*` matches "auto body repair" and "automotive repair". In URL columns, spaces also act as wildcards. |
| `negativeKeywords` | Keywords to exclude: a lead is dropped if any of them matches any of those columns. |
| `orColumns` | Columns the keywords search. Default `["wOgrequestedUrl","wSimpleCrawledUrl","wCompanyName","aiCategoryEstimation"]`. Adding `"wProfileDescriptionShort"` also searches the short description, but it moves keyword searches onto the slow path (up to about 110 seconds); keyword searches stay fast only while every `orColumns` entry is one of the four defaults. To search AI categories only, use `["aiCategoryEstimation"]`. |
| `nameIncludeTerms` / `nameExcludeTerms` | Company-name terms. While `wCompanyName` is in `orColumns` (the default), include terms are OR'ed with `positiveKeywords`, so adding them to a keyword search widens it instead of narrowing it; each include term becomes required only when `wCompanyName` is left out of `orColumns`. Exclude terms always remove matching leads. |
| `urlIncludeTerms` / `urlExcludeTerms`, `crawledUrlIncludeTerms` / `crawledUrlExcludeTerms` | Website URL terms. Same rule as company-name terms, with `wOgrequestedUrl` (Registered URL) and `wSimpleCrawledUrl` (Crawled URL) in `orColumns`. |
| `descriptionIncludeTerms` / `descriptionExcludeTerms` | Short-description terms. Each include term is required (AND) unless `wProfileDescriptionShort` is in `orColumns`. |
| `stateInclude` / `stateExclude` | Two-letter uppercase state codes, exact match: `["TX","OK"]`. |
| `cityInclude` / `cityExclude` | City names, substring match: `["Austin"]`. |
| `zipInclude` / `zipExclude` | ZIP codes, exact match. |
| `lastBuyingSignalFrom` / `lastBuyingSignalTo` | Last Buying Signal date range, inclusive: ISO 8601 (`2026-09-01`, `2026-09-30T23:59:59Z`) or relative (`rel:7d`; units `h`, `d`, `w`, `m` = months, `y`). A date-only `To` means the start of that day, so to include all of September 30 send `2026-09-30T23:59:59Z`. An invalid date fails with `500`, so check the format. |
| `buyingSignalTypeFilter` | Only leads whose latest buying signal is one of these types (exact, case-sensitive): `Newly Added`, `Phone Primary`, `Total Phones`, `Email Primary`, `Tier 1 Emails`, `Tier 2 Emails`, `Address Full`, `Instagram`, `LinkedIn`, `Facebook`, `YouTube`, `TikTok`, `X (Twitter)`, `Google Maps`, `Yelp`, `Pinterest`, `Other Social`. |
| `phonePrimaryEmptyFilter`, `emailPrimaryEmptyFilter` | `exclude_empty` keeps only leads that have that contact detail (a lead without contacts still costs a credit), `only_empty` the opposite. |
| `minRatingValue` / `maxRatingValue`, `minReviewCount` / `maxReviewCount`, `minNumLocations` / `maxNumLocations` | Numeric ranges. |
| `registrationDateFrom` / `registrationDateTo` | Domain registration date as a plain `YYYY-MM-DD`, for newly launched businesses (it is compared as text, so a `rel:` value in `registrationDateFrom` leaves out the boundary day; see `REFERENCE.md`). |
| `websiteSchemaFilter` | Website schema types, for example `["LocalBusiness","Dentist"]`. `GET /leads/other/schema-types` lists the values (the first call can be slow). |
| `redirectFilter` | `yes` or `no`: whether the registered domain redirects elsewhere. |
| `search` | Plain substring search over company name, both URLs, short description, city and primary phone (not a wildcard search). |
| `sortBy` / `sortOrder` | Default `lastBuyingSignal` / `desc` (newest buying signals first). |
| `page` / `limit` | Default 1 / 100; `limit` is at most 1000. On `GET /leads` the script requires `limit`, when given, to be one whole number from 1 to 1000 (the API reads `0` as 100). |
| `maxCredits` / `maxResults` | `GET /leads` (and the `POST /leads/export` body); preview ignores both. On `GET /leads` they cap the credits spent and the leads returned by that one call, and each page is a separate call. `maxCredits: 0` returns only leads you already have, free. |
| `excludePurchased` | `GET /leads/preview` (and the `POST /leads/export` body); `GET /leads` ignores it. `true` hides leads flagged `contactExported`. On preview it is applied after paging, so a page can hold fewer than `limit` leads and `pagination.total` still counts the hidden ones. |
**At least one positive filter is required:** `positiveKeywords`, `nameIncludeTerms`, `urlIncludeTerms`, `crawledUrlIncludeTerms` or `descriptionIncludeTerms`. Without one, `GET /leads` and preview return no leads, charge nothing and set `requiresKeywords: true`, and `POST /leads/export` returns `400 bad_request`. Location, date, signal and other filters only narrow a search; they cannot start one. Never send an empty keyword (`[""]`): it matches everything.
There are many more filters (Tier 1-6 emails, Total Phones, founders, software and ad-pixel columns, email provider and security, employee counts, NAICS, open positions and more). See `REFERENCE.md` for the complete list and formats. Unknown or misspelled parameters are ignored silently, which makes a search broader, so double-check names.
**Speed:** keyword, company-name, URL, state and city filters with the default sort answer in about a second. Other filters can take up to about 110 seconds (the script prints a note when a query took the slow path). Previewing first warms a 20-second cache, so an identical `GET /leads` right after it is fast.
### Results
```json
{"data": {
"leads": [ { "id": 2841123, "Company Name": "Bright Smile Dental", "City": "Austin", "State": "TX",
"Phone Primary": "(512) 555-0142", "Email Primary": "hello@brightsmiledentaltx.com",
"Last Buying Signal": "2026-10-03T09:14:52.118Z", "Buying Signal Type": ["Phone Primary"], "...": "..." } ],
"pagination": { "page": 1, "limit": 25, "total": 1834, "pages": 74 },
"databaseType": "other", "creditsUsed": 18, "creditsRemaining": 4982, "maxCredits": 25 }}
```
- Each lead has 61 fields with display names (`Company Name`, `Phone Primary`, `Total Phones`, `Email Primary`, `Tier 1 Emails`, `AI Categories`, `Registered URL`, `Description Short`, `Founder Name(s)` and more; full list in `REFERENCE.md`). `Last Updated` is a deprecated duplicate of `Last Buying Signal`.
- `pagination.total` is the full match count before credit caps.
- `creditsRemaining` does not include overage headroom and shows `0` when a page is empty; check `GET /me` for the real balance.
- **Preview** masks contacts (`(512) ***-**42`, `hel*@brightsmiledentaltx.com`) except on leads you already have, which come back unmasked with `"contactExported": true`. Preview also flags a lead this way when one of its Total Phones or Tier 1-6 emails matches the primary phone or primary email of a lead you received before; `GET /leads` and exports still charge 1 credit for such a lead, so `contactExported: true` does not guarantee the lead is free. Preview never charges.
## Exporting files
`POST /leads/export` creates a file from a search and saves it to the output folder automatically.
```bash
python3 smb_api.py POST /leads/export --body '{"filters":{"positiveKeywords":["*med*spa*","*aesthet*","*botox*"],"stateInclude":["FL"],"lastBuyingSignalFrom":"rel:30d"},"maxResults":1000,"maxCredits":300}'
```
- **Body:** `filters` (the same filters as search, as real JSON arrays, except `search`, `page` and `limit`: the export ignores those three without an error, so a `search` term that narrowed your preview does not narrow the export, and the export can then match and charge for more leads; and never put `maxCredits`, `maxResults` or `excludePurchased` inside `filters`, where they are ignored), plus optional top-level `maxCredits`, `maxResults`, `maxLeads`, `excludePurchased`, `formatId` and `inline`. Use `selectedIds` (lead ids) instead of `filters` to export specific leads. The script converts list filters given as strings or numbers into real arrays (the export ignores other forms of most list filters), moves a misplaced `maxCredits`, `maxResults`, `maxLeads`, `excludePurchased`, `formatId` or `inline` out of `filters`, and refuses `filters.search`, `filters.page` and `filters.limit`.
- **Without `maxCredits` an export can spend your entire balance** (plus any overage budget). Always set it; the script refuses an export without it unless you add `--confirm` after the user agreed to an uncapped spend, and it refuses `"maxCredits": null` (the export treats `null` as no cap).
- `maxResults` caps the total leads in the file. `maxLeads` also caps it, but keeps the leads that did not fit (including those cut by `maxCredits`) in a "reservoir" that is merged into your next `maxLeads` export whatever its filters are, and new leads among them cost 1 credit each. The reservoir is one pool per account, shared with email schedules and capped at the plan's monthly credit allowance; leads that do not fit are not kept. Prefer `maxResults` unless the user wants that carry-over.
- `excludePurchased: true` exports only leads you do not have yet.
- **Formats:** without `formatId` the export uses your default export format (the one with `isDefault: true` in `GET /export-formats`, which can be XLSX), or the built-in CSV when you have none; a wrong `formatId` also falls back to it. JSON and XLSX come from a saved export format. There is no file-type option in the request, so to be sure of CSV, pass the id of a CSV format.
- **XLSX limit:** at most 20,000 rows in any one file (a single file, a split part, or the `includeTotal` file), and the `400 export_too_large` error arrives after the credits were charged. For bigger exports use a CSV format, or an XLSX format with `splitFiles: true`, `splitMode: "max_rows"`, `maxRowsPerFile` of 20,000 or less and `includeTotal: false`. If it happens, do not simply repeat the request (an export always picks new leads first, so a repeat can charge for different leads): re-run it with a CSV format, `maxCredits: 0` and no `excludePurchased`, which returns only leads you already have at no cost, or re-export the charged leads from the lead export history (below).
- **Size:** up to 100,000 leads per export. 5 export requests per minute.
- **Response:** the script saves each file and prints `leadCount`, `creditsUsed`, `creditsRemaining`, `exportId` and the saved paths. The default file contains 59 columns, including `Last Buying Signal` and `Buying Signal Type`.
- `inline: false` (or `--params '{"inline":false}'`) returns download links instead of file contents; the script then downloads the files itself. Use it for very large exports.
**Re-downloading:** every export file is kept for 90 days.
```bash
python3 smb_api.py GET /export-history --params '{"limit":10}' # newest first: id, fileName, leadCount, expiresAt, downloadAvailable
python3 smb_api.py GET /export-history/412/download # saves the file
```
After 90 days (or for files exported before 26 September 2026, or whose upload failed) the download answers `410 gone`. The leads can still be re-exported **free** from the lead-level history:
```bash
python3 smb_api.py GET /lead-export-history --params '{"limit":50}' # every lead you received, with its tracking id
python3 smb_api.py POST /lead-export-history/re-export --body '{"trackingIds":[55123,55124]}'
```
`re-export` rebuilds a file from the stored copies (up to 5,000 tracking ids, no credits). `POST /lead-export-history/refresh` and `/refresh-and-export` first replace the stored copies with the current data (free, but the original copies are overwritten). See `REFERENCE.md`.
## Enriching a list of websites
`POST /enrichments` looks up a list of website URLs. URLs already in the database return the full lead record; others are fetched live from the website.
**Pricing:** 1 credit for a URL found in the database that you have not bought before; 0.1 credit for a URL fetched live; 0 for a URL you already have, one that returned no data, or one without the contact details you asked for. A URL found in the database is never fetched live. Submitted URLs and results are never added to the database. There is no pricing dialog through the API, so state these prices and agree on `maxCredits` with the user first (the script requires `--confirm`).
```bash
python3 smb_api.py POST /enrichments --body-file urls.json --confirm
# urls.json: {"urls": ["https://brightsmiledentaltx.com/contact", "acme-plumbing.com"], "maxCredits": 50, "contactTypes": "either", "emailResults": false}
python3 smb_api.py GET /enrichments/412 # poll: status queued, matching, scraping, merging, delivering, then completed / partial / failed
python3 smb_api.py GET /enrichments/412/download # once completed or partial: saves the CSV (one row per usable URL with a Status column; duplicates and unparseable URLs are only counted)
```
- Up to 100,000 URLs per run (after removing duplicates). Send full URLs; a deep link can carry better contact data. Put the most important URLs first, because a credit limit trims from the end of the list.
- Options (exact names, top level of the body; a misspelled name is ignored and removes your limit): `maxCredits` (whole number of at least 1; when omitted the run can spend up to your current credit balance but never the overage budget, while a `maxCredits` above your balance lets it draw on the overage budget, which is billed to the card), `databaseMatchPercent` (0-100, default 100), `contactTypes` (`phone`, `email` or `either`, the default), `emailResults` (default: the account setting; set `false` to skip the notification email). Never send `null` for an option.
- Runs are asynchronous. Poll `GET /enrichments/{id}` every 30-60 seconds at first, then every few minutes; large lists can take hours. `partial` means the file is ready but a credit limit stopped the run early. `failed` never produces a file.
- Each `POST` creates a new run and there is no undo. If a submit fails or times out, check `GET /enrichments` before sending it again.
## Saved searches, keyword lists and email schedules
- **Filter presets** exist to drive email schedules. `POST /filter-presets` takes `{"name": "...", "filters": {"hash": "..."}}`, where `hash` uses the dashboard's search format (`ni` company-name terms, `ui` URL terms, `si` states, `ci` cities and so on; see `REFERENCE.md`). A preset without a positive filter in that format (a company-name, URL or description term, or one of the user's own positive keyword lists) never sends anything. A hash has no key for `positiveKeywords`: `ni`, `ui`, `cli` and `di` each match only their own column and none of them searches AI Categories, while keywords from a positive keyword list in `nkl` are searched like `positiveKeywords` (Registered URL, Crawled URL, Company Name and AI Categories). To make a schedule send the same leads as a `positiveKeywords` search you previewed, first create a positive keyword list with those keywords (`POST /keyword-lists`), then put its id in `nkl` together with the location keys (for example `"#" + urllib.parse.urlencode({"nkl": "42", "si": "OH"})`); a negative list in `nkl` works like `negativeKeywords`. Presets cannot be edited, and deleting one also deletes its email schedules.
+ **Filter presets** exist to drive email schedules. `POST /filter-presets` takes `{"name": "...", "filters": {"hash": "..."}}`, where `hash` uses the dashboard's search format (`ni` company-name terms, `ui` URL terms, `si` states, `ci` cities and so on; see `REFERENCE.md`). A preset without a positive filter in that format (a company-name, URL or description term, or one of the user's own positive keyword lists) never sends anything. A hash has no key for `positiveKeywords`: `ni`, `ui`, `cli` and `di` each match only their own column and none of them searches AI Categories, while keywords from a positive keyword list in `nkl` are searched like `positiveKeywords` (Registered URL, Crawled URL, Company Name and AI Categories). To make a schedule send the same leads as a `positiveKeywords` search you previewed, first create a positive keyword list with those keywords (`POST /keyword-lists`), then put its id in `nkl` together with the location keys (for example `"#" + urllib.parse.urlencode({"nkl": "42", "si": "OH"})`); a negative list in `nkl` works like `negativeKeywords`. Presets cannot be edited, and deleting one also deletes its email schedules (check `GET /email-schedules` for schedules with that `filterPresetId` and name them to the user first).
**Keyword lists** (`/keyword-lists`) store keyword sets. Create with `{"name": "...", "type": "positive" or "negative", "keywords": ["*dental*"]}`. Lists are not applied to API searches automatically: read a list's `keywords` and pass them as `positiveKeywords` or `negativeKeywords`.
- **Email schedules** (`/email-schedules`) email new matching leads on an interval and spend credits for new leads. Required: `name`, `filterPresetId` (number), `intervalValue` (whole number 1-720), `intervalUnit` (`hours` or `days`) and `recipients` (`[{"email": "rep@company.com"}]`, up to 50). Always create with `"isActive": false`, review with the user, then activate with `PATCH /email-schedules/{id}` and `{"isActive": true}` (needs `--confirm`). An active schedule sends its first email within about 15 minutes, including every lead that currently matches (up to 10,000). Options include `maxLeadsPerEmail` (new leads over this cap, and new leads your credits could not cover, are kept in the account's shared lead reservoir while it has room and sent with a later email; on credit plans, already-received leads over the cap are not kept), `distributionMode` (`full_copy` or `split_evenly`; `fullCopyRecipients` only receive mail when at least 2 recipients are active) and combined-file settings (`REFERENCE.md`). There is no time-of-day setting: a daily schedule sends about 24 hours after the previous send, so the time drifts. `POST /email-schedules/{id}/trigger` sends right away. A `200` does not prove an email went out; compare `lastSent` and `totalSentCount` in `GET /email-schedules` before and after.
+ **Email schedules** (`/email-schedules`) email new matching leads on an interval and spend credits for new leads. Required: `name`, `filterPresetId` (number), `intervalValue` (whole number 1-720), `intervalUnit` (`hours` or `days`) and `recipients` (`[{"email": "rep@company.com"}]`, up to 50). Always create with `"isActive": false`, review with the user, then activate with `PATCH /email-schedules/{id}` and `{"isActive": true}` (needs `--confirm`). An active schedule sends its first email within about 15 minutes, including every lead that currently matches (up to 10,000). Options include `maxLeadsPerEmail` (new leads over this cap, and new leads your credits could not cover, are kept in the account's shared lead reservoir while it has room and sent with a later email; on credit plans, already-received leads over the cap are not kept), `distributionMode` (`full_copy` or `split_evenly`; `fullCopyRecipients` only receive mail when at least 2 recipients are active) and combined-file settings (`REFERENCE.md`). There is no time-of-day setting: a daily schedule sends about 24 hours after the previous send, so the time drifts. `POST /email-schedules/{id}/trigger` sends right away. A `200` does not prove an email went out; compare `lastSent` and `totalSentCount` in `GET /email-schedules` before and after. To stop a schedule, pause it with `{"isActive": false}` rather than deleting it: deleting also deletes its list of recipients who unsubscribed.
## AI helpers
- `POST /ai/suggest-categories` with `companyName`, `companyDescription` and `productService` (optional `companyWebsite`, `excludeCategories`) returns 4-12 suggested customer categories for the user's business. Nothing is saved. Turn the suggestions into keywords yourself (for example "Dentists" becomes `["*dental*","*dentist*"]`).
- `POST /ai/generate-keywords` **deletes every existing keyword list** and regenerates lists from the account's target categories, which can only be set in the dashboard. If `GET /me` shows an empty `targetCategories`, it deletes the lists and generates nothing. Filter presets that point at keyword lists by id (`nkl`, `ukl`, `ckl` or `dkl` in the hash, which dashboard-saved presets also use) silently lose those keywords and exclusions when the lists are deleted, and the same applies to `DELETE /keyword-lists/{id}`: their email schedules can stop sending, or start emailing a broader set of leads and spending credits on them. Check `GET /filter-presets` first and pause any affected schedules. Only use it when the user explicitly wants all lists replaced (needs `--confirm`). Check progress with `GET /ai/keyword-status`.
- `POST /ai/auto-refine/enable` / `disable` with `{"listId": 42}` turn on or off the AI refinement of a keyword list and its paired list; `GET /ai/auto-refine/status?listId=42` shows progress. Enabling resets the list's score history, and the refinement rewrites the list's keywords, which changes what any email schedule using the list sends (pause those schedules first if the user wants to review). A `200` does not guarantee the run started: if `autoRefineEnabled` is true but `refinementStatus` stays `null`, call enable again a few minutes later.
## Integrations: sending leads to a CRM or automation tool
Integrations deliver lead data to the user's own systems: HubSpot, Salesforce and Pipedrive (CRM connections through the provider's sign-in page), and Zapier, n8n, Make, Pipedream, Clay or any HTTPS webhook URL. **Rule 5 applies to every call that sets one up or sends data through it.** Full request and response details are in `REFERENCE.md` section 17.
- **What gets sent:** once an integration is connected, every subscribed event goes out automatically: `lead.created` (one event per lead the user receives for the first time through the dashboard, the API or an email schedule; enrichment results are not sent), `lead.updated` (a lead the user already had is received again), `export.completed`, `email_schedule.sent` and `credit.low_balance`. Lead events carry the business contact details. Webhooks are signed (`X-SMB-Signature: t=<unix-ts>,v1=<hex>`, an HMAC-SHA256 of `"<ts>." + rawBody` with the signing secret).
- **Reading is free and needs no confirmation:** `GET /integrations/providers` (which providers are available now), `GET /integrations`, `GET /integrations/{id}`, `GET /integrations/{id}/deliveries` (newest 50 attempts), and the CRM field and pipeline lists.
- - **Webhook integration:** `POST /integrations/webhook` with `provider` (`zapier`, `n8n`, `make`, `pipedream`, `clay` or `generic_webhook`), `targetUrl` (a public `https` URL), `events` and optional `displayName` (and `apiKey` for Clay, passed with `--body-file`). It needs `--confirm`. The response has the integration and a `signingSecret` that is never shown again: give it to the user so their endpoint can verify signatures, and do not log or store it. If the call fails or times out, the integration may have been created: check `GET /integrations` before trying again. 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. The signing secret cannot be shown again, so if the integration exists but the user did not get its `signingSecret`, delete it and create it again.
+ - **Webhook integration:** `POST /integrations/webhook` with `provider` (`zapier`, `n8n`, `make`, `pipedream`, `clay` or `generic_webhook`), `targetUrl` (a public `https` URL), `events` and optional `displayName` (and `apiKey` for Clay, passed with `--body-file`). It needs `--confirm`. The response has the integration and a `signingSecret` that is never shown again: give it to the user so their endpoint can verify signatures, and do not log or store it. If the call fails or times out, the integration may have been created: check `GET /integrations` before trying again. 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. The signing secret cannot be shown again, so if the integration exists but the user did not get its `signingSecret`: read it with `GET /integrations/{id}`, show the user its provider, destination URLs and events, and with their yes delete it and create it again with the same values (both calls need `--confirm`; the new integration gets a new secret, and events that went to different URLs are restored with a `PATCH` of `subscriptions`).
- **CRM connection:** `POST /integrations/{provider}/connect` (`hubspot`, `salesforce` with optional `{"environment": "sandbox"}`, or `pipedrive`; needs `--confirm`) returns an `authorizationUrl`. The user opens it in a browser within 10 minutes and approves; the CRM account they sign in to is the one connected. Then the integration appears in `GET /integrations` with these events: HubSpot `lead.created` (its `lead.updated` subscription exists but starts switched off), Salesforce `lead.created`, Pipedrive `lead.created` and `lead.updated`. If the API key that asked for the link is revoked before the user approves, the connection fails (`key_revoked`) and nothing is created. A provider that is not set up yet answers `503 provider_unavailable`; an account that already has 25 integrations gets `409 integration_limit`; any other provider name is `404`.
- - **Changing an integration:** `PATCH /integrations/{id}`. Renaming (`displayName`) and pausing (`{"status": "disabled"}`) need no confirmation. `status` accepts only `connected` and `disabled` (`needs_attention` is set by the system; sending it is a `400`, unless the integration already has it). Changing `subscriptions` (this replaces the whole list), `defaultTargetUrl`, `pipedriveDeals` or the header `apiKey`, or re-enabling with `{"status": "connected"}` (from `disabled` or `needs_attention`), sends data to a new place or resumes sending, so it needs the user's yes and `--confirm`. `defaultTargetUrl` (webhook integrations only) sent alone re-points every existing subscription, inactive ones included; to move only some events, send `subscriptions`. A header `apiKey` (Clay) is sent only to the active destination URLs it was set for (a URL on a switched-off subscription never gets it, even after that subscription is switched on, until the owner edits the integration in the dashboard or `apiKey` is sent again), so pointing an integration that has one at any new destination URL (even on the same host: Zapier, Make and Clay put every customer's hooks on one host) needs `apiKey` in the same request (a new value, or `null` to stop sending it), otherwise `400 bad_request`; pass it with `--body-file`. `apiKey` works only on Clay integrations and on webhook integrations that already send one (`400 bad_request` otherwise, and on a CRM); `null` or `""` stops sending it. The integration and its subscriptions are saved together, but a `500` may come after the change was saved: check `GET /integrations/{id}` before repeating a `PATCH`. A re-point onto a URL that another integration of the same provider already uses gives `409 integration_exists`. If the integration changes while the update is being applied (for example the owner pauses it at that moment), the update is applied again on top of that change; a `409 conflict` means it changed again and nothing changed: read it again with `GET /integrations/{id}`, check the change still makes sense with the user, then retry. A `401` "This API key has been revoked" means the key was revoked in that moment and nothing was applied: tell the user and do not retry with that key. `DELETE /integrations/{id}` removes it for good (needs `--confirm`).
+ - **Changing an integration:** `PATCH /integrations/{id}`. Renaming (`displayName`) and pausing (`{"status": "disabled"}`) need no confirmation. `status` accepts only `connected` and `disabled` (`needs_attention` is set by the system; sending it is a `400`, unless the integration already has it). Changing `subscriptions` (this replaces the whole list), `defaultTargetUrl`, `pipedriveDeals` or the header `apiKey`, or re-enabling with `{"status": "connected"}` (from `disabled` or `needs_attention`), sends data to a new place or resumes sending, so it needs the user's yes and `--confirm`. `defaultTargetUrl` (webhook integrations only) sent alone re-points every existing subscription, inactive ones included; to move only some events, send `subscriptions`. A header `apiKey` (Clay) is sent only to the active destination URLs it was set for (a URL on a switched-off subscription never gets it, even after that subscription is switched on, until the owner edits the integration in the dashboard or `apiKey` is sent again), so pointing an integration that has one at any new destination URL (even on the same host: Zapier, Make and Clay put every customer's hooks on one host) needs `apiKey` in the same request (a new value, or `null` to stop sending it), otherwise `400 bad_request`; pass it with `--body-file`. `apiKey` works only on Clay integrations and on webhook integrations that already send one (`400 bad_request` otherwise, and on a CRM); `null` or `""` stops sending it. The integration and its subscriptions are saved together, but a `500` may come after the change was saved: check `GET /integrations/{id}` before repeating a `PATCH`. A re-point onto a URL that another integration of the same provider already uses gives `409 integration_exists`. If the integration changes while the update is being applied (for example the owner pauses it at that moment), the update is applied again on top of that change; a `409 conflict` means it changed again and nothing changed: read it again with `GET /integrations/{id}`, check the change still makes sense with the user, then retry. A `401` "This API key has been revoked" means the key was revoked in that moment and nothing was applied: tell the user and do not retry with that key. `DELETE /integrations/{id}` removes it for good, with its delivery log (needs `--confirm`); to stop it without losing anything, pause it instead.
- **Sending on demand:** `POST /integrations/{id}/push-lead` with `{"leadId": 123}` sends one lead now to this integration (once). A lead the user already received is free, even at a zero balance; a new lead costs 1 credit (`402 insufficient_credits` without credits) and then counts as received, so the user's other integrations subscribed to `lead.created` also get it; re-pushing a lead the user already received sends `lead.updated` to their other integrations subscribed to `lead.updated` (the target itself gets the lead only once, from the push). `{"lead": {...}}` sends the user's own data, free and untracked. Pushes are recorded in `GET /integrations/{id}/deliveries`, CRM pushes included; a push refused before anything is sent (for example "No active subscription for this integration", answered with `status: "failed"`, `tracked: false` and `creditsUsed: 0`) is not recorded, not charged and not added to the export history. `POST /integrations/{id}/test` sends a synthetic test event to the real destination (a CRM gets a real test record that is not removed). `POST /integrations/{id}/deliveries/{deliveryId}/retry` re-sends a past delivery to the URL it originally went to. Test and retry refuse a paused (`disabled`) integration with `400` (re-enable it first); a successful test or retry of a `needs_attention` integration switches it back to `connected` (the owner is emailed). All three need `--confirm`.
- **CRM field mapping:** `GET /integrations/{id}/field-mapping` shows which SMB Sales Boost field goes to which CRM field; `PATCH` changes it, `DELETE` resets it to the defaults and `POST .../field-mapping/test` does a dry run that creates and then deletes test records in the CRM. `PATCH` replaces the whole mapping (a scope you leave out is cleared), takes only the provider's own scopes (HubSpot `contact`, `company`; Salesforce `lead`; Pipedrive `person`, `organization`; another scope is a `400 validation_error`), and needs at least one scope (to reset, use `DELETE`). All but the `GET` need `--confirm`. For Pipedrive, `GET /integrations/{id}/pipedrive/pipelines` and `/pipedrive/deal-fields` help set up automatic deals.
- **Owner safeguards:** the dashboard labels each integration with the API key that created or last re-pointed it. A dashboard change replaces that label only when it adds a destination URL the integration never had; pausing it (also when its key is revoked), and resuming it, switching on more events or switching on a URL it already had in the dashboard, keep it. When an API key creates a webhook integration, connects a CRM, changes where an integration sends data, adds events or re-enables one, the account owner is emailed for each change. During bursts of more than 10 changes in an hour, further changes are combined into summary emails. If the user did not expect that email, they should delete the integration in the Integrations tab and revoke the key in Dashboard > API, where revoking can also pause every integration the key set up.
- **Limits:** at most 25 integrations per account (`409 integration_limit`) and 20 event subscriptions per integration; the same provider and URL twice gives `409 integration_exists`. Integration calls share 60 requests per minute per account with the dashboard and the MCP server (connect: 30 per minute), then `429`. After 5 failed deliveries in a row an integration switches to `needs_attention` and stops until it is re-enabled.
## Account, credits and billing
- `GET /me`: profile, plan, `subscriptionStatus`, the credit balance fields, the `trial` object, and the `autoTopUp` and `overageBudget` settings. `PATCH /me` updates `firstName`, `lastName`, `companyName` and `companyWebsite`.
- `POST /purchase-credits` (needs `--confirm`): buy permanent credits with the card on file, charged immediately. Send `creditCount` (at least 100, at most 5x the plan's monthly credits) or `dollarAmount` (at least 1). Price per credit is the plan's rate in the table above. The script sends an idempotency key and prints it; if the call times out, re-run it within 24 hours with `--idempotency-key` set to that value, so it is not charged twice (after 24 hours, check `permanentCredits` in `GET /me` before buying again). Works during a trial, and is charged immediately. If the user moved to a lower plan in the current paid billing period (not a plan switch during a free trial), that downgrade is still pending and credits bought now expire at the next renewal together with the rest of the balance (auto top-up pauses for this reason, a manual purchase does not). `GET /me` does not show a pending downgrade, so ask the user before buying, and say so when you confirm the price.
- `GET /auto-top-up`, `PATCH /auto-top-up`: automatic credit purchases when the permanent balance falls below a threshold. Enabling requires `triggerType`, `triggerAmount`, `purchaseType` and `purchaseAmount` (and optional `capType`, `capAmount`), replaces the whole configuration (send every field you want to keep) and needs `--confirm`. `{"enabled": false}` turns it off and always works. Not available during a trial.
- `GET /overage-budget`, `PATCH /overage-budget`: lets usage continue after the balance reaches zero, billed to the card once a day at the plan's rate, up to a per-cycle budget (`{"enabled": true, "budgetType": "dollars", "budgetAmount": 50}`; `budgetType` defaults to dollars). Needs `--confirm` to enable. Not available during a trial.
- `POST /subscription/change-plan` (needs `--confirm`) with `{"targetPlan": "starter" | "growth" | "scale"}`:
- **Upgrade:** charges the new plan's full price now, restarts the billing cycle, keeps every unused credit and grants the new allowance immediately.
- **Downgrade:** no charge now. At the next renewal the entire remaining balance (unused, rolled-over and purchased credits) expires before the lower allowance is granted. Warn the user. Moving back to the original plan (or higher) before renewal cancels the pending downgrade and keeps the credits, but it is billed like an upgrade: that plan's full price is charged now and the billing cycle restarts. Confirm the amount with the user first. While a downgrade is pending, a move to a plan between the current plan and the original one (for example Scale, then Starter, then Growth) is not an upgrade: nothing is charged and the whole balance still expires at renewal. `GET /me` does not show whether a downgrade is pending, so ask the user whether they moved to a lower plan in this billing period before you describe the cost.
- **During a free trial**, `mode` decides: `switch_trial` (the default) changes the trial plan with no charge and keeps the trial end date (credits already used stay used); `activate_now` ends the trial, charges the target plan's full price now and grants its full allowance. A declined card returns `402 payment_declined` with `trialStillActive: true`: nothing changed and the trial continues.
- Platinum and Enterprise changes go through support. A `409` means nothing changed; show its `message` (see `REFERENCE.md` for the codes).
- `POST /subscription/cancel` (needs `--confirm`): cancels at the end of the current billing period, and access continues until then. During a trial the card is never charged, but the free trial is used up. It can only be undone in the dashboard before the period ends. Remaining credits are forfeited when the subscription ends. A confirmation email is sent.
## Signing up through the API
New customers can subscribe without the website:
```bash
python3 smb_api.py POST /purchase --body '{"email":"owner@company.com","plan":"growth"}' --confirm
```
- Plans: `starter`, `growth`, `scale` (14-day free trial by default), `platinum`, `enterprise` (no trial, charged at checkout). `"trial": false` skips the trial and charges at checkout.
- The response has a Stripe `checkoutUrl` (valid 24 hours) and a `trial` block (`eligible`, `days`, `credits`, `endsAt`, `note`, and `reason` when not eligible). Before the user opens the link, tell them the plan price and exactly when it is charged: at checkout, or automatically when the trial ends.
- After checkout **the API key is emailed** to that address. If the address already has an account, the purchase must first be confirmed from an email sent to it, and the key follows. Do not use `POST /claim-key`; it belongs to an older flow and cannot succeed.
- An address with an active or trialing subscription gets `409 already_subscribed` (a trialing account should use `change-plan` instead). Each call creates a new checkout; never open two. Limited to 5 per hour.
## Rate limits, errors and retries
| Limit | Applies to |
|---|---|
| 600 requests per minute per API key | Every authenticated endpoint |
| 5 per minute per API key, shared | `POST /leads/export`, the three `POST /lead-export-history/...` calls and `POST /email-schedules/{id}/trigger` |
| 5 per minute per API key, shared | `POST /ai/suggest-categories`, `POST /ai/generate-keywords`, `POST /ai/auto-refine/enable` |
| 10 lead searches at a time per account | `GET /leads`, `GET /leads/preview`, `POST /leads/export` (more returns `429 lead_query_capacity_busy`) |
| 60 per minute per account, shared with the dashboard and the MCP server | Every `/integrations` call; `POST /integrations/{provider}/connect` also 30 per minute |
| 5 per hour / 30 per hour per IP | `POST /purchase` / `POST /claim-key` |
Failed requests count toward the limits. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (an ISO timestamp) and, on a 429 or 503, `Retry-After` (seconds).
| Status | Meaning | What to do |
|---|---|---|
| 400 | Bad request (`bad_request`, `validation_error` with `details`, `invalid_request`); `export_too_large` comes after the credits were charged | Fix the request; for `export_too_large` follow **XLSX limit** under **Exporting files** and do not repeat the export |
| 401 | Missing, invalid or revoked API key | Ask the user to check `SMB_SALES_BOOST_API_KEY` |
| 402 | `insufficient_credits` (rare: credits ran out between check and charge), or a card problem (`payment_failed`, `payment_declined`, `authentication_required`) | Read `error`; for a card problem the user updates their card in the dashboard |
| 403 | No active subscription, or a feature not available on this account or during a trial | Explain; do not retry |
| 404 | Unknown endpoint or id | Check the id |
| 409 | Conflict (`plan_change_in_progress`, `already_subscribed`, `not_ready`, `integration_exists`, `integration_limit`, `conflict` and others); nothing changed | Show the `message`; retry only when it says so (for an integration `conflict`, read the integration again first) |
| 410 | `gone`: export file expired or not stored; `plan_retired` from `POST /purchase` | For `gone`, re-export free via `/lead-export-history/re-export`; for `plan_retired`, pick a current plan |
| 429 | Rate limit or `lead_query_capacity_busy` | Wait `Retry-After` (the script retries by itself, up to 60 s, except on integration calls other than `GET`, where it returns the error with advice) |
| 500 | Server error | On charging calls and integration changes do NOT repeat blindly (see Rule 6); a `500` from `push-lead` may have taken a credit, so check `GET /me` and the deliveries first |
| 502 | `provider_error`: a connected CRM's API failed (field lists, pipelines, mapping test) | Try again later; check the CRM connection in `GET /integrations/{id}` |
| 503 | `service_unavailable`, temporary; `provider_unavailable`: that integration provider is not available yet (no `Retry-After`) | For `service_unavailable` retry after `Retry-After` (the script does this, except on integration calls other than `GET`: check `GET /integrations` or the deliveries first, then repeat once); for `provider_unavailable` tell the user and do not retry |
| 524 | Network edge timeout after about 100 seconds; the server may still finish | Treat like a 500 on charging calls |
Errors look like `{"error": "code", "message": "Human-readable text"}`; the script adds `httpStatus`, and `advice` where a failure may have had an effect.
## Natural language examples
| User says | Call |
|---|---|
| "Find new dental practices in Texas" | Preview, then `GET /leads` with `positiveKeywords: ["*dental*","*dentist*","*orthodont*"]`, `stateInclude: ["TX"]`, `limit`, `maxCredits` |
| "Med spas in Florida with a buying signal this week" | `positiveKeywords: ["*med*spa*","*medical*spa*","*aesthet*","*botox*"]`, `stateInclude: ["FL"]`, `lastBuyingSignalFrom: "rel:7d"` |
| "Auto repair shops in Chicago that just got a new phone number" | `positiveKeywords: ["*auto*repair*","*mechanic*","*body*shop*"]`, `cityInclude: ["Chicago"]`, `buyingSignalTypeFilter: ["Phone Primary"]` |
| "Pet groomers in California, no boarding or kennels" | `positiveKeywords: ["*pet*groom*","*dog*groom*"]`, `negativeKeywords: ["*boarding*","*kennel*"]`, `stateInclude: ["CA"]` |
| "Only leads with an email address" | add `emailPrimaryEmptyFilter: "exclude_empty"` |
| "Well-reviewed restaurants in Atlanta" | `positiveKeywords: ["*restaurant*","*grill*","*bistro*"]`, `cityInclude: ["Atlanta"]`, `minRatingValue: 4.5`, `minReviewCount: 50` |
| "Businesses that registered their domain in the last 3 months" | a keyword filter plus `registrationDateFrom` set to the date 3 months ago as `YYYY-MM-DD` |
| "How many bakeries are in New York?" | `GET /leads/preview`, read `data.pagination.total` |
| "Show me only leads I already bought" | `GET /lead-export-history` (every lead you received, paged with `limit` and `offset`, free). To limit it to one search, run `GET /leads` with that search's filters and `maxCredits: 0` on every page, because each page only keeps the already-received leads among that page's matches |
| "Export the results but spend at most 50 credits" | `POST /leads/export` with `maxCredits: 50` |
| "Export only leads I don't have yet" | `POST /leads/export` with `excludePurchased: true` and a `maxCredits` agreed with the user (every lead in this export costs 1 credit) |
| "Download my export from last week" | `GET /export-history`, then `GET /export-history/{id}/download` |
| "Get me the contact details for these 200 websites" | `POST /enrichments` (state the prices, agree on `maxCredits`, `--confirm`) |
| "Email me new HVAC leads in Ohio every morning" | Create a positive keyword list with the HVAC keywords, then a preset whose hash has `nkl` set to that list's id and `si=OH`, create the schedule with `intervalValue: 1`, `intervalUnit: "days"` and `isActive: false`, review, then activate. There is no time-of-day setting: the first email goes out within about 15 minutes of activation and later ones about a day after the previous send, so the time drifts. Tell the user this instead of promising a fixed time |
| "How many credits do I have?" | `GET /me`, `totalCreditsRemaining` |
| "Buy 1,000 more credits" | Confirm the price, then `POST /purchase-credits` with `creditCount: 1000` and `--confirm` |
| "Upgrade to Scale" | Confirm price and timing, then `POST /subscription/change-plan` with `targetPlan: "scale"` and `--confirm` |
| "I want to sign up for Growth" | Explain the trial and charge date, then `POST /purchase` with `--confirm` |
| "Send every new lead to my Zapier webhook" | Describe the destination host, events and data, get a yes, then `POST /integrations/webhook` with `provider: "zapier"`, the URL and `events: ["lead.created"]` and `--confirm`; hand the `signingSecret` to the user once |
| "Connect my HubSpot" | Explain what will flow to HubSpot, get a yes, then `POST /integrations/hubspot/connect` with `--confirm`, give the user the `authorizationUrl` to open within 10 minutes, then check `GET /integrations` |
| "Which integrations do I have, and are they working?" | `GET /integrations` (status, `consecutiveFailures`, `lastSuccessAt`), then `GET /integrations/{id}/deliveries` |
| "Stop sending leads to Make for now" | `PATCH /integrations/{id}` with `{"status": "disabled"}` (no `--confirm` needed); re-enabling later needs the user's yes |
## smb_api.py reference
| Option | Purpose |
|---|---|
| `--params JSON` / `--params-file PATH` | Query parameters (any method). Lists as JSON arrays. `-` reads stdin. |
| `--body JSON` / `--body-file PATH` | JSON body for POST, PATCH and PUT. |
| `--confirm` | Required for calls that charge money, authorize future charges, send email, spend credits on enrichment, search or export without `maxCredits`, send lead data to an outside destination (integration create, connect, re-point, re-enable, header `apiKey` change, test, push, delivery retry, field-mapping change or test), cancel or delete. Add only after the user approves. `python3 smb_api.py --help` lists every gated call. |
| `--dry-run` | Show the request (URL, body, whether `--confirm` is needed) without sending it. |
| `--compact` | Print only key fields of each lead. |
| `--out NAME` | Also save the full JSON response as `NAME.json` in the output folder. |
| `--output-dir DIR` | Where files are saved. Default: `$SMB_SALES_BOOST_OUTPUT_DIR`, else `/mnt/user-data/outputs` if it exists, else `./smb-sales-boost-files`. |
| `--idempotency-key KEY` | Reuse the key from a timed-out `POST /purchase-credits` (within 24 hours). |
| `--timeout SEC` | Network timeout (default 180). |
| `--no-retry` / `--no-fetch` | Do not retry 429/503 (integration calls other than `GET` are never retried) / print a download link instead of downloading. |
File handling: exports, `/export-history/{id}/download`, `/export-history/{id}/download-url`, the lead-export-history files and `/enrichments/{id}/download` are saved automatically as new files (never overwriting; mode 600), and the script prints the paths. Short-lived download links are used directly and not printed, unless you pass `--no-fetch` (the printed link then works without an API key for a few minutes, so do not share or log it). Exit codes: 0 success, 1 API error (or an unexpected client error), 2 usage error or confirmation required (nothing was sent), 3 network error or timeout, 4 local file error (the script creates the output folder before sending, so a folder problem stops it before anything is charged).
## Security
- **No shell injection:** the script sends structured JSON over HTTPS with Python's standard library; user text never becomes part of a shell command when you use `--params-file` / `--body-file` for text with quotes.
- **Fixed destination:** requests go only to `https://smbsalesboost.com/api/v1`; endpoints are validated (no `..`, no other hosts), redirects are never followed, and the key is never sent to `/purchase` or `/claim-key`. Download links are fetched without the key, over HTTPS, and only from Amazon S3 (`amazonaws.com`) hosts.
- **Safe files:** file names from the server are reduced to a plain base name, limited to `.csv`, `.json` and `.xlsx`, and written only inside the output folder as new files readable only by the user.
- **Key handling:** read from `SMB_SALES_BOOST_API_KEY` and sent only in the `Authorization` header. It is never printed, logged or written to a file. Passing it as the first argument still works but exposes it to other local processes; prefer the environment variable.
- **Confirmation gate:** money, email, deletion, enrichment, uncapped search and export, and integration calls that send lead data outside SMB Sales Boost are refused without `--confirm`. Paths are matched without regard to case, so `/Integrations/5/Push-Lead` is gated like `/integrations/5/push-lead`.
- **One-time secrets:** the script never saves the response of `POST /integrations/webhook` with `--out`, because it holds the one-time `signingSecret`. Pass a body that contains a Clay `apiKey` (on create or on `PATCH /integrations/{id}`) with `--body-file`, not `--body`.
## Not available through the API
These are managed in the dashboard (https://smbsalesboost.com/dashboard): creating or revoking API keys (when revoking a key, the user can choose to also pause every integration that key set up); updating the card or viewing invoices; undoing a cancellation; target categories for keyword generation; the default "email enrichment results" setting. A remote MCP server is also available at `https://smbsalesboost.com/mcp` (details: https://smbsalesboost.com/.well-known/mcp/server-card.json).