SDK

@uploadad/sdk is a typed TypeScript client for the upload.ad REST API. It has zero runtime dependencies and works anywhere fetch is available: Node 20+, Bun, Deno, Cloudflare Workers, and other edge runtimes.

Paid plans only. The REST API, CLI, SDK, and MCP server are part of every paid plan; the free trial covers the app itself. You can subscribe at any time, including during the trial. A workspace without a plan is refused with 402 subscription_required (-32002 over MCP), and an agent that signs in without one is turned away at the authorization screen.

Install

bash
npm install @uploadad/sdk

bun works too: bun add @uploadad/sdk.

Authentication

Create a client with an API key (create one at upload.ad/dashboard/settings/api-keys):

ts
import { UploadAd } from '@uploadad/sdk';

const client = new UploadAd({ apiKey: 'ua_live_...' });

If you omit apiKey, the client reads the UPLOADAD_API_KEY environment variable. Check who you are authenticated as:

ts
const account = await client.me();
console.log(account.email, account.credits);

Retries, timeouts, and idempotency

The client retries rate limits (429, honoring retry-after), transient server errors (502/503/504), and network failures with exponential backoff, up to maxRetries times (default 2). Only requests that are safe to repeat are retried: reads, naturally idempotent mutations (PUT, PATCH, DELETE), and POSTs carrying an idempotency key.

Creating helpers (uploads.create, uploads.retry, creatives.delete, copy.create, webhooks.set, tools.call) generate an idempotency key automatically, so a retried request replays the original result instead of running twice. The remaining mutations (uploads.dismiss, copy.update, copy.delete, webhooks.setEvents, webhooks.delete) are naturally idempotent server-side, so the client retries them without a key. Pass your own key to make retries safe across process restarts:

ts
await client.tools.call('create_campaign', args, { idempotencyKey: 'order-1042' });

Every request has a 60 second timeout (uploads get 10 minutes). Tune both per client or per call:

ts
const client = new UploadAd({ apiKey, maxRetries: 4, timeoutMs: 30_000 });
await client.me({ timeoutMs: 5_000 });

Every method also accepts an AbortSignal as { signal } to cancel from the outside.

Uploading creatives

uploads.create accepts one or more files as a Blob, ArrayBuffer, or Uint8Array. Pass platform to pick the ad account the files are pushed to (meta is the default, or tiktok) and folderId to file the resulting creatives under a library folder:

ts
import { readFile } from 'node:fs/promises';

const data = await readFile('banner.png');
const [job] = await client.uploads.create([{ name: 'banner.png', data, type: 'image/png' }], {
	platform: 'tiktok',
	folderId: 'fo_...'
});

Uploads process in the background. Use waitFor to poll until a job reaches a terminal state:

ts
const upload = await client.uploads.waitFor(job.id);
if (upload.status === 'completed') {
	console.log(upload.tiktok.imageId);
}

The Upload type carries platform (meta or tiktok, the ad account the file went to) alongside the platform id fields: facebook.imageHash / facebook.videoId for Meta and tiktok.imageId / tiktok.videoId for TikTok, each set on the matching completed upload.

waitFor polls every 3 seconds by default; pass { intervalMs, timeoutMs } to tune it. A passed deadline throws UploadAdTimeoutError. The same limits apply as in the API: up to 25 files per call, images up to 30 MB, videos up to 200 MB.

Pagination

Every list method returns one page plus nextCursor (null on the last page). Pass the cursor back to continue, or use the iterate helpers to page automatically:

ts
const page = await client.creatives.list({ limit: 50 });
const next = await client.creatives.list({ limit: 50, cursor: page.nextCursor! });

for await (const creative of client.creatives.iterate()) {
	console.log(creative.id, creative.fileName);
}

uploads, creatives, and copy all follow the same shape.

Managing upload jobs

ts
const { uploads, nextCursor } = await client.uploads.list({ limit: 20, status: 'failed' });
const upload = await client.uploads.get('up_...');
await client.uploads.retry('up_...'); // retry a failed upload
await client.uploads.dismiss('up_...'); // remove it from the list

Creatives and copy variants

ts
const { creatives } = await client.creatives.list();
await client.creatives.delete(['cr_a', 'cr_b']);

