Skip to content

Spaw Node.js SDK: email, phone, IP and postal address checks

updated · October 14, 2026

The official spaw package for Node.js 18 and newer wraps every Spaw endpoint with typed results, TypeScript declarations and one error class per API code.

Mechanism
Official client, zero dependencies, ESM
Status
Built, awaiting publication on npm as spaw
Auth
Secret API key passed to the constructor
Cost
1 credit per answered lookup (five IP lookups to the credit); undeliverable answers free within fair use
Requires
Node.js 18 or newer

The Node.js client is a thin wrapper over the REST API: one method per endpoint, the API's data and meta blocks returned exactly as documented, TypeScript declarations, and an error class for every code. It has no dependencies, because it uses the fetch built into Node.js 18 and newer, and it is an ES module.

The package is built and will be published on npm as spaw. Until the listing is live, a plain fetch against the API reference does the same job. For code that runs in a browser, use the form helper with a publishable key instead; the secret key this client needs must never ship to a page.

Install and verify one address

  1. Create a secret key on the API keys page and put it in an environment variable.
  2. Install the package once it is published:
npm install spaw
  1. Verify an address:
import Spaw from 'spaw';

const spaw = new Spaw({ apiKey: process.env.SPAW_API_KEY });

const result = await spaw.verifyEmail('[email protected]');
console.log(result.data.deliverable);  // "deliverable", "risky" or "undeliverable"
console.log(result.data.reason);       // e.g. "catch_all", or null when deliverable
console.log(result.data.risk_score);   // 0–100, a sum of published weights
console.log(result.meta.credits_used); // 1 for a fresh answered lookup, 0 otherwise

result.data carries all 46 documented fields, including did_you_mean, mx_provider and smtp_checked_at, and every reason value is explained on the verdict reasons pages.

Everything the client does

