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):

MethodPathDoes
GET/api/v1/webhooksThe endpoint, its signing secret, subscribed events, and last-delivery health
PUT/api/v1/webhooksCreate the endpoint or change its URL: { "url": "https://..." }
PATCH/api/v1/webhooksSet subscribed events: { "events": [...] }, or null for all
DELETE/api/v1/webhooksRemove the endpoint
POST/api/v1/webhooks/testFire a signed sample event at the endpoint
GET/api/v1/webhooks/deliveriesThe 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)
bash
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

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)
team.member_joinedSomeone accepted an invite and joined the workspace
test.completedA creative test was decided (won or lost) by the hourly evaluation
creative.status_changedThe 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.receivedA Meta lead ad submission was pulled in (capped per form per poll; the payload carries the form, ad, and submitted fields)
automation.eventAn automation flow ran its "signed webhook" action
testSent 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:

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). 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:

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

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.