# REST API reference

The upload.ad REST API (v1) lets you drive your whole workspace from any language that can make HTTPS requests: upload creatives to your Meta and TikTok ad accounts, track upload jobs, manage the library and ad copy variants, run review and approval, launch and manage ads, moderate ad comments, and read performance.

Base URL: `https://upload.ad`

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

The reference is organized by resource:

- [Uploads](./api/uploads.md): send files and track the jobs that push them to your ad platforms
- [Creatives](./api/creatives.md): the files in your library
- [Copy variants](./api/copy.md): ad texts attached to each creative
- [Webhooks](./api/webhooks.md): signed events when an upload completes or fails, with endpoint management, a delivery log, and redelivery over v1

The API is also described by an [OpenAPI specification](/docs/openapi.json).

## Conventions

**Two surfaces, one catalog.** The API has a small set of curated REST resources (account, uploads, creatives, ad copy, webhooks) and a [tool catalog](#tools) covering everything else the product can do. The REST resources are a stable convenience layer and are complete as-is: new capabilities land in the tool catalog (`GET /api/v1/tools`, `POST /api/v1/tools/{name}`), where they appear on every surface (REST RPC, MCP, CLI, SDK) at once. When both cover the same data, either is fine; prefer the catalog when scripting an agent, the REST resources when you want fixed shapes and multipart uploads.

**Authentication.** Create an API key at [upload.ad/dashboard/settings/api-keys](https://upload.ad/dashboard/settings/api-keys) and send it on every request. The free trial covers the app only. Programmatic access requires a paid plan: keys can only be created on a paid plan, and requests return `402` with the code `subscription_required` if the plan lapses.

A key is bound to the workspace that was active when it was created. Workspace owners and admins can see and revoke every key bound to their workspace.

```
Authorization: Bearer ua_live_...
```

**Format.** Requests and responses are JSON, except `POST /api/v1/uploads`, which takes `multipart/form-data`. Timestamps are ISO 8601 strings in UTC.

**Idempotency.** Every mutating request accepts an `Idempotency-Key` header. A retry with the same key within 24 hours replays the stored result of the original call instead of running again, so a network timeout can never double-create upload jobs or ads. See [Idempotency](#idempotency) below for the full semantics.

**Pagination.** List endpoints are cursor-paginated: each page carries a `nextCursor` (`null` on the last page); pass it back as the `cursor` query parameter for the next page. Cursors are opaque and stable across inserts.

**Stability.** v1 is stable. We add endpoints, optional parameters, response fields, and tools without notice; we never remove or rename existing ones, and we never change their meaning. Build clients to ignore fields and tools they do not recognize and they will keep working.

**Errors.** Every error returns a 4xx or 5xx status with a single JSON shape carrying a stable machine-readable `code` and a human-readable `message`:

```json
{ "error": { "code": "not_found", "message": "Description of the problem" } }
```

Match on `code`, not on `message` (messages may change). The codes:

| Code                    | Status | Meaning                                                   |
| ----------------------- | ------ | --------------------------------------------------------- |
| `invalid_request`       | `400`  | Malformed request body or parameters                      |
| `unauthorized`          | `401`  | Missing or invalid API key                                |
| `subscription_required` | `402`  | An active paid plan is required                           |
| `forbidden`             | `403`  | The key's creator lacks the permission for this action    |
| `not_found`             | `404`  | Resource not found, or not visible to your account        |
| `conflict`              | `409`  | The resource is not in a state that allows the operation  |
| `workspace_changed`     | `409`  | The request targeted a workspace that is no longer active |
| `rate_limited`          | `429`  | Too many requests; retry after the `Retry-After` header   |
| `two_factor_required`   | `403`  | The action needs two-factor authentication                |
| `internal_error`        | `5xx`  | Something went wrong on our side                          |

## Permissions

A key acts with its creator's permissions in the workspace it is bound to. When the member lacks the permission an action needs, the call returns `403` with the code `forbidden`. Permission-gated actions include creating ads, activating ads and changing budgets, and deleting.

Keys can additionally be scoped at creation: pick a subset of permissions on the API keys page (or pass `scopes` when creating a key) and the key is limited to those, capped by the creator's own membership. An unscoped key gets everything its creator can do. Scopes cannot be widened after creation; create a new key instead. The scope identifiers:

| Scope        | Gates                                              |
| ------------ | -------------------------------------------------- |
| `upload`     | Uploading creatives to the library                 |
| `publish`    | Publishing creatives to ad accounts (upload jobs)  |
| `edit`       | Editing copy, arranging and annotating the canvas  |
| `comment`    | Commenting on creatives                            |
| `review`     | Approving creatives and requesting changes         |
| `delete`     | Deleting creatives and folders                     |
| `import`     | Importing from Google Drive and Dropbox            |
| `share`      | Creating external review links                     |
| `ads_view`   | Viewing ads and results                            |
| `ads`        | Creating and editing ads                           |
| `launch`     | Activating ads and changing budgets (spends money) |
| `ads_delete` | Deleting ads                                       |
| `moderate`   | Moderating comments on published ads               |
| `rules`      | Managing automation rules                          |
| `ai`         | The AI assistant (spends tokens)                   |

Media downloads (`/api/media/...`) are authorized by object ownership, not by scope: any valid key for the workspace can download its assets.

Scoping follows the workspace policy: per-folder access scoping applies to the library, creatives, and uploads, so a folder-scoped member sees only what their folders contain. Ads, insights, and audiences are workspace-wide and are governed by the Ads permissions rather than folder scope.

## Account

### GET /api/v1/me

Returns the authenticated account. Useful as a connectivity and key check.

```bash tab
curl https://upload.ad/api/v1/me \
  -H "Authorization: Bearer $UPLOADAD_API_KEY"
```

```javascript tab
const res = await fetch('https://upload.ad/api/v1/me', {
	headers: { Authorization: `Bearer ${process.env.UPLOADAD_API_KEY}` }
});
const { account } = await res.json();
```

```python tab
import os, requests

res = requests.get(
    "https://upload.ad/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['UPLOADAD_API_KEY']}"},
)
account = res.json()["account"]
```

```php tab
<?php
$ch = curl_init('https://upload.ad/api/v1/me');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . getenv('UPLOADAD_API_KEY')]);
$account = json_decode(curl_exec($ch), true)['account'];
curl_close($ch);
```

```go tab
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	req, _ := http.NewRequest("GET", "https://upload.ad/api/v1/me", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("UPLOADAD_API_KEY"))
	res, _ := http.DefaultClient.Do(req)
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)
	fmt.Println(string(body))
}
```

```ruby tab
require "net/http"
require "json"

uri = URI("https://upload.ad/api/v1/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{ENV['UPLOADAD_API_KEY']}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
account = JSON.parse(res.body)["account"]
```

```java tab
import java.net.URI;
import java.net.http.*;

public class Me {
	public static void main(String[] args) throws Exception {
		HttpRequest req = HttpRequest.newBuilder(URI.create("https://upload.ad/api/v1/me"))
			.header("Authorization", "Bearer " + System.getenv("UPLOADAD_API_KEY"))
			.build();
		HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
		System.out.println(res.body());
	}
}
```

```csharp tab
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization",
    $"Bearer {Environment.GetEnvironmentVariable("UPLOADAD_API_KEY")}");
var body = await client.GetStringAsync("https://upload.ad/api/v1/me");
Console.WriteLine(body);
```

```json
{
	"account": {
		"id": "acc_123",
		"name": "Acme Inc",
		"email": "ads@acme.com",
		"plan": "growth",
		"credits": 240,
		"trialEndsAt": null
	}
}
```

| Field         | Type            | Description                                                   |
| ------------- | --------------- | ------------------------------------------------------------- |
| `id`          | string          | Your account id (the key's creator, not the workspace owner)  |
| `name`        | string          | Your account name                                             |
| `email`       | string          | Your account email                                            |
| `plan`        | string or null  | The workspace's current plan, null if not on a plan           |
| `credits`     | integer or null | Remaining credits; null for keys created by non-owner members |
| `trialEndsAt` | string or null  | Trial end date, null when on a plan                           |

## Tools

Beyond the resource endpoints above, the full upload.ad capability set is available as a catalog of named tools you can call over REST. This is the same tool set the [MCP server](./mcp.md#tools) exposes, so anything an AI agent can do, a script can do too.

### GET /api/v1/tools

Returns the full catalog. Each entry has a `name`, a `description`, an `inputSchema` (JSON Schema for the tool's arguments), a `category` (coarse grouping such as `webhooks` or `automations`), a `destructive` flag, and a `readOnly` flag (true for tools that only read data). Entries that declare their result shape also carry an `outputSchema` (same schema dialect, plus `nullable`); coverage is incremental. The response carries an `ETag`; send it back as `If-None-Match` to revalidate and receive `304 Not Modified` when the catalog has not changed, instead of re-downloading every schema.

```bash tab
curl https://upload.ad/api/v1/tools \
  -H "Authorization: Bearer $UPLOADAD_API_KEY"
```

```javascript tab
const res = await fetch('https://upload.ad/api/v1/tools', {
	headers: { Authorization: `Bearer ${process.env.UPLOADAD_API_KEY}` }
});
const { tools } = await res.json();
```

```json
{
	"tools": [
		{
			"name": "create_ad",
			"description": "Create an ad in an ad set or ad group.",
			"inputSchema": { "type": "object", "properties": { "...": {} } },
			"category": "ads",
			"destructive": false,
			"readOnly": false
		}
	]
}
```

The `destructive` flag marks tools that spend money or make irreversible changes (creating or activating ads, changing budgets, deleting). Treat it as a signal to confirm before calling.

### POST /api/v1/tools/{name}

Call a tool by name. The JSON request body is the tool's arguments; the response is `{ "result": ... }` with whatever the tool returns.

```bash tab
curl -X POST https://upload.ad/api/v1/tools/get_campaigns \
  -H "Authorization: Bearer $UPLOADAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platform": "meta" }'
```

```javascript tab
const res = await fetch('https://upload.ad/api/v1/tools/get_campaigns', {
	method: 'POST',
	headers: {
		Authorization: `Bearer ${process.env.UPLOADAD_API_KEY}`,
		'Content-Type': 'application/json'
	},
	body: JSON.stringify({ platform: 'meta' })
});
const { result } = await res.json();
```

```json
{ "result": { "campaigns": [] } }
```

Arguments are schema-validated (types, enums, array limits, numeric ranges). Invalid input returns `400` with a message that starts `Invalid input:`. Some fields are required only on one platform (for example, Meta ad sets require target countries while TikTok ad groups use location ids instead); the catalog's per-tool schema and descriptions spell out which.

### Tool error codes

Tool failures use the standard error envelope, and the `code` carries the failure kind so clients can branch without parsing prose. The closed set:

| Code             | Status | Meaning                                                        |
| ---------------- | ------ | -------------------------------------------------------------- |
| `invalid_input`  | 400    | Bad or missing arguments (includes unknown tool names)         |
| `tool_error`     | 400    | Anything else the message explains                             |
| `limit_exceeded` | 400    | A size or count cap was hit                                    |
| `not_connected`  | 400    | Needs a platform or integration connection first               |
| `plan_required`  | 402    | Needs a (higher) subscription plan                             |
| `forbidden`      | 403    | Caller lacks the workspace permission or role                  |
| `not_found`      | 404    | The referenced object does not exist or is outside your access |
| `conflict`       | 409    | Idempotency key conflict; see [Idempotency](#idempotency)      |
| `upstream_error` | 502    | The ad platform or provider rejected the request               |
| `upstream_busy`  | 503    | The ad platform is rate limiting; retry shortly                |

The same codes appear in MCP error results (the text content is compact JSON of the form `{"error":{"code","message"}}`) and on the SDK's `UploadAdError.code`. New codes may be added over time (the set is additive, like the rest of v1); treat unknown codes as `tool_error`.

### Idempotency

Any call that creates ads, uploads files, deletes, or spends money should be sent with an idempotency key so a network retry never runs the action twice. Every mutating endpoint accepts it as an `Idempotency-Key` request header; tool calls alternatively take an `idempotencyKey` argument in the body, which also works from the CLI and MCP:

```bash
curl -X POST https://upload.ad/api/v1/tools/create_ad \
  -H "Authorization: Bearer $UPLOADAD_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: launch-2026-07-21-summer" \
  -d '{ "adsetId": "...", "creativeId": "..." }'
```

A retry with the same key within 24 hours replays the stored result instead of re-running. A retry that arrives while the original is still in flight returns an error saying the request is already being processed. Keys are scoped to your workspace, may be up to 200 characters, and a single key cannot be reused for a different tool.
