Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# CLAUDE.md

Zapier integration for the BatchData API. zapier-platform-core 19.2.1, Node 22 or newer. See README.md.

## Checks

- `npm test` runs the unit tests (node --test, nock).
- `npx zapier-platform validate` must show no errors.

## Conventions

- Every `z.request` goes through the central timeout: the `setRequestTimeout` beforeRequest middleware and `translateTimeouts` in `middleware.js` (`REQUEST_TIMEOUT_MS` in `lib/constants.js`). Do not set a timeout at a call site. Wrap every new perform function with `translateTimeouts`.
- Never auto-retry billed endpoints. Do not throw `ThrottledError` for a timeout or any error after BatchData may have processed the request.
- Never call live APIs in unit tests. Use nock. Live tests run only with `npm run test:live`.
- Never commit tokens, `.env` files, or Zapier deploy keys.
15 changes: 12 additions & 3 deletions PUBLISH-CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
# Zapier public-review checklist: BatchData (app 248744)

State as of **v0.4.0** (pushed to Zapier, private). Do **not** run `zapier-platform promote` or submit the Publish form until every item in section 2 is done.
State as of **v0.4.1** (local; not yet pushed). v0.4.0 was submitted for public review Oct 29, 2026.

## 0. Zapier review feedback (Abdullah U., partners@zapier.com, Nov 4, 2026; reminder Nov 7; ticket W614ZP-P52RM)

Two items block approval:

1. **Test account** for `integration-testing@zapier.com`: non-expiring, password Zapier support can change, every feature the modules use (incl. paid ones). Not code: see section 3. Still open.
2. **Connection label**: the fallback returned `BatchData API token`. Fixed in v0.4.1 (`authentication.js`): fallback is now `API token` (short token) or `API token missing`; primary stays `Token ending XXXX`. Test added. Push 0.4.1, then reply in the ticket thread.

The reminder says the ticket closes if there is no reply within a few days.

## 1. Already done in code / via CLI

