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

EventSent when
upload.completedAn upload job finished and the file is in your ad account's media library
upload.failedAn upload job gave up after exhausting its retries
ad.createdAn ad was built from the library or a draft
rule.triggeredAn automation rule paused or activated ads, or changed budgets
review.commentA teammate or review-link guest commented on a creative
review.status_changedA creative was approved, sent for review, or had changes requested
review.version_uploadedA new version of a creative replaced the current one
review.link_viewedA review link was opened (at most once per 6 hours per link)
testSent 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:

json
{
	"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:

json
{
	"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.

js
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));
}
python
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.