API: Webhooks and Signature Verification
Receive signed job-completion callbacks and verify them in Node or Python.
When a bulk job resolves — complete or failed — Acquired Data can POST a completion payload to your server so you never have to poll. There are two delivery mechanisms:
- Account default webhook — configured once in Dashboard → Webhooks. It fires for all bulk jobs on the account, including jobs run from the dashboard UI. See the dashboard webhooks guide for setup.
- Per-job callback_url — passed when creating a job via the API (
POST /jobsor DNC jobs). For that job it takes precedence over the account default.
Callback URLs must be public https endpoints with DNS hostnames — IP literals, localhost, and internal hostnames are rejected, and redirects are not followed.
Payload
The payload is deliberately flat — every field is top-level and scalar — so tools like GoHighLevel inbound-webhook triggers can map fields directly (see the GoHighLevel guide).
| Field | Type | Description |
|---|---|---|
event | string | job.completed or job.failed. |
job_id | uuid | The job this delivery is about. |
status | string | complete or failed. |
filename | string | The job's label. |
total_rows | number | Rows submitted. |
matched_rows | number | null | Rows that matched. Null on retried deliveries (only known at original assembly time). |
credits_charged | number | Credits billed for the job. |
download_url | string | null | Signed CSV link, valid 24 hours. Null for failed jobs. |
results_url | string | null | JSON results endpoint (/api/v1/jobs/{id}/results) — fetch it with your API key. |
completed_at | string | null | ISO timestamp the job resolved. |
error_message | string | null | Set on job.failed deliveries. |
POST https://example.com/hooks/acquired-data
Content-Type: application/json
X-AcquiredData-Event: job.completed
X-AcquiredData-Signature: sha256=6b1f0e2d4c...
{
"event": "job.completed",
"job_id": "4b5e4cb4-2d1a-4f3e-8c7b-9a0d1e2f3b45",
"status": "complete",
"filename": "my-list",
"total_rows": 9500,
"matched_rows": 8114,
"credits_charged": 9500,
"download_url": "https://data.acquiredcrm.com/storage/...signed-csv-24h...",
"results_url": "https://data.acquiredcrm.com/api/v1/jobs/4b5e4cb4-2d1a-4f3e-8c7b-9a0d1e2f3b45/results",
"completed_at": "2026-07-17T18:00:00Z",
"error_message": null
}Two headers accompany every delivery: X-AcquiredData-Event mirrors the event field, and X-AcquiredData-Signature carries sha256=<hex> — an HMAC-SHA256 of the raw request body.
Which secret signs what
- Default-webhook deliveries are signed with the webhook's signing secret, shown on the Dashboard → Webhooks page.
- callback_url deliveries are signed with the SHA-256 hex digest of your plaintext API key. The platform stores only that hash, so you derive the same secret locally:
secret = sha256_hex("ad_live_XXXX"), then verifyhmac_sha256(secret, raw_body).
Deliveries re-sent through the retry endpoint are always signed the callback_url way — with the hash of the API key that created the job (or, for dashboard-created jobs, the key making the retry call).
Verifying signatures
Always compute the HMAC over the raw bytes of the body (before any JSON parsing) and compare with a timing-safe function.
const crypto = require("node:crypto");
const express = require("express");
const app = express();
// Derive the secret once. For callback_url deliveries:
const secret = crypto.createHash("sha256").update("ad_live_XXXX").digest("hex");
// For default-webhook deliveries, use the signing secret from Dashboard -> Webhooks:
// const secret = process.env.ACQUIRED_DATA_WEBHOOK_SECRET;
app.post("/hooks/acquired-data", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-AcquiredData-Signature") ?? "";
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
console.log(payload.event, payload.job_id, payload.matched_rows);
res.sendStatus(200);
});
app.listen(3000);import hashlib
import hmac
from flask import Flask, request, abort
app = Flask(__name__)
# For callback_url deliveries: sha256 hex of your plaintext API key.
SECRET = hashlib.sha256(b"ad_live_XXXX").hexdigest()
# For default-webhook deliveries, use the signing secret from Dashboard -> Webhooks.
@app.post("/hooks/acquired-data")
def acquired_data_hook():
raw_body = request.get_data() # raw bytes, before JSON parsing
expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
received = request.headers.get("X-AcquiredData-Signature", "")
if not hmac.compare_digest(received, expected):
abort(401)
payload = request.get_json()
print(payload["event"], payload["job_id"], payload["matched_rows"])
return "", 200Delivery policy
- 3 attempts with backoff between them; delivery stops at the first 2xx response.
- 10-second timeout per attempt — respond fast and process asynchronously.
- https only, DNS hostnames only, redirects not followed.
- Delivery is best-effort and never affects the job itself — a dead endpoint won't fail your trace.
Replaying a delivery
If your endpoint was down, replay the callback for any finished job with POST /jobs/{id}/retry-webhook (or POST /dnc-jobs/{id}/retry-webhook). An optional callback_urlin the body sets or overrides the job's destination — persisted for future retries — which also lets dashboard-created jobs deliver to a webhook after the fact.
curl -X POST https://data.acquiredcrm.com/api/v1/jobs/4b5e4cb4-2d1a-4f3e-8c7b-9a0d1e2f3b45/retry-webhook \
-H "Authorization: Bearer ad_live_XXXX" \
-H "Content-Type: application/json" \
-d '{"callback_url": "https://example.com/hooks/acquired-data"}'{
"job_id": "4b5e4cb4-2d1a-4f3e-8c7b-9a0d1e2f3b45",
"callback_url": "https://example.com/hooks/acquired-data",
"delivered": true,
"http_status": 200,
"attempts": 1
}A retry against a job that hasn't resolved yet returns 409 job_not_complete; a job with no callback URL (and none provided) returns 422 validation_error. For no-code setups, pair webhooks with GoHighLevel workflows or configure the account default in Dashboard → Webhooks.