Webhooks
Instead of polling GET /api/v1/uploads/{id}, configure a webhook and upload.ad will POST signed events to your endpoint.
Set the endpoint URL on the API keys page. Webhooks require an active subscription, like the rest of the API. One endpoint per account, HTTPS only. Saving the endpoint generates a signing secret that stays the same when you change the URL.
Events
| Event | Sent when |
|---|---|
upload.completed | An upload job finished and the file is in your ad account's media library |
upload.failed | An upload job gave up after exhausting its retries |
ad.created | An ad was built from the library or a draft |
rule.triggered | An automation rule paused or activated ads, or changed budgets |
review.comment | A teammate or review-link guest commented on a creative |
review.status_changed | A creative was approved, sent for review, or had changes requested |
review.version_uploaded | A new version of a creative replaced the current one |
review.link_viewed | A review link was opened (at most once per 6 hours per link) |
test | Sent from the settings page to verify your endpoint |
A retried upload (via POST /api/v1/uploads/{id}) sends a new event when it finishes again.
By default the endpoint receives every event. You can pick which events it gets on the notifications page; test events are always delivered.
Payload
Deliveries are JSON with the same upload object the REST API returns:
{
"id": "evt_Xa9k2mPq",
"type": "upload.completed",
"createdAt": "2026-07-09T10:15:03.000Z",
"data": {
"upload": {
"id": "up_1",
"fileName": "banner.png",
"kind": "image",
"sizeBytes": 482113,
"status": "completed",
"error": null,
"platform": "meta",
"facebook": { "imageHash": "a1b2c3...", "videoId": null },
"tiktok": { "imageId": null, "videoId": null },
"createdAt": "2026-07-09T10:15:00.000Z"
}
}
}ad.created events carry data.ad with adId, name, adsetId, and creativeId. Review events carry data.comment (creativeId, fileName, author, body, guest), data.review (creativeId, fileName, actorName, status), data.version (creativeId, fileName, actorName, versionNumber), or data.link (linkId, subject). rule.triggered events carry data.rule:
{
"id": "evt_9fKq2xLm",
"type": "rule.triggered",
"createdAt": "2026-07-09T11:00:04.000Z",
"data": {
"rule": {
"name": "Kill high CPA ads",
"action": "pause",
"matchCount": 2,
"matches": [
{ "name": "UGC hook v3", "campaignName": "Prospecting US" },
{ "name": "Static promo 04", "campaignName": "Prospecting US" }
]
}
}
}Respond with any 2xx status within 5 seconds. Non-2xx responses and timeouts are retried once after a short delay, then the delivery is dropped. Deliveries can occasionally arrive more than once, so treat the event id as an idempotency key.
Verifying signatures
Every delivery carries an uploadad-signature header:
uploadad-signature: t=1783551300,v1=5257a869e7...t is a Unix timestamp and v1 is the hex HMAC-SHA256 of {t}.{rawBody} using your signing secret. To verify: recompute the HMAC over the raw request body and compare it to v1 with a constant-time comparison, and reject stale timestamps to prevent replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}import hashlib, hmac, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Verify against the raw request body exactly as received. Parsing and re-serializing the JSON first will change the bytes and the signature will not match.