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 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: send files and track the jobs that push them to your ad platforms
- Creatives: the files in your library
- Copy variants: ad texts attached to each creative
- Webhooks: 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.
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 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 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 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:
{ "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.
curl https://upload.ad/api/v1/me \
-H "Authorization: Bearer $UPLOADAD_API_KEY"const res = await fetch('https://upload.ad/api/v1/me', {
headers: { Authorization: `Bearer ${process.env.UPLOADAD_API_KEY}` }
});
const { account } = await res.json();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
$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);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))
}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"]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());
}
}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);{
"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 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.
curl https://upload.ad/api/v1/tools \
-H "Authorization: Bearer $UPLOADAD_API_KEY"const res = await fetch('https://upload.ad/api/v1/tools', {
headers: { Authorization: `Bearer ${process.env.UPLOADAD_API_KEY}` }
});
const { tools } = await res.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.
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" }'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();{ "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 |
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:
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.