Automating Skip Tracing with the Acquired Data API
An end-to-end automation pattern: submit lists, receive signed webhooks, verify signatures, and load results into your CRM.
If you trace a list once a month, the dashboard is all you need. But once your team is running lists weekly — or you're feeding leads from multiple markets into one CRM — the manual loop starts to hurt: export a CSV, upload it, wait, remember to check back, download results, re-import, fix column mappings, repeat. Every hop is a place where a list sits idle for a day or a mapping breaks. The Acquired Data API is live today, and it collapses that loop into a pipeline that runs itself.
The architecture
The whole integration is three moves:
- Submit:
POST /api/v1/jobswith your rows as JSON or a raw CSV (up to 10,000 rows per job). Propwire and PropStream exports work unmodified — headers are matched case- and punctuation-insensitively and unknown columns are ignored. Pass acallback_urlso you don't have to poll. - Receive: when the job resolves, Acquired Data POSTs a flat JSON payload (
job.completedorjob.failed) to your URL, signed with anX-AcquiredData-Signatureheader. Verify the HMAC before trusting it. - Ingest: pull
GET /api/v1/jobs/{id}/resultspage by page and upsert the flat row objects into your CRM. There's also a signeddownload_urlif you'd rather grab the CSV.
Here's the pattern end to end in Node:
import crypto from "node:crypto";
import { readFile } from "node:fs/promises";
import express from "express";
const API_KEY = process.env.ACQUIRED_DATA_API_KEY; // Dashboard -> Webhooks -> API Keys
const BASE = "https://YOUR-DOMAIN/api/v1";
// For callback_url deliveries the HMAC secret is the SHA-256 hex of your API key.
const SECRET = crypto.createHash("sha256").update(API_KEY).digest("hex");
// 1) Submit a CSV export as a bulk job
async function submitJob(csvPath) {
const params = new URLSearchParams({
filename: "july-absentee",
add_ons: "dnc,phone_verification",
callback_url: "https://yourapp.com/webhooks/acquired-data",
});
const res = await fetch(`${BASE}/jobs?${params}`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "text/csv" },
body: await readFile(csvPath),
});
const job = await res.json(); // { job_id, status, total_rows, credits_charged, ... }
console.log("submitted", job.job_id, "credits charged:", job.credits_charged);
}
// 2) Receive + verify the completion webhook, then 3) ingest results
const app = express();
app.post(
"/webhooks/acquired-data",
express.raw({ type: "*/*" }), // keep the raw body for HMAC verification
async (req, res) => {
const sig = req.get("X-AcquiredData-Signature") ?? "";
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const valid =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!valid) return res.status(401).end();
res.status(200).end(); // ack fast — deliveries retry up to 3 times
const event = JSON.parse(req.body);
if (event.event !== "job.completed") {
console.error("job failed:", event.error_message); // credits auto-refund
return;
}
for (let page = 1; ; page++) {
const r = await fetch(`${event.results_url}?page=${page}&per_page=1000`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { rows } = await r.json();
if (rows.length === 0) break;
// await crm.upsertContacts(rows); // flat objects: primary_phone, email_1, ...
}
},
);
app.listen(3000);Build in sandbox first
Every account has an environment setting, and responses tell you which one served the request ("environment": "sandbox" or "production"). Sandbox returns realistic fake data, so you can exercise the whole pipeline — submission, webhook delivery, signature verification, pagination, CRM upserts — without spending credits on real lookups. Flip to production only once the plumbing works.
Limits to design around
- 60 requests per minute per key. Pulling results at 1,000 rows per page keeps even a 10,000-row job to a handful of requests.
- One active trace job per account. Concurrent submissions return
429 job_in_progresswith aRetry-Afterheader. Design for a queue: hold pending lists, submit the next one when the webhook for the current one lands. This is a feature in disguise — it forces a serialized pipeline that's much easier to reason about than juggling parallel jobs. - Billing is up front and self-correcting. Credits are charged at submission; if a job fails, they're refunded automatically, so a failed webhook event needs no cleanup on your side.
Next steps
Grab a key and make your first call with the API getting-started guide, then go deeper on bulk jobs and webhook payloads and signatures.