Webhooks
batch.completed delivery, signature verification, and the retry schedule.
#batch.completed
The only webhook event /v1 sends today. It fires once a POST /v1/batch job finishes, whether every item resolved or some failed.
POST https://your-server.com/hooks/up2data
X-Up2Data-Event: batch.completed
X-Up2Data-Signature: t=1767225600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Content-Type: application/json{
"event": "batch.completed",
"job": {
"job_id": "job_7hd92kfa",
"status": "completed",
"total": 1000,
"estimated_completion": "2026-07-07T14:03:00Z"
}
}The payload is a summary, not the results - fetch those with GET /v1/batch/{job_id} or GET /v1/batch/{job_id}/results once you get the event.
Return any 2xx status within 10 seconds to acknowledge delivery. Anything else counts as a failed attempt.
#Verifying the signature
X-Up2Data-Signature is an HMAC-SHA256 of {timestamp}.{raw_body}, computed with your webhook secret over the raw request body, before you parse it as JSON.
import hashlib, hmac, time
def verify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
pairs = dict(p.split("=", 1) for p in header.split(","))
timestamp, signature = pairs["t"], pairs["v1"]
if abs(time.time() - int(timestamp)) > tolerance:
return False # replay protection
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)import crypto from "node:crypto";
function verify(payload, header, secret, tolerance = 300) {
const pairs = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const { t, v1 } = pairs;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${payload}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}#Retries
If your endpoint doesn't return a 2xx within 10 seconds, delivery is retried 5 times with exponential backoff: 1m, 5m, 30m, 2h, 6h. Retries carry the same job_id, so dedupe on it rather than assuming one delivery per job.
#Skipping webhooks entirely
webhook_url is optional on POST /v1/batch. Without it, poll GET /v1/batch/{job_id} instead - see Batch jobs.