Install
openclaw skills install @tomsonx232/smb-sales-boost-skillFind, 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,
openclaw skills install @tomsonx232/smb-sales-boost-skillNote 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.mdin this folder.
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.
https://smbsalesboost.com/api/v1other). Any database value you send resolves to it, so you never need to choose one.403 forbidden. New customers can subscribe through the API (see Signing up through the API).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).smbk_.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.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.
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.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.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.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.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.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.REFERENCE.md. API key management, billing details and undoing a cancellation are dashboard-only (see Not available through the API).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:
GET /leads or exported by POST /leads/export.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).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.
positiveKeywords: ["*dental*", "*dentist*", "*orthodont*"] and stateInclude: ["TX"].GET /leads/preview. Read data.pagination.total for the match count and look at a few results (contacts are masked).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.creditsUsed and the balance (creditsRemaining, or GET /me if the page was empty).# 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.
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.
{"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 }}
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.(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.POST /leads/export creates a file from a search and saves it to the output folder automatically.
python3 smb_api.py POST /leads/export --body '{"filters":{"positiveKeywords":["*med*spa*","*aesthet*","*botox*"],"stateInclude":["FL"],"lastBuyingSignalFrom":"rel:30d"},"maxResults":1000,"maxCredits":300}'
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.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.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.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).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.
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:
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.
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).
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)
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.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.POST creates a new run and there is no undo. If a submit fails or times out, check GET /enrichments before sending it again.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. To stop a schedule, pause it with {"isActive": false} rather than deleting it: deleting also deletes its list of recipients who unsubscribed.
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 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.
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).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.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).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.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.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.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.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.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"}:
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.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.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.New customers can subscribe without the website:
python3 smb_api.py POST /purchase --body '{"email":"owner@company.com","plan":"growth"}' --confirm
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.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.POST /claim-key; it belongs to an older flow and cannot succeed.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.| 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.
| 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 |
| 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).
--params-file / --body-file for text with quotes.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..csv, .json and .xlsx, and written only inside the output folder as new files readable only by the user.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.--confirm. Paths are matched without regard to case, so /Integrations/5/Push-Lead is gated like /integrations/5/push-lead.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.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).