| Item | Status |
|---|---|
| `zapier-platform validate` | 16 checks passed, 0 failed, 0 publishing tasks, 2 general warnings. Both are T003 (date/time format needs a Zap) on the two native triggers `new_search_session` and `new_session_property`. They clear once each trigger has a successful live Zap (Zaps 27 and 28 below). |
| `npm test` | 86 tests: 66 pass, 0 fail, 20 live tests skipped by default (`npm run test:live` runs them, billed). |
| `npm test` | v0.4.1: 87 tests: 67 pass, 0 fail, 20 live tests skipped by default (`npm run test:live` runs them, billed). |
| Property Monitor modules | Five modules hidden with `display.hidden: true` (`lib/modules.js`, `HIDDEN_MODULES`). Code kept intact. A test pins the hidden list and the visible count of 30. |
| Inputs ending in `_id` | Have dynamic dropdowns (D004). |
| Help text, trigger descriptions, sample data | On every module (samples/*.json). |
| Connection label | "Token ending XXXX". The key is never shown in full. |
| Connection label | "Token ending XXXX"; fallback "API token" / "API token missing" (no app name, per Zapier review). The key is never shown in full. |
| Metadata set through `zapier-platform register` | Title, description, homepage, audience private, role employee, category sales-crm, no marketing emails. |
| Logo | logo.png, 256x256 RGBA, uploaded with push. |
| Customer email | support@batchservice.com only. |
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Zapier integration for the [BatchData](https://batchdata.io) API. It covers prop

Support: support@batchservice.com

> **Status: v0.4.0, pushed to Zapier as a private integration (app 248744), not yet submitted for public review.** It passes `zapier-platform validate` with no errors or warnings and its local tests. Most operations were smoke-tested live; monitors and the instant trigger were not (see [What was and was not verified](#what-was-and-was-not-verified)).
> **Status: v0.4.1 (connection-label fix from Zapier review, Nov 2026). v0.4.0 is on Zapier (app 248744) and in public review; 0.4.1 must be pushed before replying to the reviewer.** It passes `zapier-platform validate` with no errors or warnings and its local tests. Most operations were smoke-tested live; monitors and the instant trigger were not (see [What was and was not verified](#what-was-and-was-not-verified)).

![BatchData](logo.png)

Expand Down Expand Up @@ -87,6 +87,10 @@ Sends any `/api/...` request with the connected token. It refuses, before any ca
- **Asynchronous endpoints.** Not exposed.
- A **custom monitor webhook URL.** Only the trigger's own hook URL is ever used.

## Changelog 0.4.3

Every BatchData request now times out after 25 seconds with a clear "Timeout" error, before Zapier's 30-second task limit. The app does not retry a timed-out request automatically, because BatchData may have processed and billed it.

## Development

```bash
Expand Down Expand Up @@ -125,9 +129,9 @@ npx zapier-platform login # interactive; or export ZAPIER_DEPLOY_K
npx zapier-platform register "BatchData" # first time only; creates .zapierapprc (commit it)
npx zapier-platform validate
npm test
npx zapier-platform push # uploads the version in package.json (0.4.0)
npx zapier-platform push # uploads the version in package.json (0.4.1)
npx zapier-platform invite you@example.com # optional: test with a few users first
npx zapier-platform promote 0.4.0 # starts review for public listing
npx zapier-platform promote 0.4.1 # starts review for public listing
```

Before promoting: add a test account (a sandbox API token from app.batchdata.com, which returns mock data at no cost), and review the listing text in the Zapier editor. The icon is `logo.png` (256x256, from the official BatchData logo). The first push must use a version that is not already live.
Expand Down
9 changes: 6 additions & 3 deletions authentication.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { API_HOST, SUPPORT_EMAIL } = require('./lib/constants');
const { translateTimeouts } = require('./middleware');

// The connection test posts an EMPTY address batch. A valid token gets HTTP 400
// (empty batch rejected before any lookup). An invalid token gets 401 or 403.
Expand Down Expand Up @@ -53,10 +54,12 @@ module.exports = {
'Create an API token in your BatchData account (BatchData dashboard > API). See [the BatchData developer docs](https://developer.batchdata.com). Use a token with access to the endpoints you plan to use. Each request is billed to your account.',
},
],
test,
test: translateTimeouts(test),
// The test response carries no account identity, so label by the token's last 4 characters.
// Zapier shows the app name beside every connection, so the label never repeats "BatchData".
connectionLabel: (z, bundle) => {
const key = String((bundle.authData && bundle.authData.api_key) || '');
return key.length >= 8 ? `Token ending ${key.slice(-4)}` : 'BatchData API token';
const key = String((bundle.authData && bundle.authData.api_key) || '').trim();
if (!key) return 'API token missing';
return key.length >= 8 ? `Token ending ${key.slice(-4)}` : 'API token';
},
};
4 changes: 2 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ const { version } = require('./package.json');
const { version: platformVersion } = require('zapier-platform-core');

const authentication = require('./authentication');
const { addBearerToken, handleErrors } = require('./middleware');
const { addBearerToken, handleErrors, setRequestTimeout } = require('./middleware');
const { searches, creates, triggers } = require('./lib/modules');

module.exports = {
version,
platformVersion,
flags: { cleanInputData: false },
authentication,
beforeRequest: [addBearerToken],
beforeRequest: [addBearerToken, setRequestTimeout],
afterResponse: [handleErrors],
triggers,
searches,
Expand Down
2 changes: 1 addition & 1 deletion lib/builders.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const buildSearchBody = (input) => {
const extra = parseJsonObject(input.additional_criteria, 'Additional search criteria');
const searchCriteria = deepMerge(extra, structured);

const take = isBlank(input.take) ? 25 : Number(input.take);
const take = isBlank(input.take) ? 1 : Number(input.take);
if (!Number.isInteger(take) || take < 0 || take > 100) {
throw new InputError('Max results must be a whole number from 0 to 100');
}
Expand Down
30 changes: 26 additions & 4 deletions lib/catalog-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ const { outputFieldsFromSample } = require('./outputFields');

const { InputError, buildRequest, extractResult, checkApiRequest } = engine;

// Zapier-only cost overrides on the shared catalog (the vendored catalog.json stays unchanged).
// Searches and actions hand later steps only their first result, so billed ones default to 1.
// The billed polling trigger defaults to 5 per check.
const ZAPIER_TAKE_DEFAULTS = {
get_permits: [1, 'Whole number from 1 to 100. Default 1. Billed per result. Later Zap steps use only the first result unless you turn on line items.'],
investor_buybox: [1, 'Whole number from 1 to 100. Default 1. Billed per result. Later Zap steps use only the first result unless you turn on line items.'],
investor_properties: [1, 'Whole number from 1 to 100. Default 1. Billed per result. Later Zap steps use only the first result unless you turn on line items.'],
new_session_property: [5, 'Whole number from 1 to 100. Default 5. You are billed per property delivered on each check.'],
};
for (const op of [...catalog.ops, ...catalog.triggers]) {
const override = ZAPIER_TAKE_DEFAULTS[op.key];
const field = override && (op.fields || []).find((f) => f.key === 'take');
if (field) [field.default, field.help] = override;
}
// Polling triggers that bill each record they fetch. Zapier's first poll only records existing ids
// (isPopulatingDedupe), and a session never delivers a property twice, so that poll would bill
// properties the Zap never receives. Skip it, and fetch one record when the editor loads a sample.
const BILLED_POLLING = new Set(['new_session_property']);

// Zapier requires {value, sample, label} for labelled choices.
const normalizeChoices = (choices) =>
choices.map((choice) =>
Expand Down Expand Up @@ -138,7 +157,12 @@ const pollingTrigger = (op) => ({
type: 'polling',
inputFields: inputFieldsFor(op),
perform: async (z, bundle) => {
const records = await callCatalogOp(z, bundle, op);
const meta = bundle.meta || {};
if (BILLED_POLLING.has(op.key) && meta.isPopulatingDedupe) return [];
const call = BILLED_POLLING.has(op.key) && meta.isLoadingSample
? { ...bundle, inputData: { ...(bundle.inputData || {}), take: 1 } }
: bundle;
const records = await callCatalogOp(z, call, op);
return sortNewestFirst(records, op.sortDesc).map((record) => ({
...record,
id: record.id !== undefined ? record.id : record._id,
Expand Down Expand Up @@ -177,9 +201,7 @@ const hookTrigger = (op) => ({
],
result: { compose: { subscriptionId: op.http.idPath } },
};
const [created] = await callCatalogOp(z, { inputData: bundle.inputData }, subscribeOp).catch((error) => {
throw error;
});
const [created] = await callCatalogOp(z, { inputData: bundle.inputData }, subscribeOp);
return { subscriptionId: created.subscriptionId };
},
performUnsubscribe: async (z, bundle) => {
Expand Down
3 changes: 3 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const API_HOST = 'https://api.batchdata.com';
const SUPPORT_EMAIL = 'support@batchservice.com';
// Zapier kills a task at 30 seconds with an opaque error, so BatchData calls must give up first.
const REQUEST_TIMEOUT_MS = 25000;

const QUICKLISTS = [
'absentee-owner',
Expand Down Expand Up @@ -67,6 +69,7 @@ const FORBIDDEN_BODY_KEYS = ['webhookUrl', 'errorWebhookUrl'];
module.exports = {
API_HOST,
SUPPORT_EMAIL,
REQUEST_TIMEOUT_MS,
QUICKLISTS,
LOOKUP_DATASETS,
FORBIDDEN_BODY_KEYS,
Expand Down
37 changes: 29 additions & 8 deletions lib/modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const { operations } = require('./operations');
const { runOperation, toActionResult, actionSample } = require('./execute');
const { outputFieldsFromSample } = require('./outputFields');
const { translateTimeouts } = require('../middleware');

const searchFor = (op) => ({
key: op.key,
Expand All @@ -28,7 +29,10 @@ const createFor = (op) => {
inputFields: op.inputFields,
perform: async (z, bundle) => toActionResult(op, await runOperation(z, bundle, op)),
sample,
outputFields: outputFieldsFromSample(sample),
outputFields: [sample, ...(op.extraSamples || []).map((extra) => actionSample({ ...op, sample: extra }))]
.map(outputFieldsFromSample)
.flat()
.filter((field, index, all) => all.findIndex((other) => other.key === field.key) === index),
},
};
};
Expand Down Expand Up @@ -124,12 +128,26 @@ const HIDDEN_MODULES = new Set(['new_monitor', 'monitor_event', 'get_monitor', '
const DROPDOWN_FIELDS = { session_id: 'new_search_session.id.id' };
const renamedKey = (key) => (/_id$/.test(key) ? key.replace(/_id$/, '_ref') : key);

const restoreOne = (obj, from, to) => {
if (Object.prototype.hasOwnProperty.call(obj, from)) {
if (obj[to] === undefined || obj[to] === '' || obj[to] === null) obj[to] = obj[from];
delete obj[from];
}
};

// A rename inside a row field (children) carries the parent key, and is restored in every row.
const restoreKeys = (inputData, renames) => {
const out = { ...(inputData || {}) };
for (const [from, to] of renames) {
if (Object.prototype.hasOwnProperty.call(out, from)) {
if (out[to] === undefined || out[to] === '' || out[to] === null) out[to] = out[from];
delete out[from];
for (const [from, to, parent] of renames) {
if (!parent) {
restoreOne(out, from, to);
} else if (Array.isArray(out[parent])) {
out[parent] = out[parent].map((row) => {
if (!row || typeof row !== 'object') return row;
const copy = { ...row };
restoreOne(copy, from, to);
return copy;
});
}
}
return out;
Expand All @@ -139,7 +157,7 @@ const normalizeAll = (map) => {
for (const [mapKey, module] of Object.entries(map)) {
if (HIDDEN_MODULES.has(mapKey) || HIDDEN_MODULES.has(module.key)) module.display.hidden = true;
const renames = [];
const walk = (fields) => (fields || []).forEach((field) => {
const walk = (fields, parent) => (fields || []).forEach((field) => {
if (Array.isArray(field.choices)) {
field.choices = field.choices.map((choice) =>
typeof choice === 'string' ? choice : { value: choice.value, sample: choice.sample || choice.value, label: choice.label || choice.value });
Expand All @@ -148,10 +166,10 @@ const normalizeAll = (map) => {
if (DROPDOWN_FIELDS[field.key] && !field.dynamic) field.dynamic = DROPDOWN_FIELDS[field.key];
if (!field.dynamic && !field.children && /_id$/.test(field.key)) {
const next = renamedKey(field.key);
renames.push([next, field.key]);
renames.push([next, field.key, parent]);
field.key = next;
}
if (field.children) walk(field.children);
if (field.children) walk(field.children, field.key);
});
walk(module.operation.inputFields);
if (renames.length > 0) {
Expand All @@ -162,6 +180,9 @@ const normalizeAll = (map) => {
}
}
}
for (const name of ['perform', 'performSubscribe', 'performUnsubscribe']) {
if (typeof module.operation[name] === 'function') module.operation[name] = translateTimeouts(module.operation[name]);
}
}
return map;
};
Expand Down
11 changes: 9 additions & 2 deletions lib/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const operations = [
{ key: 'last_sale_date_from', label: 'Last Sale Date (From)', type: 'datetime' },
{ key: 'last_sale_date_to', label: 'Last Sale Date (To)', type: 'datetime' },
yesNoAny('owner_occupied', 'Owner Occupied'),
{ key: 'take', label: 'Max Results', type: 'integer', default: '25', helpText: 'From 0 to 100. Default 25. You are billed per record returned.' },
{ key: 'take', label: 'Max Results', type: 'integer', default: '1', helpText: 'From 0 to 100. Default 1. You are billed per record returned. Later Zap steps use only the first result unless you turn on line items, so raise this only when you need more than one.' },
{ key: 'skip', label: 'Skip (Offset)', type: 'integer', helpText: 'Number of results to skip, for paging.' },
{ key: 'additional_criteria', label: 'Additional Search Criteria (JSON)', type: 'text', helpText: 'Optional JSON object merged into searchCriteria. Fields above win on conflict. webhookUrl and errorWebhookUrl are ignored.' },
],
Expand Down Expand Up @@ -194,6 +194,8 @@ const operations = [
},
extract: (response, request) => extractSkipTraceRecords(request.version, response),
sample: sample('skip_trace'),
// V3 records are shaped differently (property.address, person.phones), so label both shapes.
extraSamples: [sample('skip_trace_v3')],
},
{
key: 'verify_address',
Expand Down Expand Up @@ -234,8 +236,13 @@ const operations = [
}),
];

const mergeFields = (lists) => {
const seen = new Set();
return lists.flat().filter((field) => (seen.has(field.key) ? false : (seen.add(field.key), true)));
};

for (const op of operations) {
op.outputFields = outputFieldsFromSample(op.sample);
op.outputFields = mergeFields([op.sample, ...(op.extraSamples || [])].map(outputFieldsFromSample));
op.supportEmail = SUPPORT_EMAIL;
op.host = API_HOST;
}
Expand Down
37 changes: 35 additions & 2 deletions middleware.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use strict';

const { SUPPORT_EMAIL } = require('./lib/constants');
const constants = require('./lib/constants');

const { SUPPORT_EMAIL } = constants;

const addBearerToken = (request, z, bundle) => {
const token = bundle.authData && bundle.authData.api_key;
Expand All @@ -11,6 +13,30 @@ const addBearerToken = (request, z, bundle) => {
return request;
};

const setRequestTimeout = (request) => {
if (request.timeout === undefined) request.timeout = constants.REQUEST_TIMEOUT_MS;
// node-fetch restarts its timeout for the response body, so a signal enforces one total deadline.
if (request.signal === undefined) request.signal = AbortSignal.timeout(constants.REQUEST_TIMEOUT_MS);
return request;
};

const TIMEOUT_TYPES = new Set(['request-timeout', 'body-timeout', 'aborted']);

// A timed-out call may have been processed and billed, so it is never retried automatically.
const translateTimeouts = (perform) => async (z, bundle) => {
try {
return await perform(z, bundle);
} catch (error) {
if (!error || !TIMEOUT_TYPES.has(error.type)) throw error;
const seconds = Math.round(constants.REQUEST_TIMEOUT_MS / 1000);
throw new z.errors.Error(
`BatchData did not respond within ${seconds} seconds. The request may still have been processed (and billed); check your BatchData account before replaying this task. If this keeps happening, contact ${SUPPORT_EMAIL}.`,
'Timeout',
504,
);
}
};

const messageFrom = (response) => {
const data = response.data;
const status = data && typeof data === 'object' ? data.status : undefined;
Expand All @@ -30,6 +56,13 @@ const handleErrors = (response, z) => {
'The BatchData API token was rejected. Reconnect BatchData with a valid token.',
);
}
if (code === 402) {
throw new z.errors.Error(
`${detail || 'Your BatchData account balance is too low for this request.'} Add funds in your BatchData account, then retry. Contact ${SUPPORT_EMAIL} for help.`,
'InsufficientFunds',
402,
);
}
if (code === 403) {
throw new z.errors.Error(
`${detail || 'Access denied.'} This token may not have access to this endpoint. Contact ${SUPPORT_EMAIL} for help.`,
Expand All @@ -54,4 +87,4 @@ const handleErrors = (response, z) => {
throw new z.errors.Error(detail || `BatchData returned HTTP ${code}.`, 'ApiError', code);
};

module.exports = { addBearerToken, handleErrors };
module.exports = { addBearerToken, handleErrors, setRequestTimeout, translateTimeouts };
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "batchdata",
"version": "0.4.0",
"version": "0.4.3",
"description": "BatchData property data, skip tracing, address verification and phone compliance checks for Zapier.",
"main": "index.js",
"license": "MIT",
Expand Down
Loading
Loading