From 35f92f5b489f8080caa9b8d1ce1167596013f969 Mon Sep 17 00:00:00 2001 From: BatchData Date: Mon, 9 Nov 2026 08:17:17 -0700 Subject: [PATCH 1/3] Zapier v0.4.1: connection label fallback without app name (Zapier review W614ZP-P52RM, Nov 4 2026) - authentication.js: fallback label is 'API token' / 'API token missing' instead of 'BatchData API token' - test: fallbacks never include the app name - README/PUBLISH-CHECKLIST: review feedback and status --- PUBLISH-CHECKLIST.md | 15 ++++++++++++--- README.md | 6 +++--- authentication.js | 6 ++++-- package-lock.json | 4 ++-- package.json | 2 +- test/publish-readiness.test.js | 11 +++++++++++ 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/PUBLISH-CHECKLIST.md b/PUBLISH-CHECKLIST.md index 22f8ebc..c67950d 100644 --- a/PUBLISH-CHECKLIST.md +++ b/PUBLISH-CHECKLIST.md @@ -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. | diff --git a/README.md b/README.md index c28aa05..7942d32 100644 --- a/README.md +++ b/README.md @@ -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) @@ -125,9 +125,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. diff --git a/authentication.js b/authentication.js index e3505ec..a6cbc72 100644 --- a/authentication.js +++ b/authentication.js @@ -55,8 +55,10 @@ module.exports = { ], 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'; }, }; diff --git a/package-lock.json b/package-lock.json index 011b82d..8200118 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "batchdata", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "batchdata", - "version": "0.1.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "zapier-platform-core": "19.2.1" diff --git a/package.json b/package.json index 83d0389..1c80b4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "batchdata", - "version": "0.4.0", + "version": "0.4.1", "description": "BatchData property data, skip tracing, address verification and phone compliance checks for Zapier.", "main": "index.js", "license": "MIT", diff --git a/test/publish-readiness.test.js b/test/publish-readiness.test.js index 6b04dc8..2525c09 100644 --- a/test/publish-readiness.test.js +++ b/test/publish-readiness.test.js @@ -80,6 +80,17 @@ test('authentication has a label, help text with a link, and the connection labe assert.ok(!label.includes('abcdefgh')); }); +test('connection label fallbacks never include the app name (Zapier review, Nov 4 2026)', () => { + const label = (authData) => App.authentication.connectionLabel({}, { authData }); + assert.equal(label({ api_key: 'abc' }), 'API token'); + assert.equal(label({ api_key: '' }), 'API token missing'); + assert.equal(label({}), 'API token missing'); + assert.equal(App.authentication.connectionLabel({}, {}), 'API token missing'); + for (const value of [label({ api_key: 'abc' }), label({}), label({ api_key: 'abcdefgh12345678' })]) { + assert.ok(!/batchdata/i.test(value), value); + } +}); + test('property monitor modules are hidden and every other module is visible', () => { const app = require('../index'); const hidden = []; From 5d0220507e19603153fbe4a26168656fcfd8bbc4 Mon Sep 17 00:00:00 2001 From: BatchData Date: Mon, 9 Nov 2026 08:57:30 -0700 Subject: [PATCH 2/3] Zapier v0.4.2 (local, not pushed): sign-off fixes from Claude review + live run, Nov 9 2026 - Billed session trigger: skip API call on dedupe population; sample load fetches 1; default take 5 - Billed searches (Find Properties, Permits, Investor Buy Box, Investor Properties) default take 1 - Batch Look Up / Batch Skip Trace: row-level Your Reference ID no longer dropped - 402 mapped to a clear insufficient-funds error - Skip Trace output fields now label the V3 record shape too - Remove no-op catch in hook subscribe; regression tests in test/signoff-0.4.2.test.js --- lib/builders.js | 2 +- lib/catalog-modules.js | 30 ++++++- lib/modules.js | 33 ++++++-- lib/operations.js | 11 ++- middleware.js | 7 ++ package-lock.json | 2 +- package.json | 2 +- samples/skip_trace_v3.json | 163 +++++++++++++++++++++++++++++++++++++ test/builders.test.js | 2 +- test/catalog.test.js | 2 +- test/signoff-0.4.2.test.js | 55 +++++++++++++ test/structure.test.js | 7 +- 12 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 samples/skip_trace_v3.json create mode 100644 test/signoff-0.4.2.test.js diff --git a/lib/builders.js b/lib/builders.js index 97d4ebe..b766ddc 100644 --- a/lib/builders.js +++ b/lib/builders.js @@ -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'); } diff --git a/lib/catalog-modules.js b/lib/catalog-modules.js index c80aabd..e896321 100644 --- a/lib/catalog-modules.js +++ b/lib/catalog-modules.js @@ -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) => @@ -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, @@ -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) => { diff --git a/lib/modules.js b/lib/modules.js index 5d5f2fc..b214cdd 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -28,7 +28,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), }, }; }; @@ -124,12 +127,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; @@ -139,7 +156,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 }); @@ -148,10 +165,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) { diff --git a/lib/operations.js b/lib/operations.js index b0231ce..58ef3a8 100644 --- a/lib/operations.js +++ b/lib/operations.js @@ -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.' }, ], @@ -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', @@ -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; } diff --git a/middleware.js b/middleware.js index 2cf5a2e..da92648 100644 --- a/middleware.js +++ b/middleware.js @@ -30,6 +30,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.`, diff --git a/package-lock.json b/package-lock.json index 8200118..e5657d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "batchdata", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 1c80b4f..10b69c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "batchdata", - "version": "0.4.1", + "version": "0.4.2", "description": "BatchData property data, skip tracing, address verification and phone compliance checks for Zapier.", "main": "index.js", "license": "MIT", diff --git a/samples/skip_trace_v3.json b/samples/skip_trace_v3.json new file mode 100644 index 0000000..898390a --- /dev/null +++ b/samples/skip_trace_v3.json @@ -0,0 +1,163 @@ +{ + "apiVersion": "v3", + "input": { + "propertyAddress": { + "street": "1011 Rosegold St", + "city": "Franklin Square", + "state": "NY", + "zip": "11010" + } + }, + "property": { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "address": { + "hash": "b7bd4cea51b26af459febfbf822c99e2", + "addressValidity": "Valid", + "fullAddress": "1011 Rosegold St, Franklin Square, NY 11010-2507", + "street": "1011 Rosegold St", + "city": "Franklin Square", + "county": "Nassau", + "state": "NY", + "zip": "11010", + "zipPlus4": "2507" + }, + "mailingAddress": { + "hash": "b7bd4cea51b26af459febfbf822c99e2", + "addressValidity": "Valid", + "fullAddress": "1011 Rosegold St, Franklin Square, NY 11010-2507", + "street": "1011 Rosegold St", + "city": "Franklin Square", + "county": "Nassau", + "state": "NY", + "zip": "11010", + "zipPlus4": "2507" + }, + "owners": [ + { + "name": { + "first": "John", + "last": "Doe", + "full": "John Doe", + "akas": [ + { + "first": "John", + "middle": "Quinn", + "last": "Doe", + "full": "John Quinn Doe", + "rank": 2 + }, + { + "first": "Jhon", + "last": "Doe", + "full": "Jhon Doe", + "rank": 3 + } + ] + } + } + ] + }, + "person": { + "propertyOwner": true, + "name": { + "first": "John", + "last": "Doe", + "full": "John Doe", + "akas": [ + { + "first": "John", + "middle": "Quinn", + "last": "Doe", + "full": "John Quinn Doe", + "rank": 2 + }, + { + "first": "Jhon", + "last": "Doe", + "full": "Jhon Doe", + "rank": 3 + } + ] + }, + "addresses": [ + { + "hash": "b7bd4cea51b26af459febfbf822c99e2", + "addressValidity": "Valid", + "fullAddress": "1011 Rosegold St, Franklin Square, NY 11010-2507", + "street": "1011 Rosegold St", + "city": "Franklin Square", + "county": "Nassau", + "state": "NY", + "zip": "11010", + "zipPlus4": "2507", + "rank": 1, + "propertyMailingAddress": true + }, + { + "hash": "c8ce5dfa62c37bg560gfcgcg933da0f3", + "addressValidity": "Valid", + "fullAddress": "500 Maple Ave, Hempstead, NY 11550-3210", + "street": "500 Maple Ave", + "city": "Hempstead", + "county": "Nassau", + "state": "NY", + "zip": "11550", + "zipPlus4": "3210", + "rank": 2, + "propertyMailingAddress": false + } + ], + "phones": [ + { + "rank": 1, + "number": "1112223333", + "type": "Mobile", + "carrier": "OMNIPOINT COMMUNICATIONS- INC.", + "tested": true, + "reachable": true, + "dnc": false, + "tcpa": false + }, + { + "rank": 2, + "number": "4445556666", + "type": "Land Line", + "carrier": "VERIZON NEW YORK- INC.", + "tested": false, + "reachable": false, + "dnc": false, + "tcpa": false + }, + { + "rank": 3, + "number": "7778889999", + "type": "Land Line", + "carrier": "VERIZON NEW YORK- INC.", + "tested": true, + "reachable": true, + "dnc": true, + "tcpa": false + }, + { + "rank": 4, + "number": "1011121314", + "type": "Land Line", + "carrier": "VERIZON NEW YORK- INC.", + "tested": true, + "reachable": true, + "dnc": false, + "tcpa": true + } + ], + "emails": [ + { + "rank": 1, + "email": "johndoe@gmail.net", + "tested": true + } + ], + "litigator": false, + "deceased": false, + "dob": "1985-07-21T00:00:00.000Z" + } +} diff --git a/test/builders.test.js b/test/builders.test.js index 179c4d1..cea9e8c 100644 --- a/test/builders.test.js +++ b/test/builders.test.js @@ -48,7 +48,7 @@ test('search: dedicated field wins over JSON blob and callbacks are stripped', ( assert.equal(body.searchCriteria.query, 'Tempe, AZ'); assert.deepEqual(body.searchCriteria.building, { yearBuilt: { min: 1990 } }); assert.ok(!JSON.stringify(body).includes('webhookUrl')); - assert.equal(body.options.take, 25); + assert.equal(body.options.take, 1); // billed search defaults to 1 (0.4.2) }); test('search: validates take and JSON', () => { diff --git a/test/catalog.test.js b/test/catalog.test.js index d2da390..533a295 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -74,7 +74,7 @@ test('permits: one record per permit with the property attached; address and fil assert.equal(calls[0].url, 'https://api.batchdata.com/api/v2/property/get-property-permits'); assert.deepEqual(calls[0].body.request.allTags, { inList: ['solar', 'roofing'] }); assert.equal(calls[0].body.request.issueDate.minDate, '2025-01-01'); - assert.equal(calls[0].body.options.take, 25); + assert.equal(calls[0].body.options.take, 1); // billed search defaults to 1 (0.4.2) }); test('investor buybox and properties map their envelopes', async () => { diff --git a/test/signoff-0.4.2.test.js b/test/signoff-0.4.2.test.js new file mode 100644 index 0000000..c836d4a --- /dev/null +++ b/test/signoff-0.4.2.test.js @@ -0,0 +1,55 @@ +'use strict'; + +// Regression tests for the 0.4.2 sign-off fixes (Claude review + live run, Nov 9 2026). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { makeZ } = require('./helpers'); +const { searches, creates, triggers } = require('../lib/modules'); + +const bundleFor = (inputData, extra = {}) => ({ authData: { api_key: 'test-token' }, inputData, ...extra }); + +test('billed session trigger: dedupe population makes no API call', async () => { + const bundle = bundleFor({ session_id: 's1', query: 'Phoenix, AZ' }, { meta: { isPopulatingDedupe: true } }); + const { z, calls } = makeZ(bundle, async () => ({ data: {} })); + assert.deepEqual(await triggers.new_session_property.operation.perform(z, bundle), []); + assert.equal(calls.length, 0); +}); + +test('billed session trigger: loading a sample fetches one record only', async () => { + const bundle = bundleFor({ session_id: 's1', query: 'Phoenix, AZ', take: 25 }, { meta: { isLoadingSample: true } }); + const { z, calls } = makeZ(bundle, async () => ({ data: { results: { properties: [{ _id: 'p1' }] } } })); + await triggers.new_session_property.operation.perform(z, bundle); + assert.equal(calls.length, 1); + assert.equal(calls[0].body.options.take, 1); +}); + +test('billed session trigger and searches default to a small Max Results', () => { + const take = (m) => m.operation.inputFields.find((f) => f.key === 'take').default; + assert.equal(take(triggers.new_session_property), '5'); + for (const key of ['search_properties', 'get_permits', 'investor_buybox', 'investor_properties']) assert.equal(take(searches[key]), '1', key); +}); + +test('row-level Your Reference ID reaches the request in batch modules', async () => { + for (const key of ['batch_lookup_property', 'batch_skip_trace']) { + const rowsField = creates[key].operation.inputFields.find((f) => f.children); + const refKey = rowsField.children.find((c) => /_ref$/.test(c.key)).key; + const row = { street: '1 A St', city: 'X', state: 'AZ', zip: '85001', [refKey]: 'ROW-1' }; + const bundle = bundleFor({ [rowsField.key]: [row] }); + const { z, calls } = makeZ(bundle, async () => ({ data: { results: { properties: [] }, result: { data: [] } } })); + await creates[key].operation.perform(z, bundle); + assert.ok(JSON.stringify(calls[0].body).includes('ROW-1'), `${key} dropped the row reference`); + } +}); + +test('402 gives a clear insufficient-funds error', async () => { + const bundle = bundleFor({ street: '1 A St', city: 'X', state: 'AZ', zip: '85001' }); + const { z } = makeZ(bundle, async () => ({ status: 402, data: {} })); + await assert.rejects(searches.verify_address.operation.perform(z, bundle), (e) => e.status === 402 && /balance/i.test(e.message)); +}); + +test('skip trace output fields label both V1 and V3 record shapes', () => { + const keys = searches.skip_trace.operation.outputFields.map((f) => f.key); + assert.ok(keys.includes('propertyAddress__street')); + assert.ok(keys.includes('property__address__street')); + assert.ok(keys.some((k) => k.startsWith('person__phones[]'))); +}); diff --git a/test/structure.test.js b/test/structure.test.js index 00344bf..1a84ff7 100644 --- a/test/structure.test.js +++ b/test/structure.test.js @@ -36,8 +36,13 @@ test('every module has labels, input fields with keys, and outputFields present assert.ok(!keys.has(field.key), `${module.key} duplicate field ${field.key}`); keys.add(field.key); } + // Some modules return more than one record shape (Skip Trace V1 vs V3); each field must be in one of the samples. + const op = require('../lib/operations').operations.find((o) => o.key === module.key || `${o.key}_action` === module.key); + const { actionSample } = require('../lib/execute'); + const extras = ((op && op.extraSamples) || []).map((extra) => (module.key.endsWith('_action') ? actionSample({ ...op, sample: extra }) : extra)); for (const field of module.operation.outputFields) { - assert.notEqual(get(module.operation.sample, field.key), undefined, `${module.key} outputField ${field.key} not in sample`); + const found = [module.operation.sample, ...extras].some((sample) => get(sample, field.key) !== undefined); + assert.ok(found, `${module.key} outputField ${field.key} not in any sample`); } assert.ok(JSON.stringify(module.operation.sample).length < 5000, `${module.key} sample too large`); } From c168cc3086add7b6e5966a155c44512894e6d480 Mon Sep 17 00:00:00 2001 From: Ivo Draginov Date: Fri, 13 Nov 2026 08:23:07 -0700 Subject: [PATCH 3/3] Zapier v0.4.3: 25s request timeout with clear error instead of Zapier's 30s task kill (address verify Zap alert, Nov 13 2026) --- CLAUDE.md | 15 ++++++ README.md | 4 ++ authentication.js | 3 +- index.js | 4 +- lib/constants.js | 3 ++ lib/modules.js | 4 ++ middleware.js | 30 ++++++++++- package-lock.json | 4 +- package.json | 2 +- test/timeout-0.4.3.test.js | 107 +++++++++++++++++++++++++++++++++++++ 10 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md create mode 100644 test/timeout-0.4.3.test.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..615dfbc --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 7942d32..5f4cb49 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/authentication.js b/authentication.js index a6cbc72..3d506d8 100644 --- a/authentication.js +++ b/authentication.js @@ -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. @@ -53,7 +54,7 @@ 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) => { diff --git a/index.js b/index.js index 9131eb4..362d86c 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ 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 = { @@ -12,7 +12,7 @@ module.exports = { platformVersion, flags: { cleanInputData: false }, authentication, - beforeRequest: [addBearerToken], + beforeRequest: [addBearerToken, setRequestTimeout], afterResponse: [handleErrors], triggers, searches, diff --git a/lib/constants.js b/lib/constants.js index 3a8f39d..3b4e228 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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', @@ -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, diff --git a/lib/modules.js b/lib/modules.js index b214cdd..a149294 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -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, @@ -179,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; }; diff --git a/middleware.js b/middleware.js index da92648..384ce9e 100644 --- a/middleware.js +++ b/middleware.js @@ -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; @@ -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; @@ -61,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 }; diff --git a/package-lock.json b/package-lock.json index e5657d3..c60c29f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "batchdata", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "batchdata", - "version": "0.4.1", + "version": "0.4.3", "license": "MIT", "dependencies": { "zapier-platform-core": "19.2.1" diff --git a/package.json b/package.json index 10b69c0..f71fb29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "batchdata", - "version": "0.4.2", + "version": "0.4.3", "description": "BatchData property data, skip tracing, address verification and phone compliance checks for Zapier.", "main": "index.js", "license": "MIT", diff --git a/test/timeout-0.4.3.test.js b/test/timeout-0.4.3.test.js new file mode 100644 index 0000000..887a3ac --- /dev/null +++ b/test/timeout-0.4.3.test.js @@ -0,0 +1,107 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const zapier = require('zapier-platform-core'); +const nock = require('nock'); +const constants = require('../lib/constants'); +const App = require('../index'); + +const appTester = zapier.createAppTester(App); +const HOST = 'https://api.batchdata.com'; +const address = { street: '1 A St', city: 'Phoenix', state: 'AZ', zip: '85001' }; +const bundle = () => ({ authData: { api_key: 'test-token' }, inputData: { ...address } }); + +const withTimeout = async (ms, fn) => { + const previous = constants.REQUEST_TIMEOUT_MS; + constants.REQUEST_TIMEOUT_MS = ms; + try { + return await fn(); + } finally { + constants.REQUEST_TIMEOUT_MS = previous; + } +}; + +const isTimeoutError = (error) => { + const parsed = JSON.parse(error.message.split('\nWhat happened:')[0]); + assert.equal(parsed.code, 'Timeout'); + assert.equal(parsed.status, 504); + assert.match(parsed.message, /^BatchData did not respond within \d+ seconds\./); + assert.match(parsed.message, /may still have been processed \(and billed\)/); + assert.match(parsed.message, /support@batchservice\.com/); + return true; +}; + +test.afterEach(() => nock.cleanAll()); + +test('every request gets a 25 second timeout unless one is already set', () => { + const { setRequestTimeout } = require('../middleware'); + assert.equal(constants.REQUEST_TIMEOUT_MS, 25000); + assert.equal(setRequestTimeout({}).timeout, 25000); + assert.equal(setRequestTimeout({ timeout: 5 }).timeout, 5); + assert.ok(setRequestTimeout({}).signal instanceof AbortSignal); + const signal = AbortSignal.timeout(1000); + assert.equal(setRequestTimeout({ signal }).signal, signal); + assert.ok(App.beforeRequest.includes(setRequestTimeout)); +}); + +test('hanging verify address search fails with a clear Timeout error', async () => { + nock(HOST).post('/api/v1/address/verify').delay(2000).reply(200, {}); + const started = Date.now(); + await withTimeout(150, () => + assert.rejects(appTester(App.searches.verify_address.operation.perform, bundle()), isTimeoutError), + ); + assert.ok(Date.now() - started < 1500); +}); + +test('hanging verify address action fails with a clear Timeout error', async () => { + nock(HOST).post('/api/v1/address/verify').delay(2000).reply(200, {}); + const started = Date.now(); + await withTimeout(150, () => + assert.rejects(appTester(App.creates.verify_address_action.operation.perform, bundle()), isTimeoutError), + ); + assert.ok(Date.now() - started < 1500); +}); + +test('a response body that stalls after headers still fails within the total timeout', async () => { + nock(HOST).post('/api/v1/address/verify').delay({ head: 250, body: 2000 }).reply(200, {}); + const started = Date.now(); + await withTimeout(300, () => + assert.rejects(appTester(App.searches.verify_address.operation.perform, bundle()), isTimeoutError), + ); + assert.ok(Date.now() - started < 450, `took ${Date.now() - started} ms`); +}); + +test('hanging catalog module and connection test fail with a clear Timeout error', async () => { + nock(HOST).post('/api/v1/property/search').delay(2000).reply(200, {}); + await withTimeout(150, () => + assert.rejects( + appTester(App.searches.search_properties.operation.perform, { + authData: { api_key: 'test-token' }, + inputData: { query: 'Phoenix, AZ', take: '1' }, + }), + isTimeoutError, + ), + ); + + nock(HOST).post('/api/v1/address/verify').delay(2000).reply(400, {}); + await withTimeout(150, () => + assert.rejects(appTester(App.authentication.test, { authData: { api_key: 'test-token' } }), isTimeoutError), + ); +}); + +test('non-timeout errors are unchanged', async () => { + nock(HOST).post('/api/v1/address/verify').reply(402, {}); + await assert.rejects(appTester(App.searches.verify_address.operation.perform, bundle()), (error) => { + const parsed = JSON.parse(error.message.split('\nWhat happened:')[0]); + assert.equal(parsed.code, 'InsufficientFunds'); + assert.equal(parsed.status, 402); + return true; + }); +}); + +test('a normal 200 still returns results', async () => { + nock(HOST).post('/api/v1/address/verify').reply(200, require('./fixtures/address-verify.json')); + const results = await appTester(App.searches.verify_address.operation.perform, bundle()); + assert.ok(Array.isArray(results)); +});