APIUpdated 2026-07-17

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 /jobs or 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).

FieldTypeDescription
eventstringjob.completed or job.failed.
job_iduuidThe job this delivery is about.
statusstringcomplete or failed.
filenamestringThe job's label.
total_rowsnumberRows submitted.
matched_rowsnumber | nullRows that matched. Null on retried deliveries (only known at original assembly time).
credits_chargednumberCredits billed for the job.
download_urlstring | nullSigned CSV link, valid 24 hours. Null for failed jobs.
results_urlstring | nullJSON results endpoint (/api/v1/jobs/{id}/results) — fetch it with your API key.
completed_atstring | nullISO timestamp the job resolved.
error_messagestring | nullSet on job.failed deliveries.
Example delivery
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 verify hmac_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.

Node (Express)
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);
Python (Flask)
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 "", 200

Delivery 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.

Request
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"}'
Response
{
  "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.