const { copies } = await client.copy.list('cr_a');
const copyId = await client.copy.create('cr_a', {
	headline: 'Summer sale',
	primaryText: 'Up to 40% off this week only.'
});
await client.copy.update('cr_a', copyId, { label: 'variant-b' }); // patch: only sent fields change
await client.copy.delete('cr_a', copyId);

Downloading media

creatives.media fetches a creative's binary with the client's credentials and returns the raw Response; pass the creative's mediaPath (or previewPath) field and stream or buffer the result:

ts
import { writeFile } from 'node:fs/promises';

const { creatives } = await client.creatives.list({ limit: 1 });
const res = await client.creatives.media(creatives[0].mediaPath!);
await writeFile('banner.png', Buffer.from(await res.arrayBuffer()));

Pass { download: 'name.png' } to receive it as an attachment with that filename.

Tools

The client can list and call any tool in the upload.ad catalog, the same set the MCP server exposes. tools.list returns each tool's name, description, inputSchema, destructive flag (spends money or cannot be undone), and readOnly flag; tools.call runs one and returns its result:

ts
const tools = await client.tools.list();
const result = await client.tools.call('get_campaigns', { platform: 'meta' });

tools.call is fully typed: tool names autocomplete and each tool's arguments are checked at compile time (generated from the live catalog). The client.run facade offers the same calls as camelCase methods:

ts
await client.run.setWebhook({ url: 'https://example.com/hooks' });
await client.run.getAdsPerformance({ level: 'campaign', range: 'last_7d' });

The server catalog is additive and may be ahead of your installed SDK; call tools this build doesn't know about with client.tools.callRaw(name, args). Each tool's category, destructive, and readOnly flags are also available offline via the exported TOOL_INFO map.

Tools that declare an outputSchema in the catalog also get typed results: client.run.getWebhook() resolves to the actual webhook shape, not unknown. Coverage is incremental; tools without a declared output resolve to unknown (dev builds validate declared shapes against real handler results, so a declared type is never a guess).

Tool calls carry an idempotency key automatically (see above), so ad-creating and money-spending calls are retry-safe.

Managing webhooks

client.webhooks manages the workspace's webhook endpoint (one per workspace) and its delivery log:

ts
const webhook = await client.webhooks.set('https://example.com/hooks/uploadad');
console.log(webhook.secret); // uawh_..., for verifyWebhookSignature

// The secret is only returned by set(); webhooks.get() carries secret: null.
// Lost it? Call set() again with the same URL: the secret survives URL changes.

await client.webhooks.setEvents(['upload.completed', 'upload.failed']); // null = all
await client.webhooks.test(); // fire a signed sample event

const { deliveries } = await client.webhooks.deliveries({ limit: 20 });
const failed = deliveries.find((d) => d.status === 'failed');
if (failed) await client.webhooks.redeliver(failed.id);

Failed deliveries are retried automatically with backoff for about 10 hours; the log keeps 7 days. See Webhooks for events and payloads.

Verifying webhooks

Use verifyWebhookSignature to authenticate webhook deliveries. Pass the raw request body exactly as received, the uploadad-signature header, and your signing secret:

ts
import { verifyWebhookSignature } from '@uploadad/sdk';

const rawBody = await request.text();
const ok = await verifyWebhookSignature(
	rawBody,
	request.headers.get('uploadad-signature') ?? '',
	process.env.UPLOADAD_WEBHOOK_SECRET ?? ''
);
if (!ok) return new Response('invalid signature', { status: 401 });

Signatures older than 5 minutes are rejected by default; pass a fourth argument to change the tolerance in seconds. See Webhooks for event payloads.

Error handling

Every non-2xx response throws an UploadAdError with the HTTP status, the stable machine-readable code from the API envelope, and the API's message:

ts
import { UploadAdError } from '@uploadad/sdk';

try {
	await client.uploads.get('up_missing');
} catch (err) {
	if (err instanceof UploadAdError && err.code === 'not_found') {
		console.log('not found');
	} else {
		throw err;
	}
}

Codes match the REST API's error table: invalid_request, unauthorized, subscription_required, forbidden, not_found, conflict, workspace_changed, rate_limited, two_factor_required, internal_error. Timeouts throw UploadAdTimeoutError (a subclass with code: 'timeout').