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, or manage it programmatically with the endpoints below. Webhooks require an active subscription, like the rest of the API. One endpoint per workspace, public HTTPS URLs only. Saving the endpoint generates a signing secret that stays the same when you change the URL.
Managing the endpoint
The endpoint is manageable over v1 with the same Bearer auth as the rest of the API (also available in the SDK as client.webhooks and the CLI as uploadad webhook):
| Method | Path | Does |
|---|---|---|
GET | /api/v1/webhooks | The endpoint, its signing secret, subscribed events, and last-delivery health |
PUT | /api/v1/webhooks | Create the endpoint or change its URL: { "url": "https://..." } |
PATCH | /api/v1/webhooks | Set subscribed events: { "events": [...] }, or null for all |
DELETE | /api/v1/webhooks | Remove the endpoint |
POST | /api/v1/webhooks/test | Fire a signed sample event at the endpoint |
GET | /api/v1/webhooks/deliveries | The delivery log for the last 7 days, cursor-paginated |
POST | /api/v1/webhooks/deliveries/{id} | Re-send a logged delivery (same event id and payload) |
curl -X PUT https://upload.ad/api/v1/webhooks \
-H "Authorization: Bearer $UPLOADAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/uploadad" }'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) |
team.member_joined | Someone accepted an invite and joined the workspace |
test.completed | A creative test was decided (won or lost) by the hourly evaluation |
creative.status_changed | The hourly performance rollup relabeled a creative as a winner, loser, fatigued, or back to neutral (the payload carries the creative, the new and previous label with 30-day numbers, and its live ads) |
lead.received | A Meta lead ad submission was pulled in (capped per form per poll; the payload carries the form, ad, and submitted fields) |
automation.event | An automation flow ran its "signed webhook" action |
test | Sent from the settings page or POST /api/v1/webhooks/test to verify your endpoint (delivered with type test; subscribe to it as test.completed, though test deliveries always go through regardless) |
A retried upload (via POST /api/v1/uploads/{id}) sends a new event when it finishes again.
By default the endpoint receives every event. Pick which events it gets on the notifications page or with PATCH /api/v1/webhooks (which returns 404 until an endpoint exists, and rejects unknown event names); 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). team.member_joined events carry data.member (name, email). automation.event deliveries carry data.automationId, data.automationName, an optional data.event label, and whatever data.data object the flow's action was configured to send. 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 immediately, then with increasing backoff in the background (roughly 1 minute to 8 hours, giving up after about 10 hours). Every delivery is logged for 7 days: inspect outcomes with GET /api/v1/webhooks/deliveries and re-send any of them with POST /api/v1/webhooks/deliveries/{id}. Retries and redeliveries keep the original event id, and deliveries can occasionally arrive more than once, so treat that 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.