Method Endpoint
verifyEmail(email, options) POST /email, with callbackUrl and callbackSecret for async settling
verifyEmails(emails) POST /email/batch, up to 50
domain(domain) GET /email/domain/{domain}
listBulkJobs({ page, status, since, until }), previewBulkJob(emails, { csv, webhookUrl }), startBulkJob(draftId, { mapping, idempotencyKey }), createBulkJob(emails, { webhookUrl }), getBulkJob(id), cancelBulkJob(id), resumeBulkJob(id), redeliverBulkWebhook(id), downloadBulkResults(id, { variant, format }) the bulk endpoints, up to 100,000 addresses; a run that stopped on an empty balance is resumed from the top of its stored list
listSuppressions({ page, value, source, reason, since, until, perPage }), addSuppressions(emails), removeSuppression(id), removeSuppressions(values) the suppression list, by id or by the address itself (up to 1,000 per call)
reportFeedback(items), feedbackSummary() delivery outcomes and measured accuracy
account(), ledger({ since, until, reason, via, apiTokenId, page, perPage }), requestLog(requestId), dataStatus() the live credit balance, the credit ledger, and one request read back by the id it answered with
sendTestWebhook(url, event) POST /webhooks/test, a real signed delivery to a URL you name, free, so a signature check can be written before a paid run depends on it
verifyWebhook(rawBody, header, secret, { toleranceSeconds }) checks a delivery you received: the digest AND the timestamp in X-Spaw-Signature-V2, compared in constant time. Not an API call
validatePhone(phone, { country, hlr, clientIp, callWindow }), validatePhones(phones, { country, hlr, callWindow }) POST /phone and POST /phone/batch, up to 1,000 numbers
phoneBlockReport(country, prefix) GET /phone/block/{country}/{prefix}, the regulator's own allocation table for one range
listPhoneBulkJobs({ page, … }), previewPhoneBulkJob(phones, { csv, country, hlr }), startPhoneBulkJob(draftId, { mapping }), createPhoneBulkJob(phones, { country, webhookUrl }), getPhoneBulkJob(id), cancelPhoneBulkJob(id), resumePhoneBulkJob(id), redeliverPhoneBulkWebhook(id), downloadPhoneBulkResults(id, { variant, format }) the bulk phone endpoints, up to 100,000 numbers
listPhoneSuppressions({ page, value, country, … }), addPhoneSuppressions(items, { country }), removePhoneSuppression(id), removePhoneSuppressions(values, { country }) the phone suppression list; a value is read to E.164 before it is matched
getPhonePolicy(), updatePhonePolicy({ maxRiskScore, blockSignals, allowedCountries, blockedCountries, blockedLineTypes, requireReachable, blockOptedOut }), resetPhonePolicy() the account's SMS policy behind ok_to_send and blocked_by on every phone answer (GET, PUT, DELETE /phone/policy); a fraud and deliverability decision, not consent and not a do-not-call scrub
lookupIp(ip, { privacy, abuseContact, networkDetails, hostname, browserTimezone, fields }), lookupMyIp(), lookupIps(ips) POST /ip, GET /ip/me, POST /ip/batch, up to 1,000 addresses; browserTimezone fills timezone_matches, fields keeps only the named keys
lookupAsn(asn), lookupIpRange(prefix) GET /ip/asn/{asn} (every prefix of one autonomous system) and GET /ip/range?prefix=… (what the feeds say about a whole prefix)
listIpLists(), createIpList({ name, kind, entries }), getIpList(id), updateIpList(id, changes), deleteIpList(id) your own allow and deny lists, account-wide, applied to every lookup: an allow-list hit zeroes the score, a deny-list hit tops it
listIpBulkJobs({ page, … }), previewIpBulkJob(ips, { csv, privacy }), startIpBulkJob(draftId, { mapping }), createIpBulkJob(ips, { webhookUrl, privacy }), getIpBulkJob(id), cancelIpBulkJob(id), resumeIpBulkJob(id), redeliverIpBulkWebhook(id), downloadIpBulkResults(id, { variant, format }) the bulk IP endpoints, up to 100,000 addresses
reportIpFeedback(items), ipFeedbackSummary() IP outcomes (abuse, fraud, bot, chargeback), free
getEmailPolicy(), updateEmailPolicy({ maxRiskScore, blockSignals, minMailboxConfidence }), resetEmailPolicy() the account's sending policy behind ok_to_send and blocked_by on every email answer (GET, PUT, DELETE /email/policy)
listDomainMonitors(), createDomainMonitor({ name, domains, cadence, webhookUrl, webhookEvents }), getDomainMonitor(id), updateDomainMonitor(id, changes), runDomainMonitor(id), deleteDomainMonitor(id) monitored domain lists: up to 500 domains re-checked daily, weekly or monthly for mail-setup drift (/email/domain-monitors)
listIpMonitors(), createIpMonitor({ name, ips, cadence, webhookUrl, webhookEvents }), getIpMonitor(id), updateIpMonitor(id, changes), runIpMonitor(id), deleteIpMonitor(id) monitored IP lists with blocklist alerts, changeable in place and postable to a webhook
verifyAddress(address, { country, deliverability, fields }), verifyAddresses(addresses, { country }) POST /address and POST /address/batch, up to 100 addresses; the separated fields include building, fields keeps only the named keys, and every answer carries the 61 fields incl. unit_check, looks_placeholder, seen_by_you, ok_to_ship and blocked_by
listAddressBulkJobs({ page, … }), previewAddressBulkJob(addresses, { csv, country, deliverability }), startAddressBulkJob(draftId, { mapping }), createAddressBulkJob(addresses, { country, deliverability, webhookUrl, idempotencyKey }), getAddressBulkJob(id), cancelAddressBulkJob(id), resumeAddressBulkJob(id), redeliverAddressBulkWebhook(id), downloadAddressBulkResults(id, { variant, format }) the bulk address endpoints, up to 100,000 addresses
addressStreets(country, postalCode) GET /address/streets, every street a national register lists inside a postcode, 1 credit
addressesAtPostcode(postcode, { country }) GET /address/postcode/{postcode}: the same streets from an open register, or a licensed partner's delivery points where one is configured
getAddressPolicy(), updateAddressPolicy({ maxRiskScore, blockSignals, requireExists }), resetAddressPolicy() the account's shipping policy behind ok_to_ship and blocked_by on every address answer (GET, PUT, DELETE /address/policy)
listAddressMonitors(), createAddressMonitor({ name, addresses, cadence, country, deliverability, webhookUrl, webhookEvents }), getAddressMonitor(id), updateAddressMonitor(id, changes), runAddressMonitor(id), deleteAddressMonitor(id) monitored address lists, alerting on newly flagged addresses
reportAddressFeedback(items, { country }), addressFeedbackSummary() postal outcomes (delivered, returned, moved, refused), free and never shared between accounts
listAddressSuppressions({ page, value, country, … }), addAddressSuppressions(items, { country }), removeAddressSuppression(id), removeAddressSuppressions(values, { country }) the address suppression list, up to 10,000 per import; a value is folded against its country before it is matched
verifyEntity(identifier, { type, country }), verifyEntities(identifiers) POST /entity and POST /entity/batch, up to 500 identifiers read against locally synced registers
listEntityMonitors(), createEntityMonitor({ name, identifiers, cadence, webhookUrl, webhookEvents }), getEntityMonitor(id), updateEntityMonitor(id, changes), runEntityMonitor(id), deleteEntityMonitor(id) monitored identifier lists, alerting when an entity is dissolved or its registration lapses
listMonitors(), createMonitor({ name, emails, cadence, webhookUrl, webhookEvents }), getMonitor(id), updateMonitor(id, changes), runMonitor(id), deleteMonitor(id), and the phone equivalents monitored email and phone lists; an update sends only the keys you pass, and { webhookUrl: null } removes the webhook
checkConsistency({ email, phone, country, ip, address }) POST /consistency, any subset of the four signals compared against each other
listConsistencyBulkJobs(), createConsistencyBulkJob(items, { country, webhookUrl }), previewConsistencyBulkJob(items, { csv, country }), startConsistencyBulkJob(draftId, { mapping }), getConsistencyBulkJob(id), cancelConsistencyBulkJob(id), resumeConsistencyBulkJob(id), redeliverConsistencyBulkWebhook(id), downloadConsistencyBulkResults(id, { variant }) the same check over a whole list, queued: a row costs the sum of the legs it ran and the comparison is free

