# 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](https://upload.ad/dashboard/settings/billing) 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](https://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](./mcp.md#tools) 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](/docs/api/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](/docs/api/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'`).