A batch keeps the API's shape: data.results holds one { index, input, data, meta } per address in input order — index is its place in the list you sent and input the value as you sent it, because the answer carries the normalized form, and meta.stopped_reason is set instead of throwing when the balance runs out part-way, so paid results are never lost. A bulk job returns its webhook_secret once; keep it, because it signs the completion webhook. verifyWebhook(rawBody, header, secret) is the other half: pass the raw body and the X-Spaw-Signature-V2 header and it answers whether the delivery is ours, unmodified and recent. It checks the timestamp as well as the digest, which is the point of the header — a signature over the body alone never goes stale, so a captured delivery could be posted back at you a year later and still verify. The original X-Spaw-Signature, with no timestamp in it, is sent beside it until 1 March 2027 and is deliberately not accepted by this call.

A delivery that failed is not lost: redeliverBulkWebhook(jobId) (and its phone, IP, address and cross-signal twins) posts that run's completion webhook to your URL again, for the day your endpoint was down for the half-minute the three attempts cover. The body is byte-for-byte the one the first delivery carried, rebuilt from the run's own frozen row, so the two reconcile — but the signature is stamped afresh, and has to be, because verifyWebhook refuses anything whose timestamp is more than five minutes old. Verify a redelivery with the same call you verify everything else with. Nothing deduplicates it for you: a receiver that gets both sees the event twice, and job.id is the key to deduplicate on. A run created without a webhook_url raises ConflictError with code WEBHOOK_NOT_CONFIGURED and one that has not settled JOB_NOT_FINISHED; a run whose last delivery succeeded is not refused, because a receiver can lose a message it acknowledged. The delivery is queued rather than made while you wait — one attempt takes half a minute against an endpoint that is down — so the call resolves 202 and the run it hands back reads webhook_status: "pending" until a worker has an answer; read the outcome with getBulkJob(jobId). It is free and capped at ten a minute per signed-in user, neither per key nor per account.

Addresses

verifyAddress takes the address as one written line or as an object of the separated fields (address_line1, locality, postal_code and the rest), always with a country: that is what decides which country's format it is read under, and it is never guessed. The answer splits the address into components, formats it the way that country writes addresses, and says what open data can establish — the postcode's status and centroid, whether the town matches it, what kind of delivery point it is, how close a national address register comes to it, and how many companies are registered there. AddressResult types every field.

const address = await spaw.verifyAddress('10 Downing Street, London SW1A 2AA', { country: 'GB' });
address.data.formatted;        // the address as that country writes it
address.data.postcode_status;  // "live", "terminated", or null where no directory covers the country
address.data.address_type;     // street, po_box, private_mailbox, general_delivery, …
address.data.risk_signals;     // the weights behind risk_score

await spaw.verifyAddresses(addresses, { country: 'GB' });  // up to 100 per call; an item's own country wins

For a whole list, queue a bulk run instead of looping: up to 100,000 rows in the background, billed per row on exactly the single-lookup rules, with in-list repeats free and counted in duplicate_count.

Read the list before you start it: the previewBulkJob() call (and its phone, IP and address twins) answers what we made of it — which column filled which field and whether a heading named it or we guessed, the first rows as parsed, the repeats, and credits_upper_bound — and queues nothing. That figure is a CEILING, never a quote: a row can be free for reasons no preview can know in advance, so a run usually costs less and never more. Start it with the startBulkJob() call, correcting a column we read wrongly with mapping.

const { job } = (
    await spaw.createAddressBulkJob(addresses, { country: 'GB', idempotencyKey: 'addresses-2026-09-05' })
).data;
await spaw.getAddressBulkJob(job.id);              // status, counts, credits_used
await spaw.downloadAddressBulkResults(job.id);     // or { variant: 'invalid' } / 'high'

A field is null when its dataset does not cover the country, which means "not evaluated" and never "no". exists is true only on a premise match and false only under a register whose coverage is complete: a register whose coverage is partial, because it covers part of the country or cannot vouch for every address, cannot make an address's absence mean anything, so it answers null. is_mail_drop is a heuristic built from mailbox operators' own published branch lists and from how many companies are registered at an address, never a postal service's own agency flag; mail_drop_source says which of the two fired.

One answer is licensed and not enabled. deliverability: true would ask a partner whether post actually arrives and whether the delivery point is a mail-receiving agency, vacant or residential; until one is configured deliverability_checked comes back false, the four fields stay null and the premium credits are charged only where a partner answered. The streets inside a postcode are an open answer wherever a national register with a street listing is installed: addressStreets(country, postalCode) lists them for 1 credit, addressesAtPostcode answers the same streets as one entry each (a licensed partner's delivery points, 2 credits, where one is configured), and a country neither covers (Italy, for one), and a postcode the listing cannot speak for — a Northern Irish BT, forces BFPO or non-geographic BX code, or a German code outside Berlin — rejects with NotEnabledError (ADDRESS_LIST_UNAVAILABLE), free; an ordinary British postcode answers its district's streets from Ordnance Survey's Open Names, never its buildings. A postcode the register lists no street at rejects with NotFoundError (ADDRESS_STREETS_NOT_FOUND), free.

A saved list of up to 500 addresses is re-read on a weekly or monthly cadence — there is no daily one, because the registers and postcode directories an address is measured against are republished weekly at best. Every run answers each address ok, one of the flagged invalid, not_in_register, terminated_postcode, mail_drop and high_risk, or unverifiable, which means the address stood as written but no dataset installed on this server covers it well enough to check — a limit of the data, never a property of the address, and never an alert. An email goes out only for an address that was already known and unflagged and became flagged; a first run and a newly added address only set the baseline, and one that stays flagged never alerts twice. Runs bill per address exactly like a single lookup.

const { monitor } = (await spaw.createAddressMonitor({ name: 'Supplier sites', addresses, cadence: 'monthly', country: 'GB' })).data;
(await spaw.getAddressMonitor(monitor.id)).data.monitor.results;  // { address: { status, flags, changed, … } }
await spaw.runAddressMonitor(monitor.id);                         // re-check now

Tell the service what happened to the post you sent and it keeps your own suppression list. Outcomes are delivered, returned, moved and refused. A moved or refused report suppresses the address at once; a second returned not followed by a later delivered suppresses it; a delivered report clears an entry the feedback put there, and never touches one you imported. Batch, bulk and monitor runs answer a suppressed address free with meta.suppressed true, a single lookup never consults the list, and drops an entry your reports added once they no longer stand behind it (a delivery since, or reports aged out); an import leaves only when you remove it; and an entry your reports added is re-verified rather than served once it is older than 90 days, while an import never goes stale.

await spaw.reportAddressFeedback([                     // free, and never logged as a lookup
    { address: '10 Downing Street, London SW1A 2AA', outcome: 'returned' },
    { address_line1: 'Postfach 10 01 01', country: 'DE', outcome: 'moved' },
], { country: 'GB' });
(await spaw.addressFeedbackSummary()).data.outcomes;   // your own counts, by outcome

await spaw.addAddressSuppressions(deadAddresses, { country: 'GB' });  // up to 10,000, free
(await spaw.listAddressSuppressions()).data.suppressions;
await spaw.removeAddressSuppression(suppressionId);    // read fresh next time

Unlike the IP and phone products, address feedback is never shared between accounts. An IP is a network endpoint and a phone number is a handset, but a postal address is where somebody lives, and a shared "post fails here" database would be a database about residences. Your reports raise the risk of your own later lookups of an address and nobody else's, through reported_returned (+50, dataset address-feedback), which is scored while your most recent report for the address is returned, moved or refused rather than delivered.

Businesses

Business verification reads one identifier — a Legal Entity Identifier or a UK company number — against the registers this server holds, which is the check you want before signing with a counterparty or when a supplier list needs going over again. Both registers are bulk files synced locally, so no third party sits in the request path and nothing about a lookup leaves the server; the LEI reference data is published under a CC0 public-domain dedication, and Spaw is not affiliated with its publisher. A company the national register records as dissolved is the strongest signal the product carries: a dissolved company no longer exists as a legal person and cannot enter a contract. A monitor that stops being run is deleted and the identifiers it stored go with it — prunes_after_days on every monitor payload says how long that takes — because an identifier can belong to a sole trader as easily as to a company.

Cross-signal consistency

checkConsistency runs any subset of the four signals in one call and reports where their answers agree and where they do not: it is built for signup, onboarding and marketplace vetting, the moment before there is an order or a card and the contact details are all you have. Each signal bills exactly as its own single lookup, and the analysis on top is free, because every comparison is arithmetic over fields the four pipelines already computed. There is no score and no verdict — you get named flags and both sides of every comparison quoted, because a difference between two fields is a fact about the fields and not a judgement about a person.

checkConsistency is one record at a time. previewConsistencyBulkJob and startConsistencyBulkJob run the same check over a list of up to 100,000 rows, so scoring a signups export is one upload rather than four and a spreadsheet join afterwards. One header row is read as an email address, a phone number, an IP address and the address fields at once, and a row that resolves only two of the four is a perfectly good consistency check of those two.

The preview prices the run against the SIGNALS the columns feed rather than one credit a row — a file reaching only an email address and a phone number answers credits_per_row 2, not 4 — and it is still a ceiling and never a quote.

const draft = await spaw.previewConsistencyBulkJob(undefined, { csv, country: 'GB' });
draft.data.draft.mapping;             // { '1': 'email', '2': 'phone', '3': 'ip', '4': 'address_line1', … }
draft.data.draft.credits_upper_bound; // the most it can cost, never what it will cost

const job = await spaw.startConsistencyBulkJob(draft.data.draft.id);
const rows = await spaw.downloadConsistencyBulkResults(job.data.job.id, { variant: 'flagged' });

A result row is the comparison, not four lookups pasted together: signals_requested, signals_answered, country_agreement, flags, one cell per comparison, distance_km, and the few per-product fields those comparisons read. A comparison cell reads agree, differ, not_compared, missing_value, low_confidence, or nothing at all — an empty cell is a comparison never attempted, because the row did not carry both of its signals.

const report = await spaw.checkConsistency({
    email: '[email protected]',
    phone: '+442079460018',
    country: 'GB',                 // the region hint for the phone number only
    ip: '81.2.69.142',
    address: { address: '10 Downing Street, London SW1A 2AA', country: 'GB' },
});
report.data.consistency.flags;             // e.g. ["ip_country_differs_from_address_country"]
report.data.consistency.country_agreement; // "agree", "differ", or null when none could be made
report.data.signals.ip.data;               // each product's own result, unchanged
report.meta.credits_used;                  // the sum of the per-signal costs

Handle errors

Every failure throws a subclass of SpawError carrying code, message, httpStatus and requestId; no credits are spent on a failed request.

import Spaw, { InsufficientCreditsError, RateLimitedError, SpawError } from 'spaw';

try {
    const result = await spaw.verifyEmail('[email protected]');
} catch (error) {
    if (error instanceof InsufficientCreditsError) {
        // nothing ran and nothing was billed
    } else if (error instanceof RateLimitedError) {
        // error.retryAfter seconds, from the Retry-After header
    } else if (error instanceof SpawError) {
        console.error(error.code, error.httpStatus, error.requestId);
    }
}

AuthenticationError maps UNAUTHENTICATED, ValidationError maps VALIDATION_FAILED with errors keyed by field, NotFoundError covers a bulk job or suppression entry that is not on the account, and SpawConnectionError means the API could not be reached. Pass maxRetries: 2 to the constructor to retry 429 and 5xx answers automatically, honouring Retry-After.

What to do with the verdict

Branch on deliverable. Keep deliverable addresses, drop undeliverable ones, and route risky ones by reason: refuse disposable and likely_typo, decide role per use case, and treat catch_all as an address whose mailbox cannot be confirmed rather than a bad one, using mailbox_confidence as the threshold. When did_you_mean is set, offer it back to the user.

Cost

One credit per fresh deliverable or risky verdict, and one per five IP lookups. Unverified answers, invalid input, repeats within seven days and the spaw.test test addresses cost nothing, undeliverable verdicts cost nothing within a fair-use allowance, and every account gets 100 free credits a month. Point your test suite at [email protected] and its siblings: fixed answers, no credits, never logged.

Related

markdown version: /integrations/node-sdk.md

Verify addresses the same way

The Spaw API runs every check described here on each lookup, with an SMTP handshake that never sends mail. 10 free lookups a month, no card required.

Get your API key

More integrations