# overads developer documentation > The full text of every page under https://overads.io/docs, generated from the same content the site renders. The short index is at https://overads.io/llms.txt. Base URL `https://api.overads.io/public/v1`; MCP server `https://api.overads.io/public/v1/mcp`; OpenAPI https://api.overads.io/public/v1/openapi.json. Contents: - Start: Getting started, Authentication, Rate limits, Errors, Idempotency - API reference: Connections, Posts, Media, Best times, Webhooks API, Analytics, Workspaces - MCP: MCP server, MCP tools - Webhooks: Webhooks - Platforms: Instagram, Facebook, LinkedIn, X, Bluesky, Mastodon, Threads, YouTube, TikTok, Pinterest - Guides: Cross-platform posting, Scheduling and approvals, Media by URL and presigned uploads, n8n, Make, Zapier, Workflows over the API, Managed workspaces # Start A key, a first post, the rules every call follows. ## Getting started Source: https://overads.io/docs/getting-started Create a key, schedule a post with one request, then plug the same key into your AI assistant. Everything lives under `https://api.overads.io/public/v1`. The same key works for REST, for the MCP server and for outbound webhooks. Three steps, about five minutes. ### 1. Create a key Open Settings > API (the API keys and MCP page) in the app, pick the scopes the integration needs, set an expiry if you want one, and copy the key. It starts with `sk_live_` and is shown once. A key belongs to one workspace and only ever sees that workspace. > Keys are included on Pro and Scale, and neither plan caps publishing channels. A paused workspace, one whose trial or subscription has ended, cannot create a key, and every call with an existing key answers `402` until it has a plan again. ### 2. Find a connection, then post A post targets connection ids, not platform names. List them first. curl: ```bash curl https://api.overads.io/public/v1/connections \ -H "Authorization: Bearer sk_live_YOUR_KEY" ``` Take an `id` whose `tokenStatus` is `valid`, then create a scheduled post. `scheduledAt` is ISO 8601; leave it out to save a draft instead. curl: ```bash curl -X POST https://api.overads.io/public/v1/posts \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: launch-2026-09-14-a" \ -d '{ "content": "Shipping day. Full notes on the blog.", "connectionIds": ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], "scheduledAt": "2026-09-14T16:00:00Z", "mediaUrls": ["https://cdn.example.com/launch.jpg"] }' ``` The answer is `201` with the post and a `warnings` list. Read the warnings: they are the things that happened without failing the call. ```json { "data": { "id": "b6a0…", "status": "scheduled", "approvalStatus": "pending", "scheduledAt": "2026-09-14T16:00:00.000Z", "platforms": ["linkedin"], "targets": [ { "connectionId": "3f1c…", "platform": "linkedin", "status": "pending", "postedId": null, "permalink": null } ] }, "warnings": [ { "code": "APPROVAL_REQUIRED", "message": "This workspace requires approval for scheduled posts. The post is queued as pending and will not publish until a member approves it in the app." } ] } ``` An `APPROVAL_REQUIRED` warning means the workspace has the approval switch on. The post is real and scheduled; a member approves it in the app. Read [Scheduling and approvals](https://overads.io/docs/guides/scheduling-and-approvals) for the full rule. ### 3. Give your assistant the same key The MCP server is at `https://api.overads.io/public/v1/mcp`. For Claude Code that is one line: ```bash claude mcp add --transport http overads https://api.overads.io/public/v1/mcp --header "Authorization: Bearer sk_live_YOUR_KEY" ``` Cursor, VS Code, Codex, Windsurf, n8n, Zapier and Claude Desktop's config file are on the [MCP page](https://overads.io/docs/mcp). The tools the assistant sees are the ones the key is scoped for. ChatGPT, Claude and Claude Code can also connect with no key at all: you sign in to overads and approve, see [Connect by signing in](https://overads.io/docs/mcp#connect-by-signing-in). ### Where next - [Authentication](https://overads.io/docs/authentication): scopes, expiry, what each status code means. - [Posts](https://overads.io/docs/api/posts): every field on the create call, variants, per-platform settings. - [Webhooks](https://overads.io/docs/webhooks): get told when a post lands instead of polling. - [OpenAPI](https://api.overads.io/public/v1/openapi.json): the machine-readable contract, no key needed. ### Other languages The API is plain JSON over HTTPS, so any HTTP client works. Two equivalents of the create call above. Node 18+: ```javascript const res = await fetch("https://api.overads.io/public/v1/posts", { method: "POST", headers: { Authorization: "Bearer " + process.env.OVERADS_API_KEY, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ content: "Shipping day. Full notes on the blog.", connectionIds: ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], scheduledAt: "2026-09-14T16:00:00Z", }), }); const { data, warnings } = await res.json(); ``` Python 3: ```python import os, uuid, requests r = requests.post( "https://api.overads.io/public/v1/posts", headers={ "Authorization": f"Bearer {os.environ['OVERADS_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "content": "Shipping day. Full notes on the blog.", "connectionIds": ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], "scheduledAt": "2026-09-14T16:00:00Z", }, timeout=30, ) body = r.json() print(r.status_code, body.get("warnings")) ``` ## Authentication Source: https://overads.io/docs/authentication Bearer keys, the eight scopes, expiry, and the status codes a bad credential produces. ### The header Every request carries the key as a bearer token. There is no other way to authenticate on `/public/v1`. ```http Authorization: Bearer sk_live_YOUR_KEY ``` Keys are created in Settings > API (the API keys and MCP page). Each key is bound to the workspace it was created in; the API never accepts a workspace parameter to move a key elsewhere. ### Scopes A key carries a list of scopes chosen when it is created. Each route declares the scope it needs; a call without it answers `403` with code `SCOPE_MISSING`. Scopes are all-or-nothing per route: a route that lists two needs both. | Scope | Unlocks | | --- | --- | | `posts:read` | Read posts and their per-account delivery log | | `posts:write` | Create, edit, schedule, cancel and publish posts | | `connections:read` | List connected social accounts and their token health | | `media:write` | Upload media and ingest media by URL | | `analytics:read` | Read ad accounts, metrics and the landing-page crawl | | `workflows:run` | Trigger workflows and read their runs | | `webhooks:manage` | Register and manage outbound webhooks | | `workspaces:manage` | Create client workspaces, mint their keys and hosted connect links | On the MCP server the same scopes decide which tools `tools/list` returns. A tool outside the key's scopes is not listed and cannot be called. > Keys created before scopes were enforced carry an empty scope list and keep full access. A key created in the app today always carries an explicit list. ### Expiry and revocation A key may carry an `expiresAt`. Past that instant it answers `401` like a key that never existed. Revoking a key in the app has the same effect immediately. There is no refresh flow: make a new key. ### Status codes The flat bodies below also carry `error`, `path` and `timestamp`; the table shows the fields to switch on. | Status | Body | Meaning | | --- | --- | --- | | `401` | `{ "statusCode": 401, "message": "Missing API key" }` | No `Authorization: Bearer sk_live_...` header. | | `401` | `{ "statusCode": 401, "message": "Invalid API key" }` | Unknown, revoked or expired key. | | `402` | flat body with `code: "workspace_locked"` | The workspace is paused: its trial or subscription has ended. Choose a plan in Settings, Billing and the same call works again. | | `402` | flat body with `code: "plan_feature_unavailable"` | The workspace's plan does not include API access. Keys work on Pro and Scale. | | `403` | `{ "error": { "code": "SCOPE_MISSING" } }` | The key lacks the scope this route needs. | | `429` | flat body with `retryAfterSeconds` | Rate limit, see [Rate limits](https://overads.io/docs/rate-limits). | ### Managed workspaces An agency key may act for a client workspace it created over `POST /workspaces` by sending `x-overads-workspace: ` on any public route or MCP call. The swap happens before any other check, so scopes, rate limits and idempotency all apply to the client workspace. Only a direct child counts: a managed workspace cannot itself manage others. ```http Authorization: Bearer sk_live_AGENCY_KEY x-overads-workspace: c0ff3e00-1a2b-4c3d-8e4f-5a6b7c8d9e0f ``` | Case | Answer | | --- | --- | | Header names a workspace the key's workspace manages | The call acts on that workspace. | | Header names the key's own workspace | Same as no header. | | Header names any other workspace | `403` with `WORKSPACE_NOT_MANAGED`. | | Header is not a single uuid | `400` with `WORKSPACE_NOT_MANAGED` and `field: "x-overads-workspace"`. | | Header on `/workspaces/*` routes | `400` with `WORKSPACE_HEADER_NOT_ALLOWED`; those routes always act on the agency. | The alternative is a key that belongs to the client workspace, minted with `POST /workspaces/:id/api-keys`; it needs no header. The whole flow is on [Managed workspaces](https://overads.io/docs/guides/managed-workspaces). ## Rate limits Source: https://overads.io/docs/rate-limits Per-workspace ceilings by plan, the headers that report them, and what a 429 looks like. Limits are counted per workspace, not per IP address. Adding a machine does not raise the ceiling; sharing an office network does not lower it. The MCP server counts against the same budget as REST. ### Ceilings | Plan | Per minute | Per UTC day | | --- | --- | --- | | Scale | 600 | 200,000 | | Pro | 120 | 40,000 | The daily counter resets at UTC midnight. ### Headers Every response from a keyed route carries the ceiling. The remaining count is present only when it was measured; a missing header means the counter could not be read, not that it is zero. | Header | Value | | --- | --- | | `X-RateLimit-Scope` | `workspace` | | `X-RateLimit-Limit-Minute` | The per-minute ceiling for the plan | | `X-RateLimit-Limit-Day` | The daily ceiling | | `X-RateLimit-Remaining-Day` | Requests left today, when known | ### When you hit one Both cases answer `429`. The body is the flat shape, not the `{ error }` envelope, and names how long to wait. Burst: ```json { "statusCode": 429, "code": "public_api_rate_limited", "message": "This workspace has made 120 public API requests in the last minute, which is the ceiling. ...", "perMinute": 120, "retryAfterSeconds": 17 } ``` Daily cap: ```json { "statusCode": 429, "code": "public_api_daily_cap_reached", "message": "This workspace has made all 40000 of its public API requests for today on the pro plan. ...", "used": 40000, "limit": 40000, "plan": "pro", "retryAfterSeconds": 21600 } ``` Sleep for `retryAfterSeconds` and retry. Writes are safe to retry when you sent an `Idempotency-Key`; see [Idempotency](https://overads.io/docs/idempotency). > If the rate counter itself is unreachable the API serves the request and logs it. A counter outage never takes an integration offline. ## Errors Source: https://overads.io/docs/errors The one error shape, every code in the catalogue, and what to do about each. ### The shape Every error the public API raises itself has one body. `code` is stable for the life of v1 and is the thing to switch on; `message` is for a person; `field` names the offending input when there is one. ```json { "error": { "code": "SCHEDULED_TIME_IN_PAST", "message": "scheduledAt is 42 minutes in the past. Send a time in the future, or omit it to create a draft.", "field": "scheduledAt" } } ``` Three families do not use this shape. Authentication and rate-limit failures answer with the flat body shown on [Authentication](https://overads.io/docs/authentication) and [Rate limits](https://overads.io/docs/rate-limits). Schema validation of a request body (an unknown field, a wrong type, a missing required field) answers `400` with the framework's flat body: `{ "statusCode": 400, "message": ["..."], "error": "Bad Request" }`. Unknown fields are refused, never ignored. And a post that breaks one target channel's limits on create or edit is refused by the shared publish checks, which answer `400` with a flat body carrying the code at the top level: `{ "statusCode": 400, "code": "TOO_LONG", "message": "X posts are limited to 280 characters (this one is 301)." }`. Those codes are listed after the catalogue. Every flat body also carries `error`, `path` and `timestamp` beside the fields shown here; the examples on these pages list the fields worth switching on. ### Warnings Things that happened without failing the call ride on successful writes as `warnings: [{ code, message }]`. Two exist: `APPROVAL_REQUIRED` (the post is queued as pending) and `SCHEDULED_TIME_COERCED` (a `scheduledAt` up to five minutes in the past was moved to now). ### The catalogue | Code | Status | Meaning | Recovery | | --- | --- | --- | --- | | `MEDIA_REQUIRED` | 400 | The post targets Instagram and carries no media. (YouTube, TikTok and Pinterest refuse a text-only scheduled post with the same code in the flat channel-limit body below.) | Add `mediaUrls` or `mediaIds`. | | `MEDIA_NOT_FOUND` | 404 | A `mediaIds` entry is not a media asset in this workspace. | Use the id returned by `POST /media/upload-url`. | | `MEDIA_NOT_READY` | 409 | The asset has not been completed, or the bytes are not in storage yet. | PUT the file, then `POST /media/:id/complete`. | | `MEDIA_TOO_LARGE` | 400 | Over the cap: 15 MB images, 500 MB video by upload, 25 MB video by URL. (A video over one target channel's own size cap carries the same code in the flat channel-limit body below.) | Shrink it, or use the presigned upload for large video. | | `MEDIA_URL_REFUSED` | 400 | The URL is not public https, did not answer 2xx, or did not return an image or video. | Host the file on a public https URL. | | `MEDIA_STORAGE_UNAVAILABLE` | 503 | Object storage is not configured or not reachable. | Retry later; nothing was written. | | `SCHEDULED_TIME_IN_PAST` | 400 | `scheduledAt` is more than five minutes in the past. | Send a future time, or omit it for a draft. | | `PLATFORM_SETTING_UNKNOWN` | 400 | A key in `platformSettings` is unknown, mistyped, or for a platform the post does not target. | The `field` names the path; check the [platform page](https://overads.io/docs/platforms/instagram). | | `POST_NOT_FOUND` | 404 | No post with that id in this workspace. | List with `GET /posts`. | | `POST_NOT_EDITABLE` | 409 | The post is publishing, published, partially published or cancelled. | Create a new post. | | `CONNECTION_NOT_FOUND` | 404 | A `connectionIds` entry is not in this workspace. | List with `GET /connections`. | | `CONNECTION_EXPIRED` | 409 | The connection is not active or its token has expired. | Reconnect the account in the app. | | `APPROVAL_REQUIRED` | 409 | `POST /posts/:id/publish` on a post that is pending approval. | A member approves it in the app. Do not retry. | | `IDEMPOTENCY_KEY_CONFLICT` | 422 | The `Idempotency-Key` was used with a different request. | Use a fresh key for a new request. | | `IDEMPOTENCY_IN_FLIGHT` | 409 | A request with this key is still running. | Wait, then retry with the same key to get its result. | | `SCOPE_MISSING` | 403 | The key lacks the route's scope. | Create a key with the scope. | | `VALIDATION_FAILED` | 400 (409 for a taken slug) | An `Idempotency-Key` over 255 characters, an upload `contentType` the kind does not accept, a taken workspace `slug`, or a managed key asking only for `workspaces:manage`. | The `field` names it. | | `WEBHOOK_NOT_FOUND` | 404 | No endpoint with that id. | List with `GET /webhooks`. | | `WEBHOOK_URL_REFUSED` | 400 | Not https, or a localhost, private or link-local address, or credentials in the URL. | Use a public https URL. | | `WEBHOOK_EVENT_UNKNOWN` | 400 | An event name outside the catalogue. | See [Webhooks](https://overads.io/docs/webhooks) for the list. | | `WEBHOOK_DELIVERY_NOT_FOUND` | 404 | No delivery with that id on that endpoint. | List with `GET /webhooks/:id/deliveries`. | | `WORKSPACE_NOT_FOUND` | 404 | No managed workspace with that id under this key's workspace. | List with `GET /workspaces`. | | `WORKSPACE_NOT_MANAGED` | 403 (400 when malformed) | `x-overads-workspace` names a workspace this key cannot act for, or is not a single uuid. | Create the workspace over `POST /workspaces`, or drop the header. | | `WORKSPACE_HEADER_NOT_ALLOWED` | 400 | `x-overads-workspace` was sent to a `/workspaces/*` route. | Drop the header; those routes act on the agency. | | `CONNECT_TOKEN_INVALID` | 404 | A hosted connect link that never existed or was rotated away. | Mint a new one with `POST /workspaces/:id/connect-url`. | | `CONNECT_TOKEN_EXPIRED` | 410 | A hosted connect link past its `expiresAt`. | Mint a new one. | | `CONNECT_SESSION_INVALID` | 401 | The 15-minute session behind a connect attempt is bad or its link is no longer live. | Reload the connect page. | ### Channel-limit refusals Every target of a post is checked against its channel's limits when the post is created or edited (`POST /posts`, `PATCH /posts/:id`, `create_post`, `update_post`). A failure answers `400` with the flat body `{ statusCode, code, message }`; `message` names the channel and the cap, for example `Bluesky takes either images or one video, not both in one post.` Video size and duration are checked only when overads knows them from the file's media asset record; nothing is guessed from a URL. | Code | Meaning | Recovery | | --- | --- | --- | | `TOO_LONG` | The copy for one channel is over its character cap. | Give that channel a shorter `variants` entry. | | `TOO_MANY_ITEMS` | More images, videos or total items than the channel takes; or a second video on a channel that takes one. | Trim the media list, or split the post. | | `MEDIA_UNSUPPORTED` | Images and a video together on Bluesky or TikTok, or images on YouTube, which takes video only. | Send one kind of media to that channel. | | `MEDIA_REQUIRED` | A scheduled post with no media for YouTube (one video), TikTok (one video or photos) or Pinterest (one image or one video). A draft is allowed to be incomplete. | Attach media before scheduling. | | `MEDIA_TOO_LARGE` | A video over the channel's file cap (Bluesky 100 MB, Mastodon 99 MB, X 512 MB, LinkedIn 500 MB, Instagram, Facebook and Threads 1 GB, Pinterest 2 GB, TikTok 4 GB). | Shrink it, or drop that channel. | | `MEDIA_TOO_LONG` | A video over the channel's duration cap (X 140 seconds, Bluesky 3 minutes, Threads 5, TikTok 10, Instagram 15, Pinterest 15, Facebook 20, LinkedIn 30, YouTube 12 hours). | Trim it, or drop that channel. | | `MEDIA_TOO_SHORT` | A Pinterest video under 4 seconds. | Use a longer video. | | `BOARD_REQUIRED` | A Pinterest post scheduled without `platformSettings.pinterest.boardId`. | Pass `boardId`; the app's channel settings list the account's boards. | ### Per-account failures are not errors A post to three accounts that lands on two answers `200`. The account that failed is in `targets` with `status: "failed"` and the platform's error text verbatim in `error`. Read targets, not just the HTTP status, and subscribe to `post.failed` to be told. ## Idempotency Source: https://overads.io/docs/idempotency The Idempotency-Key header: replay, conflict and in-flight semantics on every write. Every `POST`, `PATCH` and `DELETE` on `/public/v1` accepts an `Idempotency-Key` header. Send one on every write so a retry after a timeout cannot create the same post twice. ```http Idempotency-Key: 4d2f0c8e-launch-post ``` ### The rule - The key is scoped to the workspace and lives 24 hours. Up to 255 characters; a UUID is a fine choice. - The fingerprint is the method, the path and the JSON body with keys sorted. Serialising the same body in a different key order still matches. - Same key, same fingerprint, finished: the stored response is replayed with its original status and a `Idempotency-Replayed: true` header. Nothing runs again. - A replay never carries a minted credential. On `POST /workspaces/:id/api-keys`, `POST /webhooks` and `POST /webhooks/:id/rotate-secret` the stored copy has `secret: null` and `secretOmitted: true`; the secret is returned by the original response only. - Same key, same fingerprint, still running: `409` with code `IDEMPOTENCY_IN_FLIGHT`. Wait and retry. - Same key, different fingerprint: `422` with code `IDEMPOTENCY_KEY_CONFLICT`. Your retry loop reused a key for a new request. - A failed attempt releases the key, so a retry after a `4xx` or `5xx` runs for real. - No header: the write runs every time it is sent. ### Over MCP Write tools take an optional `idempotencyKey` argument backed by the same store. The fingerprint there is the tool name plus the arguments, so a key used on REST and again on MCP with different arguments is a conflict. ### Failure mode > If the idempotency store is unreachable the write proceeds without deduplication and the outage is logged. The API never refuses a write to protect against a retry. # API reference Every route under /public/v1, with fields and one curl each. ## Connections Source: https://overads.io/docs/api/connections List the social accounts a workspace connected, with token health and the last delivery outcome. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `connections:read`. Connecting an account happens in the app, with the platform's own sign-in. The API reads what is connected; it cannot connect or disconnect. A LinkedIn Company Page is its own row with `platform: "linkedin"` and the Page's name; an Instagram account connected by signing in on Instagram and one connected through Facebook both appear as `platform: "instagram"`. See the [platform pages](https://overads.io/docs/platforms/linkedin) for what each door can do. #### GET /connections Scope `connections:read`. Every social account connected to the workspace, with token health. `tokenStatus` is `valid`, `expiring_soon` (inside seven days of expiry), `expired` (past expiry, or the connection is not active) or `unknown` (the platform reported no expiry). A post to an `expired` connection is refused with `CONNECTION_EXPIRED`. `lastError` and `lastSuccessfulPost` come from the delivery log. Both null means nothing has been delivered through it yet. Response: `{ data: Connection[] }`. 200: ```json { "data": [ { "id": "3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11", "platform": "linkedin", "accountName": "overads", "displayName": "overads", "avatarUrl": null, "status": "active", "tokenStatus": "valid", "tokenExpiresAt": "2026-11-02T09:12:00.000Z", "lastError": null, "lastSuccessfulPost": "2026-09-08T16:00:04.000Z", "createdAt": "2026-08-01T10:00:00.000Z" } ] } ``` curl: ```bash curl https://api.overads.io/public/v1/connections -H "Authorization: Bearer sk_live_YOUR_KEY" ``` ## Posts Source: https://overads.io/docs/api/posts Create, list, edit, cancel and publish posts, and read the per-account delivery log. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `posts:read, posts:write`. ### The post model A post is one group with one `content` string, optional per-platform `variants`, media, and a `targets` list with one row per connection. `status` describes the group; each target carries its own `status`, `postedId`, `permalink` and `error`, so a post that reached two of three accounts is `partial_published` with the failure named on the third target. | `status` | Meaning | | --- | --- | | `draft` | Saved, no `scheduledAt`. | | `scheduled` | Queued for `scheduledAt`. | | `publishing` | Delivery is running. | | `published` | Every target landed. | | `partial_published` | Some targets landed, some failed. | | `failed` | No target landed. | | `cancelled` | Cancelled before delivery. | `approvalStatus` is `none`, `pending`, `approved` or `rejected`. A `pending` post does not publish until a member approves it; see [Scheduling and approvals](https://overads.io/docs/guides/scheduling-and-approvals). #### POST /posts Scope `posts:write`. Create a draft, or a scheduled post when `scheduledAt` is set. Answers `201` with `{ data, warnings }`. When the workspace requires approval for scheduled posts the post lands with `approvalStatus: "pending"` and `warnings` carries `APPROVAL_REQUIRED`. Platform limits (characters, media counts, required media) are checked here, so an over-limit post is refused at creation rather than at the scheduled minute. See the [platform pages](https://overads.io/docs/platforms/linkedin). | Body | Type | | Meaning | | --- | --- | --- | --- | | `content` | string | required | The shared copy, 1 to 10,000 characters. Per-platform overrides go in `variants`. | | `connectionIds` | string[] | required | 1 to 20 connection ids from `GET /connections`. | | `scheduledAt` | ISO 8601 | optional | When to publish. Omit for a draft. Up to five minutes in the past is moved to now with a `SCHEDULED_TIME_COERCED` warning; earlier is `SCHEDULED_TIME_IN_PAST`. | | `mediaUrls` | string[] | optional | Up to 10 public https image or video URLs. Downloaded server-side into overads storage. A URL already in overads storage is used as-is. | | `mediaIds` | string[] | optional | Up to 10 ids of media completed through `POST /media/:id/complete`. | | `platformSettings` | object | optional | Per-platform delivery settings keyed by platform, for example `{ "mastodon": { "visibility": "unlisted" } }`. Unknown keys are refused with `PLATFORM_SETTING_UNKNOWN`. Settings for a platform the post does not target are refused too. | | `variants` | object | optional | Per-platform copy overrides: `{ "twitter": { "content": "..." } }`. | | `requireApproval` | boolean | optional | Queue the scheduled post for approval even when the workspace does not require it. | Response: `{ data: Post, warnings: Warning[] }`. `Post` carries `targets`, one row per connection. 200: ```json { "data": { "id": "b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d", "content": "Shipping day. Full notes on the blog.", "variants": null, "mediaUrls": ["https://media.overads.io/social-media/.../launch.jpg"], "platforms": ["linkedin", "twitter"], "connectionIds": ["3f1c…", "9a77…"], "status": "scheduled", "approvalStatus": "none", "scheduledAt": "2026-09-14T16:00:00.000Z", "publishedAt": null, "platformSettings": null, "platformPostIds": null, "error": null, "createdAt": "2026-09-11T09:30:12.000Z", "updatedAt": "2026-09-11T09:30:12.000Z", "targets": [ { "id": "…", "connectionId": "3f1c…", "platform": "linkedin", "status": "pending", "postedId": null, "permalink": null, "error": null, "attempts": 0, "publishedAt": null, "updatedAt": "…" }, { "id": "…", "connectionId": "9a77…", "platform": "twitter", "status": "pending", "postedId": null, "permalink": null, "error": null, "attempts": 0, "publishedAt": null, "updatedAt": "…" } ] }, "warnings": [] } ``` curl: ```bash curl -X POST https://api.overads.io/public/v1/posts -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "content": "Shipping day. Full notes on the blog.", "connectionIds": ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], "scheduledAt": "2026-09-14T16:00:00Z", "variants": { "twitter": { "content": "Shipping day. Notes: overads.io/blog" } } }' ``` #### GET /posts Scope `posts:read`. List posts, newest first. | Query | Type | | Meaning | | --- | --- | --- | --- | | `status` | enum | optional | `draft`, `scheduled`, `publishing`, `published`, `partial_published`, `failed` or `cancelled`. | | `platform` | string | optional | One platform id, for example `linkedin`. | | `from` | ISO 8601 | optional | Lower bound on `scheduledAt`. | | `to` | ISO 8601 | optional | Upper bound on `scheduledAt`. | | `page` | integer | optional | Default 1. | | `limit` | integer | optional | Default 50, max 200. | Response: `{ data: Post[], page, limit, total }`. Rows in the list do not carry `targets`; read one post for those. curl: ```bash curl "https://api.overads.io/public/v1/posts?status=scheduled&limit=20" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /posts/:id Scope `posts:read`. One post with its per-account delivery log in `targets`. Response: `{ data: Post }`. curl: ```bash curl https://api.overads.io/public/v1/posts/b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### PATCH /posts/:id Scope `posts:write`. Edit a draft or scheduled post. Only the fields you send change. Takes the same fields as create, all optional. Refused with `POST_NOT_EDITABLE` once the post is publishing, published, partially published or cancelled. Scheduling a draft through this edit applies the workspace approval rule the same way create does, with the same `APPROVAL_REQUIRED` warning. Response: `{ data: Post, warnings: Warning[] }`. curl: ```bash curl -X PATCH https://api.overads.io/public/v1/posts/b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "scheduledAt": "2026-09-15T09:00:00Z" }' ``` #### DELETE /posts/:id Scope `posts:write`. Cancel a draft or scheduled post. The row is kept with `status: "cancelled"`. Nothing is removed from any platform. A post that already published cannot be cancelled (`POST_NOT_EDITABLE`). Response: `{ data: Post, warnings: [] }`. curl: ```bash curl -X DELETE https://api.overads.io/public/v1/posts/b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` #### POST /posts/:id/publish Scope `posts:write`. Publish now and wait for the outcome. Refused with `APPROVAL_REQUIRED` (`409`) while the post is pending approval; a member approves it in the app. The call returns when delivery has been attempted on every target, so read `targets` for the per-account result. Response: `{ data: Post, warnings: [] }` with `targets` filled in: `postedId` and `permalink` on success, `error` verbatim from the platform on failure. curl: ```bash curl -X POST https://api.overads.io/public/v1/posts/b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d/publish -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` #### GET /posts/:id/targets Scope `posts:read`. The per-account delivery log for one post. Response: `{ data: Target[] }`. Each target has `connectionId`, `platform`, `status`, `postedId`, `permalink`, `error`, `attempts`, `publishedAt`, `updatedAt`. curl: ```bash curl https://api.overads.io/public/v1/posts/b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d/targets -H "Authorization: Bearer sk_live_YOUR_KEY" ``` ## Media Source: https://overads.io/docs/api/media Two ways to attach media: public URLs the server downloads, or presigned uploads for large files. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `media:write`. ### Which path - `mediaUrls` on the post: the fast path. Public https, up to 10 URLs, 15 MB per image and 25 MB per video. The server downloads the file into overads storage; the post cannot tell how the media arrived. - Presigned upload: for anything private or larger, up to 500 MB of video. Three calls: get a URL, `PUT` the bytes, complete. Every URL is fetched through the same guard the rest of the platform uses: public addresses only, redirects re-checked, the socket pinned to the vetted address. A refusal names the host, never the full URL, because signed URLs carry tokens. #### POST /media/upload-url Scope `media:write`. A presigned `PUT` for a direct upload to overads storage. Images up to 15 MB (`image/jpeg`, `image/png`, `image/gif`, `image/webp`); video up to 500 MB (`video/mp4`, `video/quicktime`, `video/webm`). The URL is valid for 15 minutes. PUT the bytes to `uploadUrl` with exactly the returned `headers`, then call complete. For a file already on the public web skip this and pass `mediaUrls` on the post. | Body | Type | | Meaning | | --- | --- | --- | --- | | `kind` | enum | required | `image` or `video`. | | `contentType` | string | required | The `Content-Type` the PUT will send. | | `bytes` | integer | required | Exact byte length of the file the PUT will send. | Response: `{ data: { media, uploadUrl, method: "PUT", headers, expiresAt }, warnings: [] }`. `media.status` is `uploading` until completed. 200: ```json { "data": { "media": { "id": "7c2e…", "url": "https://media.overads.io/social-media/…/7c2e….mp4", "kind": "video", "status": "uploading", "source": "upload", "bytes": 48213004, "width": null, "height": null, "durationMs": null, "createdAt": "…" }, "uploadUrl": "https://….amazonaws.com/…?X-Amz-Signature=…", "method": "PUT", "headers": { "Content-Type": "video/mp4", "Content-Length": "48213004" }, "expiresAt": "2026-09-11T09:45:00.000Z" }, "warnings": [] } ``` curl: ```bash curl -X POST https://api.overads.io/public/v1/media/upload-url -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "kind": "video", "contentType": "video/mp4", "bytes": 48213004 }' ``` #### POST /media/:id/complete Scope `media:write`. Confirm the upload landed. The asset becomes `ready` and can be attached through `mediaIds`. Refused with `MEDIA_NOT_READY` when no object is in storage yet, and with `MEDIA_TOO_LARGE` when the uploaded object is over the cap for its kind. Response: `{ data: Media, warnings: [] }`. curl: ```bash curl -X POST https://api.overads.io/public/v1/media/7c2e1b0a-4f3d-4e2b-9a1c-2d3e4f5a6b7c/complete -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### DELETE /media/:id Scope `media:write`. Delete a media asset and its stored object. Posts that already reference the URL keep the string, but the object is gone. Delete only media no post still needs. Response: `204`, no body. curl: ```bash curl -X DELETE https://api.overads.io/public/v1/media/7c2e1b0a-4f3d-4e2b-9a1c-2d3e4f5a6b7c -H "Authorization: Bearer sk_live_YOUR_KEY" ``` ### The upload, end to end bash: ```bash # 1. reserve RES=$(curl -s -X POST https://api.overads.io/public/v1/media/upload-url -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d "{ \"kind\": \"video\", \"contentType\": \"video/mp4\", \"bytes\": $(stat -f%z reel.mp4) }") URL=$(echo "$RES" | jq -r .data.uploadUrl) ID=$(echo "$RES" | jq -r .data.media.id) # 2. put the bytes with the returned headers curl -X PUT "$URL" -H "Content-Type: video/mp4" --data-binary @reel.mp4 # 3. complete, then attach curl -X POST https://api.overads.io/public/v1/media/$ID/complete -H "Authorization: Bearer sk_live_YOUR_KEY" curl -X POST https://api.overads.io/public/v1/posts -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d "{ \"content\": \"New reel\", \"connectionIds\": [\"...\"], \"mediaIds\": [\"$ID\"] }" ``` ## Best times Source: https://overads.io/docs/api/best-times Suggested slots to schedule into, from the workspace's own publish history. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `posts:read`. #### GET /best-times Scope `posts:read`. Suggested upcoming times to schedule, from this workspace's own publish history. At most six slots come back. When history is thin the slots come from per-platform priors and `fromHistory` is `false`. Say which they are when you show them. `reason` is one of two sentences: `Your posts have shipped well around this time` when the slots come from history, `Common high-engagement window` otherwise. | Query | Type | | Meaning | | --- | --- | --- | --- | | `platform` | string | optional | Restrict to one platform id. | Response: `{ data: { slots: { at, label, score, reason }[], fromHistory: boolean } }`. `score` is a relative strength from 0 to 100. 200: ```json { "data": { "slots": [ { "at": "2026-09-15T17:00:00.000Z", "label": "Tue 6:00 PM", "score": 84, "reason": "Your posts have shipped well around this time" } ], "fromHistory": true } } ``` curl: ```bash curl "https://api.overads.io/public/v1/best-times?platform=linkedin" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` ## Webhooks API Source: https://overads.io/docs/api/webhooks Register endpoints, rotate secrets, read the delivery log, redeliver and test. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `webhooks:manage`. This page is the management surface. The events, the envelope, the signature and the retry policy are on the [Webhooks](https://overads.io/docs/webhooks) page. Endpoints can also be managed in the app under Settings > Webhooks. #### GET /webhooks Scope `webhooks:manage`. List endpoints. Secrets are never included. Response: `{ data: Endpoint[] }` with `id`, `name`, `url`, `events`, `active`, `consecutiveFailures`, `lastDeliveryAt`, `createdAt`, `updatedAt`. curl: ```bash curl https://api.overads.io/public/v1/webhooks -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### POST /webhooks Scope `webhooks:manage`. Register an endpoint. The signing secret is returned once, here. | Body | Type | | Meaning | | --- | --- | --- | --- | | `name` | string | required | 1 to 80 characters. | | `url` | string | required | https only. Localhost, private and link-local addresses, and URLs carrying credentials, are refused with `WEBHOOK_URL_REFUSED`. | | `events` | string[] | required | One or more event names from the [catalogue](https://overads.io/docs/webhooks). Duplicates and unknown names are refused. | Response: `201` with `{ data: Endpoint & { secret }, warnings: [] }`. Store `secret` (`whsec_` plus 64 hex characters); it is not shown again. 200: ```json { "data": { "id": "e1d2…", "name": "Zapier catch hook", "url": "https://hooks.example.com/overads", "events": ["post.published", "post.failed"], "active": true, "consecutiveFailures": 0, "lastDeliveryAt": null, "createdAt": "…", "updatedAt": "…", "secret": "whsec_0f9a…" }, "warnings": [] } ``` curl: ```bash curl -X POST https://api.overads.io/public/v1/webhooks -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Zapier catch hook", "url": "https://hooks.example.com/overads", "events": ["post.published", "post.failed"] }' ``` #### GET /webhooks/:id Scope `webhooks:manage`. One endpoint. Response: `{ data: Endpoint }`. curl: ```bash curl https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### PATCH /webhooks/:id Scope `webhooks:manage`. Rename, re-point, re-subscribe, pause or resume. | Body | Type | | Meaning | | --- | --- | --- | --- | | `name` | string | optional | 1 to 80 characters. | | `url` | string | optional | https only; re-checked against the same address rules. | | `events` | string[] | optional | Replaces the subscription list. | | `active` | boolean | optional | `false` pauses the endpoint. `true` resumes it and resets `consecutiveFailures` to 0. | Response: `{ data: Endpoint, warnings: [] }`. curl: ```bash curl -X PATCH https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "active": true }' ``` #### DELETE /webhooks/:id Scope `webhooks:manage`. Delete an endpoint and its delivery log. Response: `204`, no body. curl: ```bash curl -X DELETE https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### POST /webhooks/:id/rotate-secret Scope `webhooks:manage`. Mint a new signing secret. The old one stops verifying with the next send. Response: `{ data: Endpoint & { secret }, warnings: [] }`. curl: ```bash curl -X POST https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f/rotate-secret -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /webhooks/:id/deliveries Scope `webhooks:manage`. The last deliveries, newest first. | Query | Type | | Meaning | | --- | --- | --- | --- | | `limit` | integer | optional | 1 to 50, default 50. | Response: `{ data: Delivery[] }` with `id`, `event`, `attempt` (0 until the first try), `statusCode` (null when no response arrived), `error`, `nextAttemptAt`, `deliveredAt`, `createdAt` and the `payload` that was sent. curl: ```bash curl "https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f/deliveries?limit=20" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### POST /webhooks/:id/deliveries/:deliveryId/redeliver Scope `webhooks:manage`. Send a past delivery again, as a new delivery with the old payload. Response: `201` with `{ data: Delivery, warnings: [] }`. curl: ```bash curl -X POST https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f/deliveries/5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d/redeliver -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### POST /webhooks/:id/test Scope `webhooks:manage`. Queue a signed `ping` so you can verify the signature end to end. `ping` cannot be subscribed to; it only ever comes from this route. Its `data` is `{ endpointId, message }`. Response: `201` with `{ data: Delivery, warnings: [] }`. curl: ```bash curl -X POST https://api.overads.io/public/v1/webhooks/e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f/test -H "Authorization: Bearer sk_live_YOUR_KEY" ``` ## Analytics Source: https://overads.io/docs/api/analytics Ad accounts, spend and results by day, and the landing-page crawl, every response with provenance. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `analytics:read`. `analytics:read` covers three things: the ad accounts a workspace connected, their spend and results by day, and the landing-page crawl. Two in-app products are not on the API and have no MCP tool: Google Search Console (read-only, under Signals in the app) and Signals itself. Per-post engagement is read through two MCP tools, described at the end of this page. ### Every response carries provenance Analytics routes answer `{ data, provenance }`. Read `provenance` before `data`: it says which source produced the reading, when it was taken, and, when `data` is empty, which of three different things that means. | `provenance.collection` | Meaning | | --- | --- | | `source_not_connected` | Nothing is connected for this source. Says nothing about your ads. | | `never_collected` | A source exists and no reading has been taken yet. | | `collected` | A reading exists; `collectedAt` is when. | | `collection_stalled` | An older reading exists and collection has been failing since; `stalledDetail` says why. | `provenance.emptyMeans` is `no_source_connected`, `collection_has_not_run` or `we_looked_and_there_is_nothing`. Only the last licenses a sentence like "there were none". `rowCount` is null when no reading was taken. `statement` is the whole thing as one sentence, safe to show verbatim. > Missing is null, never zero. `sum()` over no rows is null on the wire, and a null counter beside `sourceRows: 0` is how you tell "no rows in the window" from "rows that sum to zero". #### GET /sources Scope `analytics:read`. What this deployment can answer: every source, whether it is enabled, and why when it is not. Response: `{ version: "v1", sources: { id, label, ownership, origin, enabled, disabledBecause }[] }`. Platform-derived sources ship off pending a legal review and say so in `disabledBecause`. curl: ```bash curl https://api.overads.io/public/v1/sources -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /ad-accounts Scope `analytics:read`. The ad accounts this workspace connected, each with its own sync state. Response: `{ data: AdAccount[], provenance }`. Each row has `id`, `platform`, `externalId`, `name`, `currency`, `timezone`, `status`, `metricsCollection`, `syncStatus`, `syncError`. curl: ```bash curl https://api.overads.io/public/v1/ad-accounts -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /metrics/summary Scope `analytics:read`. Totals over a window. Every counter is null when there is no reading, never 0. Money is in minor units (cents) as a string. Check `currencies` before adding totals: more than one means they cannot be summed. | Query | Type | | Meaning | | --- | --- | --- | --- | | `from` | YYYY-MM-DD | optional | First day. Send both ends or neither; the default is the last 30 days. | | `to` | YYYY-MM-DD | optional | Last day, inclusive. | | `accountId` | uuid | optional | One ad account. Omit for all. | Response: `{ data: { from, to, currencies, impressions, clicks, spendMinor, conversions, revenueMinor, sourceRows, coversThrough } | null, provenance }`. 200: ```json { "data": { "from": "2026-08-12", "to": "2026-09-10", "currencies": ["USD"], "impressions": "1842211", "clicks": "40318", "spendMinor": "1284400", "conversions": "912", "revenueMinor": null, "sourceRows": 58, "coversThrough": "2026-09-10" }, "provenance": { "source": "own_ad_metrics", "sourceLabel": "your connected ad accounts", "ownership": "first_party", "collection": "collected", "collectedAt": "2026-09-11T02:04:11.000Z", "stalledDetail": null, "rowCount": 58, "emptyMeans": null, "statement": "Collected from your connected ad accounts at 2026-09-11T02:04:11.000Z. Every figure below describes that moment and no later one." } } ``` curl: ```bash curl "https://api.overads.io/public/v1/metrics/summary?from=2026-08-12&to=2026-09-10" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /metrics/daily Scope `analytics:read`. Per-day totals over a window, newest first, split by account, platform and currency. | Query | Type | | Meaning | | --- | --- | --- | --- | | `from` | YYYY-MM-DD | optional | First day. | | `to` | YYYY-MM-DD | optional | Last day, inclusive. | | `accountId` | uuid | optional | One ad account. | | `limit` | integer | optional | Default 500, max 2,000. | Response: `{ data: { date, accountId, platform, currency, impressions, clicks, spendMinor, conversions, revenueMinor }[], provenance }`. curl: ```bash curl "https://api.overads.io/public/v1/metrics/daily?from=2026-09-01&to=2026-09-10&limit=31" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /crawl/sites Scope `analytics:read`. Sites overads crawls for this workspace, each with its own crawl state. Response: `{ data: Site[], provenance }` with `id`, `origin`, `host`, `label`, `robotsState`, `robotsFetchedAt`, `robotsCrawlDelaySeconds`, `trackedPages`, `crawl`. curl: ```bash curl https://api.overads.io/public/v1/crawl/sites -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /crawl/pages Scope `analytics:read`. Tracked URLs and the outcome of the last visit to each, verbatim from the crawler. `state` is one of twelve values. Only `fetched_empty`, `fetched_baseline`, `fetched_unchanged`, `fetched_drifted` and `fetched_changed` say anything about the page; the rest say we did not read it and why. | Query | Type | | Meaning | | --- | --- | --- | --- | | `siteId` | uuid | optional | One site. | | `limit` | integer | optional | Default 200, max 1,000. | Response: `{ data: Page[], provenance }` with `id`, `siteId`, `url`, `path`, `source`, `state`, `stateDetail`, `lastHttpStatus`, `title`, `pricingConfidence`, `pricingSignals`, `lastFetchedAt`, `lastChangedAt`. curl: ```bash curl "https://api.overads.io/public/v1/crawl/pages?limit=50" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /crawl/pages/:pageId/timeline Scope `analytics:read`. Every visit to one page, newest first, including the visits that returned nothing. | Query | Type | | Meaning | | --- | --- | --- | --- | | `limit` | integer | optional | Default 200, max 500. | Response: `{ data: Snapshot[], provenance }` with `id`, `pageId`, `state`, `stateDetail`, `httpStatus`, `finalUrl`, `contentType`, `byteLength`, `contentHash`, `title`, `h1`, `capturedAt`. curl: ```bash curl https://api.overads.io/public/v1/crawl/pages/0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e/timeline -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /crawl/changes Scope `analytics:read`. Detected page changes: the before string, the after string, and the detector that fired. No direction is ever inferred. An empty list is qualified. Read `provenance.emptyMeans` before concluding that nothing changed. | Query | Type | | Meaning | | --- | --- | --- | --- | | `siteId` | uuid | optional | One site. | | `pageId` | uuid | optional | One page. | | `limit` | integer | optional | Default 100, max 500. | Response: `{ data: Change[], provenance }` with `kinds`, `similarity`, `titleBefore`, `titleAfter`, `h1Before`, `h1After`, `ctasAdded`, `ctasRemoved`, `priceComparison`, `priceDetail`, `detectedAt`. curl: ```bash curl "https://api.overads.io/public/v1/crawl/changes?limit=20" -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### GET /instagram/profiles Scope `analytics:read`. Tracked Instagram handles. Gated: answers 404 unless the deployment enables platform-derived sources. Response: `{ data: Profile[], provenance }` when enabled; `404` otherwise. `GET /sources` says which applies. curl: ```bash curl https://api.overads.io/public/v1/instagram/profiles -H "Authorization: Bearer sk_live_YOUR_KEY" ``` ### Engagement on published posts Per-post engagement and per-account insights are read through the MCP tools `post_stats` and `profile_stats`, not through a REST route yet. Both return `null` readings with an `unavailableReason` on deployments where platform-derived reads are off, which is the default today. ## Workspaces Source: https://overads.io/docs/api/workspaces Create client workspaces, mint keys for them, and issue hosted connect links, for agencies and B2B. Base URL `https://api.overads.io/public/v1`. Every route needs a key with the scope shown, sent as `Authorization: Bearer sk_live_YOUR_KEY`. The machine-readable contract is at [https://api.overads.io/public/v1/openapi.json](https://api.overads.io/public/v1/openapi.json), no key needed. Scope for this family: `workspaces:manage`. These routes always act on the key's own workspace (the agency). They are the one place the `x-overads-workspace` header is refused: sending it answers `400` with `WORKSPACE_HEADER_NOT_ALLOWED`. Every other public route and every MCP tool honours the header; see [Managed workspaces](https://overads.io/docs/guides/managed-workspaces). #### GET /workspaces Scope `workspaces:manage`. The workspaces this key's workspace manages. Response: `{ data: Workspace[] }` with `id`, `name`, `slug`, `managedBy` (the agency workspace id) and `createdAt`. curl: ```bash curl https://api.overads.io/public/v1/workspaces -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### POST /workspaces Scope `workspaces:manage`. Create a client workspace managed by this key's workspace. A managed workspace is a real workspace: same composer, same connections, same limits. It is owned by the agency workspace's owner, inherits the agency's plan and is covered by the same subscription, appears in the app switcher, and is listed under Settings > Client Accounts. | Body | Type | | Meaning | | --- | --- | --- | --- | | `name` | string | required | 1 to 80 characters. | | `slug` | string | optional | URL-safe handle, 2 to 40 lower-case letters, digits and hyphens. Derived from the name when omitted. A taken slug is refused with `VALIDATION_FAILED` (`409`). | Response: `201` with `{ data: Workspace, warnings: [] }`. 200: ```json { "data": { "id": "c0ff…", "name": "Acme Coffee", "slug": "acme-coffee", "managedBy": "a9e1…", "createdAt": "2026-09-11T10:02:00.000Z" }, "warnings": [] } ``` curl: ```bash curl -X POST https://api.overads.io/public/v1/workspaces -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Acme Coffee" }' ``` #### GET /workspaces/:id Scope `workspaces:manage`. One managed workspace. `WORKSPACE_NOT_FOUND` for any workspace this key's workspace does not manage. Response: `{ data: Workspace }`. curl: ```bash curl https://api.overads.io/public/v1/workspaces/c0ff3e00-1a2b-4c3d-8e4f-5a6b7c8d9e0f -H "Authorization: Bearer sk_live_YOUR_KEY" ``` #### DELETE /workspaces/:id Scope `workspaces:manage`. Detach a managed workspace. Nothing is deleted. The workspace, its posts and its connections stay with their owner, now unmanaged. Every hosted connect link for it is revoked. Keys minted for it keep working for that workspace. Response: `204`, no body. curl: ```bash curl -X DELETE https://api.overads.io/public/v1/workspaces/c0ff3e00-1a2b-4c3d-8e4f-5a6b7c8d9e0f -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` #### POST /workspaces/:id/api-keys Scope `workspaces:manage`. Mint an API key that belongs to the managed workspace. The secret is returned once, here. The key acts on the client workspace directly, with no header. It can never hold `workspaces:manage` (a client cannot mint clients): the scope is stripped, and a request whose only scope is that one is refused with `VALIDATION_FAILED`. | Body | Type | | Meaning | | --- | --- | --- | --- | | `name` | string | required | 1 to 80 characters. | | `scopes` | string[] | optional | Any scopes except `workspaces:manage`. Omitted means the calling key's scopes minus `workspaces:manage`. | | `expiresAt` | ISO 8601 or null | optional | When the key stops working. Must be in the future. | Response: `201` with `{ data: { id, name, prefix, scopes, expiresAt, createdAt, lastUsedAt, secret }, warnings: [] }`. Store `secret`; it is not shown again. curl: ```bash curl -X POST https://api.overads.io/public/v1/workspaces/c0ff3e00-1a2b-4c3d-8e4f-5a6b7c8d9e0f/api-keys -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Acme integration", "scopes": ["connections:read", "posts:read", "posts:write", "media:write"] }' ``` #### POST /workspaces/:id/connect-url Scope `workspaces:manage`. A time-limited hosted page where the client connects channels without an overads login. The link is returned once. The page shows the brand name and logo you pass (or the agency workspace's name), the channel buttons, and the accounts already connected. Nothing else, and no login. See [Managed workspaces](https://overads.io/docs/guides/managed-workspaces). | Body | Type | | Meaning | | --- | --- | --- | --- | | `expiresInDays` | integer | optional | 1 to 90. Default 7. | | `rotate` | boolean | optional | Revoke every earlier link for this workspace before minting this one. | | `brand` | object | optional | `{ name?, logoUrl? }`. `name` up to 80 characters, defaults to the agency workspace name; `logoUrl` an https image URL shown above the channel tiles. | Response: `201` with `{ data: { url, expiresAt, rotated }, warnings: [] }`. `url` is `https://overads.io/connect/ovc_...`. 200: ```json { "data": { "url": "https://overads.io/connect/ovc_Qm9…", "expiresAt": "2026-09-18T10:05:00.000Z", "rotated": false }, "warnings": [] } ``` curl: ```bash curl -X POST https://api.overads.io/public/v1/workspaces/c0ff3e00-1a2b-4c3d-8e4f-5a6b7c8d9e0f/connect-url -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "expiresInDays": 14, "brand": { "name": "Northwind Social", "logoUrl": "https://cdn.northwind.example/logo.png" } }' ``` ### The hosted connect page `GET /public/connect/:token` (no key) returns what the page at `/connect/:token` renders: `{ data: { workspace: { id, name }, brand: { name, logoUrl }, expiresAt, channels: { platform, configured }[], connections: { id, platform, accountName, displayName, avatarUrl, status }[] } }`. `POST /public/connect/:token/session` (no key) mints a 15-minute session for one channel-connect attempt: `{ data: { session, expiresAt } }`. A link that never existed or was rotated away answers `404` with `CONNECT_TOKEN_INVALID`; an expired one answers `410` with `CONNECT_TOKEN_EXPIRED`; a bad session answers `401` with `CONNECT_SESSION_INVALID`. These two routes exist for the hosted page; an integration only needs the connect-url route above. # MCP The server, client setup, and every tool. ## MCP server Source: https://overads.io/docs/mcp Connect ChatGPT, Claude or Claude Code by signing in to overads, or Cursor, VS Code, Codex, n8n, Zapier and other MCP clients with an API key. The overads MCP server is the same publishing, workflow and analytics surface as the REST API, spoken as tools. Server URL `https://api.overads.io/public/v1/mcp`, transport Streamable HTTP. There are two ways in: sign in to overads from ChatGPT, Claude or Claude Code and approve the connection, or send an API key from any other client. Either way, the assistant sees only the tools its permissions allow. Setting up one assistant? [overads.io/agents](https://overads.io/agents) has two-minute pages for Claude, ChatGPT, Cursor, Codex, Windsurf, VS Code and any other client, with a section written for the agent itself. | | | | --- | --- | | Endpoint | `https://api.overads.io/public/v1/mcp` | | Transport | Streamable HTTP (`POST` for JSON-RPC, `GET` for the standalone stream, `DELETE` to end the session) | | Auth | Sign in with OAuth 2.1 (ChatGPT, Claude, Claude Code), or `Authorization: Bearer sk_live_YOUR_KEY` for every other client | | Plan | The workspace must be on the Pro or Scale plan. A paused workspace, one whose trial or subscription has ended, cannot connect, by signing in or with a key | | Session | Minted on `initialize`, returned in `Mcp-Session-Id`, idle timeout 30 minutes | | Rate limit | Shares the workspace's REST budget, see [Rate limits](https://overads.io/docs/rate-limits) | | Managed workspaces | `x-overads-workspace: ` on the request is honoured by every tool, see [Managed workspaces](https://overads.io/docs/guides/managed-workspaces) | | Setup document | [https://api.overads.io/public/v1/mcp/setup](https://api.overads.io/public/v1/mcp/setup), the setup steps as JSON, no key needed | ### Connect by signing in ChatGPT, Claude and Claude Code connect without a key. You add the server URL in the app, sign in to overads in the window that opens, pick one workspace, choose what the app may do, and approve. Nobody copies or pastes a secret. #### ChatGPT ```text Settings > Plugins > Add > Add custom MCP server Name: overads MCP server URL: https://api.overads.io/public/v1/mcp Authentication: OAuth Save, then sign in to overads, pick a workspace and approve. ``` OpenAI decides which ChatGPT plans can add a custom MCP server and which of them can run write actions such as creating a post. #### Claude (claude.ai, desktop and mobile) ```text Customize > Connectors > Add > Add custom connector Name: overads Remote MCP server URL: https://api.overads.io/public/v1/mcp Leave OAuth Client ID and OAuth Client Secret empty Add, then Connect. Sign in to overads, pick a workspace and approve. ``` Once added on claude.ai the connector also works in the Claude desktop and mobile apps signed in to the same Claude account. On Team and Enterprise plans an Owner adds it first under Organization settings > Connectors, then members Connect it under Customize > Connectors. #### Claude Code ```bash claude mcp add --transport http overads https://api.overads.io/public/v1/mcp ``` Then run /mcp inside Claude Code, pick overads and choose Authenticate. Your browser opens the overads approval page. Because Claude Code runs on your computer, the page warns that it sends you back to a local program; approve only if you just started the sign-in yourself. #### What the approval screen shows - The app asking to connect. ChatGPT and Claude are named because overads checks where the approval returns to; an app overads cannot confirm carries a caution, and so does Claude Code, because it returns to a program on your computer rather than a website. - The overads account you are signed in as, with a Switch account link. - Your workspaces. Only workspaces where you are an owner or admin, on the Pro or Scale plan, can be picked; the others are shown dimmed. A connection works in the one workspace you pick. To use another workspace, connect again. - What the app will be able to do: the permissions it asked for, one checkbox each, ticked to start with. Untick anything it should not do. - Approve and Deny. overads never approves on its own and never remembers an approval: every new connection shows this screen. | Permission | On the approval screen | Scope | | --- | --- | --- | | See connected accounts | Which social accounts are connected, and whether each connection still works. | `connections:read` | | See posts | Your posts and where each one was delivered. | `posts:read` | | Create and publish posts | Create, edit, schedule, cancel and publish posts. | `posts:write` | | Upload media | Add images and videos, including from a link. | `media:write` | | Run workflows | Start your saved workflows and see how each run went. | `workflows:run` | | See ad results | Your ad accounts and their results, plus the landing-page scan. | `analytics:read` | A signed-in connection reaches the MCP server only, never the REST API, and it cannot manage webhooks or workspaces. The workspace's approval rule still applies: when posts need approval, a post the app creates waits for a member to approve it in overads. #### Disconnect an app - In overads, open Settings > API keys and MCP. Connected apps lists every app that signed in to the workspace, who connected it, what it may do and when it was last used. Owners and admins can press Disconnect; the app stops working on its next call. - Changing or resetting your overads password, or signing out everywhere, disconnects every app you connected. - Leaving a workspace, or being removed from it, ends the connections you made there. - A connection that goes unused for 90 days ends on its own. - If the workspace is paused because its trial or subscription ended, calls are refused until it is on Pro or Scale again. Removing overads inside ChatGPT or Claude removes it from that app. To be sure the connection is ended on the overads side too, disconnect it in Connected apps. ### Connect with an API key Every other client sends an API key in the `Authorization` header. This is also the way to connect from automation tools and from your own code. Create the key in Settings > API (the API keys and MCP page). For an assistant that only reads, give it `connections:read`, `posts:read` and `analytics:read`; add `posts:write` and `media:write` when it should schedule, and `workflows:run` when it should run automations. Revoke a key on the same page. #### Claude Desktop (config file) claude_desktop_config.json: ```json { "mcpServers": { "overads": { "command": "npx", "args": [ "-y", "mcp-remote", "https://api.overads.io/public/v1/mcp", "--header", "Authorization:Bearer sk_live_YOUR_KEY" ] } } } ``` Claude Desktop reaches remote servers through mcp-remote. Settings > Developer > Edit Config, paste, then restart Claude Desktop. To skip the key, add overads as a Claude connector instead (see Connect by signing in). #### Claude Code (API key) ```bash claude mcp add --transport http overads https://api.overads.io/public/v1/mcp --header "Authorization: Bearer sk_live_YOUR_KEY" ``` Run once in a terminal. Check with `claude mcp list`; the tools appear in every session. #### Cursor .cursor/mcp.json: ```json { "mcpServers": { "overads": { "url": "https://api.overads.io/public/v1/mcp", "headers": { "Authorization": "Bearer sk_live_YOUR_KEY" } } } } ``` Project-level in .cursor/mcp.json, or global in ~/.cursor/mcp.json. Cursor > Settings > MCP shows the connection. #### VS Code .vscode/mcp.json: ```json { "servers": { "overads": { "type": "http", "url": "https://api.overads.io/public/v1/mcp", "headers": { "Authorization": "Bearer sk_live_YOUR_KEY" } } } } ``` For GitHub Copilot agent mode. Open the file and press Start above the server entry. #### Codex ~/.codex/config.toml: ```toml [mcp_servers.overads] url = "https://api.overads.io/public/v1/mcp" http_headers = { Authorization = "Bearer sk_live_YOUR_KEY" } ``` OpenAI Codex CLI reads MCP servers from config.toml. Restart Codex after editing. #### Windsurf ~/.codeium/windsurf/mcp_config.json: ```json { "mcpServers": { "overads": { "serverUrl": "https://api.overads.io/public/v1/mcp", "headers": { "Authorization": "Bearer sk_live_YOUR_KEY" } } } } ``` Windsurf > Settings > Cascade > MCP servers, or edit the file directly and refresh. #### n8n ```text Node: MCP Client Tool Endpoint: https://api.overads.io/public/v1/mcp Server transport: HTTP Streamable Authentication: Bearer auth Token: sk_live_YOUR_KEY ``` Attach the node to an AI Agent; every tool the key is scoped for is offered to the agent. #### Zapier ```text Zapier Agents > Tools > Add MCP server Server URL: https://api.overads.io/public/v1/mcp Authentication: Custom header Header: Authorization Value: Bearer sk_live_YOUR_KEY ``` Available on Zapier plans that allow custom MCP servers. The agent sees the same tool list as any other client. #### Other MCP clients ```bash curl -sS https://api.overads.io/public/v1/mcp \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' ``` A client that speaks Streamable HTTP and can send a custom Authorization header can connect this way. POST initialize, keep the Mcp-Session-Id response header, send it on every later request, DELETE to end the session. ### Ask for a recap or a draft Four tools answer a whole question in one call, so the assistant does not have to chain a dozen reads. Ask in plain words; the assistant picks the tool. | You ask | Tool | What comes back | | --- | --- | --- | | "How did last week go?" | `get_weekly_recap` | Ad results and published posts for the last 7 full days, each set against the 7 days before. | | "What worked this month?" | `what_worked` | The posts and ads that beat this workspace's own usual results, each with the figure it was compared with. | | "Draft a LinkedIn post about our spring sale." | `draft_on_brand_post` | Copy in the brand's voice, one version per channel within its length limit, saved as a draft. | | "Is anything waiting for me to approve?" | `list_waiting_approvals` | Posts pending approval and actions the assistant staged, each with a link to approve it in the app. | - A draft is only a draft. `draft_on_brand_post` never schedules and never publishes; a member schedules it in the app, and in a workspace that requires approval it then waits for a yes as well. - Drafts are text only and go to connections on Instagram, Facebook, LinkedIn, X, Bluesky, Mastodon and Pinterest. A Pinterest pin still needs an image before it can be scheduled. - Ad-by-ad sections (top and bottom ads, the ads that beat their account) appear only where the deployment enables platform-derived data. Otherwise the recap carries account-level totals and names the missing sections in `notIncluded`; that is not a reading of zero. ### What the server tells the model The `initialize` response carries instructions every client should surface. Three rules matter for anyone building on top: - Approvals. When the workspace requires approval for scheduled posts, `create_post` and `update_post` still succeed, but the post lands with `approvalStatus: "pending"` and the result carries a warning with code `APPROVAL_REQUIRED`. The post will not publish until a member approves it in the app. The model should say so, not retry, and not call publish now to get around it (it is refused with the same code). - Idempotency. Every write tool (`create_post`, `update_post`, `delete_post`, `get_upload_url`, `complete_media`, `delete_media`, `run_workflow`, `draft_on_brand_post`) accepts an optional `idempotencyKey`. A fresh one per intended write, reused on retry: same key and arguments replays the first result, different arguments is `IDEMPOTENCY_KEY_CONFLICT`, still running is `IDEMPOTENCY_IN_FLIGHT`. Keys live 24 hours. - Null is not zero. A counter that is null was not measured. Analytics results carry `data` and `provenance`; the model must read `provenance` before stating a conclusion from `data`. ### Sessions and errors - A session belongs to the workspace whose key or sign-in opened it. A valid credential for another workspace with a session id it did not mint gets `404`, the same answer as an unknown session. - An expired session (30 minutes idle) answers `404` with a message saying to send `initialize` again. - A tool that is not in the key's or connection's scopes, is gated off on this deployment, or does not exist all answer the same way: `Unknown tool`. This is deliberate; the error does not enumerate what the deployment holds. - Tool errors come back as MCP tool results with `isError: true` and the same `code` strings as the [REST catalogue](https://overads.io/docs/errors). ### Legacy single-POST clients > A POST with no `Mcp-Session-Id` header and no `Accept: text/event-stream` is treated as a first-generation single-request JSON-RPC call and answered with plain JSON: `initialize`, `ping`, `tools/list` and `tools/call` only. This path is kept for one release. New integrations should use the Streamable HTTP transport. Legacy single POST: ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_connections","arguments":{}}} ``` ## MCP tools Source: https://overads.io/docs/mcp/tools Every tool the server offers, its parameters, the scope that unlocks it, and whether it writes. Tool names are a contract, like REST paths: they never change meaning within v1. The publishing tools carry the names agents already know, so a prompt written for another publishing server works here unchanged. Every tool answers the same payload the REST route would, in both the text block and `structuredContent`. `tools/list` returns only the tools the key is scoped for. Write tools take an optional `idempotencyKey`; see [Idempotency](https://overads.io/docs/idempotency). With every scope, a deployment with default settings offers 27 of the tools below. `list_instagram_profiles` is the 28th and is listed only where the deployment enables platform-derived data. ### Publishing #### list_connections List connected social accounts. Scope `connections:read`, read-only. Every connected account with `tokenStatus`, `lastError` and `lastSuccessfulPost`. Call it first to get the `connectionIds` that `create_post` needs. An `expired` connection must be reconnected in the app. #### list_posts List posts. Scope `posts:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `status` | enum | `draft`, `scheduled`, `publishing`, `published`, `partial_published`, `failed`, `cancelled` | | `platform` | string | One platform id | | `from`, `to` | ISO 8601 | Window on `scheduledAt` | | `page` | integer | Default 1 | | `limit` | integer | Default 50, max 200 | Newest first, paginated; read `total` before saying how many there are. #### create_post Create a post. Scope `posts:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `content` | string | Required, 1 to 10,000 characters | | `connectionIds` | string[] | Required, 1 to 20 | | `scheduledAt` | ISO 8601 | Omit for a draft | | `mediaUrls` | string[] | Up to 10 public https URLs | | `mediaIds` | string[] | Up to 10, from `complete_media` | | `platformSettings` | object | Per-platform settings keyed by platform | | `variants` | object | `{ [platform]: { content } }` | | `requireApproval` | boolean | Queue for approval anyway | | `idempotencyKey` | string | Up to 255 characters | Same rules as `POST /posts`: Instagram needs media, unknown settings are refused with `PLATFORM_SETTING_UNKNOWN`, and the approval rule applies with an `APPROVAL_REQUIRED` warning. #### get_post Get one post. Scope `posts:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `postId` | uuid | Required | One post with `targets`. A failed target carries the platform error verbatim. #### update_post Edit a post. Scope `posts:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `postId` | uuid | Required | | … | | Every `create_post` field, all optional | | `idempotencyKey` | string | | Only the fields passed change. `POST_NOT_EDITABLE` once the post is publishing, published or cancelled. #### delete_post Cancel a post. Scope `posts:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `postId` | uuid | Required | | `idempotencyKey` | string | | Cancel a draft or scheduled post; the row is kept as `cancelled`. Nothing is removed from any platform. #### get_upload_url Get a media upload URL. Scope `media:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `kind` | enum | `image` or `video`, required | | `contentType` | string | Required | | `bytes` | integer | Required, exact length | | `idempotencyKey` | string | | A presigned PUT valid for 15 minutes (15 MB images, 500 MB video). PUT the bytes, then `complete_media`, then pass the id as `mediaIds`. #### complete_media Complete a media upload. Scope `media:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `mediaId` | uuid | Required | | `idempotencyKey` | string | | Confirms the upload landed. `MEDIA_NOT_READY` if the bytes are not there yet. #### delete_media Delete a media asset. Scope `media:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `mediaId` | uuid | Required | | `idempotencyKey` | string | | Deletes the asset and its object. Only delete media no post still needs. #### get_best_times Get best posting times. Scope `posts:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `platform` | string | Optional | Slots from the workspace's own history plus per-platform priors; `fromHistory` says which. #### post_stats Get engagement for a published post. Scope `posts:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `postId` | uuid | Required | Delivery facts per target plus an `engagement` block. `engagement` is null with an `unavailableReason` when there is no reading; today that is the case on every deployment where platform-derived reads are off, and for every platform other than Facebook and Instagram. #### profile_stats Get profile stats for a connected account. Scope `posts:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `connectionId` | uuid | Required | The connection, its delivery record from our own log (`publishedPosts`, `lastSuccessfulPost`, `lastError`), and `insights`, which is null with an `unavailableReason` under the same conditions as `post_stats`. ### Workflows and the assistant #### list_workflows List workflows. Scope `workflows:run`, read-only. The workspace's automations: `id`, `name`, `description`, `enabled`, `autonomy`, `triggers`, `templateKey`, `updatedAt`. A disabled workflow can still be run manually. #### run_workflow Run a workflow. Scope `workflows:run`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `workflowId` | uuid | Required | | `dryRun` | boolean | Walk data and condition nodes, report what would be staged | | `triggerMeta` | object | Exposed to nodes as `{{trigger.*}}` | | `idempotencyKey` | string | | Queues a manual run and returns `{ runId, workflowId, status, dryRun }`. Refused with `plan_limit_reached` when the daily run cap is used up, and with a budget message when the monthly AI budget is spent. #### get_workflow_run Get a workflow run. Scope `workflows:run`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `runId` | uuid | Required | Status (`queued`, `running`, `succeeded`, `failed`, `skipped`), times, `costMicros` (null until finished), `summary`, `error` and the `steps` in order. A step that staged an action for a human to confirm says so; it has not been performed. #### ask_gloofy Ask the overads assistant. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `question` | string | Required, up to 4,000 characters | One grounded answer from the workspace's assistant, with `toolsUsed` and any `stagedActions`. Costs model budget against the workspace, the same as chatting in the app. Staged actions have not been performed. ### Recaps, drafts and approvals #### get_weekly_recap Get the weekly recap. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `from`, `to` | YYYY-MM-DD | Both or neither; default the last 7 full days (UTC), ending yesterday | One recap of the window set against the window of the same length just before it. `ads` and `posts` are each `{ data, provenance }`. `ads` carries the change in impressions, clicks, results, spend and revenue, cost per result and ROAS, and a line per ad account; money is in minor units, and when the window spans more than one currency it appears per account only. The top and bottom three ads are included only where the deployment enables platform-derived data; otherwise they are null and named in `notIncluded`. `posts` carries deliveries per channel against the prior window, failed deliveries, and the posts that measured above or below the account's usual results; it needs `posts:read` as well, and without it is named in `notIncluded`. A null figure was not measured, not zero. #### what_worked Find what worked. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `from`, `to` | YYYY-MM-DD | Both or neither; default the last 30 days | The posts and ads that beat this workspace's own baseline, each with its figure and the figure it was compared with. A post is listed when its result is at least 1.5 times the median of the same account's earlier posts; an ad when its cost per result (3 results or more) or its ROAS beat its own account's figure for the window by 1.5 times or more, with each account's figures in `byAccount`. The ad list is present only where the deployment enables platform-derived data; otherwise it is null and named in `notIncluded`. `brandMemory` carries the performance facts saved to the brand memory. An empty list under a provenance of collected means nothing cleared the bar, which is not the same as no data; `verdicts.unmeasured` counts posts that could not be judged. #### draft_on_brand_post Draft an on-brand post. Scope `posts:write`, writes. | Parameter | Type | Notes | | --- | --- | --- | | `topic` | string | Required, 1 to 2,000 characters: a topic, a brief or notes | | `connectionIds` | string[] | Required, 1 to 20, from `list_connections` | | `idempotencyKey` | string | Up to 255 characters | Writes copy in the brand's voice from the brand memory, one version per targeted channel within that channel's character limit, and saves it as a draft. It never schedules and never publishes; in a workspace that requires approval the result carries an `APPROVAL_REQUIRED` warning, because the draft will wait for approval once it is scheduled. Text only. Connections must be on a live channel (Instagram, Facebook, LinkedIn, X, Bluesky, Mastodon and Pinterest), otherwise `CHANNEL_NOT_LIVE`; an expired connection answers `CONNECTION_EXPIRED`. Copy still over a limit after one rewrite is refused with `TOO_LONG` and nothing is saved. Uses AI budget against the workspace. Returns the draft id, each channel's text with its length and limit, and a link to the draft in the app. #### list_waiting_approvals List items waiting for approval. Scope `posts:read`, read-only. Posts pending approval (soonest due first) and actions the assistant staged for confirmation (newest first), each with a link into the app where it is approved. Nothing listed has been published or performed. `counts` are the true totals; `items` is capped at 20 and `truncated` says when more are waiting. Staged actions also need `analytics:read`; without it they are named in `notIncluded` and `counts.proposals` is null. ### Analytics #### list_ad_accounts List connected ad accounts. Scope `analytics:read`, read-only. The ad accounts this workspace connected, each with its metrics-sync state. #### get_metrics_summary Get ad metrics totals. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `from`, `to` | YYYY-MM-DD | Both or neither; default last 30 days | | `accountId` | uuid | Optional | Totals over a window. Money in minor units as a string; null counters are missing readings. #### list_daily_metrics List ad metrics by day. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `from`, `to` | YYYY-MM-DD | | | `accountId` | uuid | | | `limit` | integer | Default 500, max 2,000 | Per-day totals, newest first, split by account, platform and currency. #### list_crawled_sites List crawled sites. Scope `analytics:read`, read-only. Sites overads crawls for this workspace, each with a `crawl` block. #### list_crawled_pages List crawled pages. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `siteId` | uuid | | | `limit` | integer | Default 200, max 1,000 | Tracked URLs and how the last visit ended. Twelve `state` values; only the `fetched_*` ones say anything about the page. #### get_page_timeline Get one page visit history. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `pageId` | uuid | Required | | `limit` | integer | Default 200, max 500 | Every visit, newest first, including the ones that returned nothing. #### list_page_changes List detected page changes. Scope `analytics:read`, read-only. | Parameter | Type | Notes | | --- | --- | --- | | `siteId` | uuid | | | `pageId` | uuid | | | `limit` | integer | Default 100, max 500 | Visits where a detector fired, with before and after strings. Check `provenance.emptyMeans` before reading an empty list as no change. #### list_instagram_profiles List tracked Instagram profiles. Scope `analytics:read`, read-only. Gated: Absent from `tools/list` unless the deployment enables platform-derived sources. Tracked handles and the last reading of each. Counts are null when Instagram withheld them. # Webhooks Events, signatures, retries. ## Webhooks Source: https://overads.io/docs/webhooks Ten events, one envelope, an HMAC signature to verify, and a retry policy that pauses a dead endpoint. Register an https endpoint, pick events, store the secret. overads POSTs a signed JSON envelope for every matching event. Endpoints are managed over the [Webhooks API](https://overads.io/docs/api/webhooks) or in the app under Settings > Webhooks. ### Events Missing readings in a payload are null, never 0 or an empty string. `permalink` is null when the platform returned none. | Event | When | `data` | | --- | --- | --- | | `post.scheduled` | A post was scheduled (or rescheduled) for a future time | `{ postId, scheduledAt, platforms }` | | `post.published` | One account shipped a post; one event per target | `{ postId, targetId, platform, connectionId, postedId, permalink, publishedAt }` | | `post.failed` | One account failed to publish a post; one event per target | `{ postId, targetId, platform, error: { code, message }, failedAt }` | | `post.approval_required` | A scheduled post is waiting for a reviewer | `{ postId }` | | `post.approved` | A reviewer approved a pending post | `{ postId, approvedBy }` | | `connection.error` | A connected social account stopped working and needs a reconnect | `{ connectionId, platform, reason }` | | `workflow.run.completed` | A workflow run finished | `{ workflowId, runId, summary }` | | `workflow.run.failed` | A workflow run failed | `{ workflowId, runId, error }` | | `proposal.staged` | The assistant staged an action that needs a confirm | `{ actionId, toolName, title }` | | `proposal.confirmed` | A member confirmed an action the assistant staged | `{ actionId, toolName, title }` | `ping` is what `POST /webhooks/:id/test` sends, with `data: { endpointId, message }`. It cannot be subscribed to. ### The envelope Every delivery is one JSON object. `version` is `"1"` and stays `"1"` for as long as these field names hold. ```json { "version": "1", "event": "post.published", "timestamp": "2026-09-14T16:00:04.212Z", "data": { "postId": "b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d", "targetId": "0c9e…", "platform": "linkedin", "connectionId": "3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11", "postedId": "urn:li:share:7241…", "permalink": "https://www.linkedin.com/feed/update/urn:li:share:7241…", "publishedAt": "2026-09-14T16:00:04.000Z" } } ``` ### Headers | Header | Value | | --- | --- | | `X-Overads-Signature` | Hex HMAC-SHA256 of the raw request body, keyed with the endpoint secret | | `X-Overads-Event` | The event name, same as `event` in the body | | `X-Overads-Delivery` | The delivery id. The same id on a retry, so use it to de-duplicate | | `Content-Type` | `application/json` | | `User-Agent` | `overads-webhooks/1` | ### Verify the signature Compute HMAC-SHA256 over the raw bytes you received, with the `whsec_...` secret as the key, and compare the hex digest to the header with a constant-time comparison. Parse the JSON only after the comparison passes. Do not re-serialise the body before hashing; whitespace differences change the digest. Node (Express): ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const app = express(); app.post("/overads", express.raw({ type: "application/json" }), (req, res) => { const expected = Buffer.from( createHmac("sha256", process.env.OVERADS_WEBHOOK_SECRET).update(req.body).digest("hex"), "hex", ); let given; try { given = Buffer.from(String(req.get("X-Overads-Signature") ?? "").trim().toLowerCase(), "hex"); } catch { return res.status(400).end(); } if (given.length !== expected.length || given.length === 0 || !timingSafeEqual(given, expected)) { return res.status(401).end(); } const envelope = JSON.parse(req.body.toString("utf8")); // envelope.event, envelope.data, req.get("X-Overads-Delivery") res.status(204).end(); }); ``` Python (Flask): ```python import hashlib, hmac, os from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["OVERADS_WEBHOOK_SECRET"].encode() @app.post("/overads") def overads_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() given = (request.headers.get("X-Overads-Signature") or "").strip().lower() if not given or not hmac.compare_digest(given, expected): abort(401) envelope = request.get_json(force=True) # envelope["event"], envelope["data"], request.headers["X-Overads-Delivery"] return "", 204 ``` Answer with any `2xx` within 10 seconds. Anything else, including a timeout, counts as a failed attempt. Do the real work after you have responded, or on a queue. ### Retries and auto-pause - Three attempts per delivery: immediately, then 10 seconds after the first failure, then 1 minute after the second. Every attempt carries the same `X-Overads-Delivery` id. - Each failed attempt adds one to the endpoint's `consecutiveFailures`; a successful send resets it to 0. - At 20 consecutive failures the endpoint pauses itself (`active: false`), stops receiving events, and the workspace is notified. Resume it with `PATCH /webhooks/:id { "active": true }`, which resets the counter. - A paused endpoint's pending retries are recorded as failed with `Endpoint is paused`; nothing is queued for it. - `GET /webhooks/:id/deliveries` shows the last 50 with `attempt`, `statusCode`, `error` and `nextAttemptAt`; `POST .../redeliver` sends any of them again as a new delivery. ### URL rules - `https://` only. Plain http is refused. - No `localhost` or `*.localhost`, no private, loopback or link-local address, no credentials in the URL. The rule is applied at registration, on every URL change, and again right before each send, with the resolved address pinned so DNS cannot change it between the check and the connection. - Redirects are not followed. Answer at the URL you registered. - A refused URL answers `WEBHOOK_URL_REFUSED` with the reason. > Deliveries are best-effort by design. If the queue cannot be reached when an event fires, the thing the event describes still happened; the notification is what was lost. Poll `GET /posts` for anything you cannot afford to miss. # Platforms Limits, settings and review status per channel. ## Instagram Source: https://overads.io/docs/platforms/instagram Instagram over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Live through both doors since Meta's App Review approved them on 2026-10-13. Through Facebook (Login for Business), Meta granted `instagram_basic` and `instagram_content_publish`; through Instagram Login, `instagram_business_basic` and `instagram_business_content_publish`. Delivery still reads the grant on each connection and refuses a post before its first Graph call when that connection was made without the publish permission, so an account connected before the approval may need to reconnect. ### Connecting Two ways in, both in the app. Sign in on Instagram (Meta's Instagram Login): the user logs in at instagram.com with Instagram credentials, needs a Business or Creator account, and no Facebook account, Business portfolio or Page is involved. The permissions asked for are `instagram_business_basic` and `instagram_business_content_publish`; the grant Meta returns is stored on the connection and checked at delivery. This door publishes to Instagram and nothing else. Or sign in through Facebook (Login for Business), which asks for a Business portfolio and a Page, and is the only path that also reaches Pages, ads and Page insights. `GET /connections` reports both as `platform: "instagram"` and does not say which door a connection came through. Once connected the account appears in `GET /connections` with `platform: "instagram"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 2,200 | | API id | `instagram` | - At least one image or video is required; a text-only post is refused with `MEDIA_REQUIRED`. - Up to 10 images, or 1 video up to 1 GB and 15 minutes. - `videoType` picks Reels or Stories for a video. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.instagram`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `videoType` | `REELS` or `STORIES` | How a video is published | | `shareToFeed` | boolean | Also show a Reel in the feed | | `coverUrl` | https URL | Cover image for a Reel | | `firstComment` | string, up to 2,000 | Posted as the first comment right after the post | platformSettings: ```json { "instagram": { "videoType": "REELS", "shareToFeed": true, "firstComment": "Link in bio." } } ``` ## Facebook Source: https://overads.io/docs/platforms/facebook Facebook over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Live since Meta's App Review approved `pages_manage_posts` on 2026-10-13. Posts go to a Page the person manages; delivery reads the grant on each connection, so a Page connected before the approval may need to reconnect. ### Connecting OAuth through the Meta sign-in, in the app. Posts go to a Page the user manages. Once connected the account appears in `GET /connections` with `platform: "facebook"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 63,206 | | API id | `facebook` | - Up to 10 images, or 1 video up to 1 GB and 20 minutes. - Media is optional. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.facebook`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `firstComment` | string, up to 2,000 | Posted as the first comment right after the post | platformSettings: ```json { "facebook": { "firstComment": "Full write-up: https://overads.io/blog" } } ``` ## LinkedIn Source: https://overads.io/docs/platforms/linkedin LinkedIn over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Publishes today, as the signed-in member or as a Company Page the member administers. ### Connecting OAuth through LinkedIn, in the app, which connects the member profile. Company Pages are a second step: the app reads live from LinkedIn which Pages the member may post as and adds the ones chosen as separate connections, each with its own `id` for `connectionIds`, named after the Page. A Page posts with the member's token, so adding one needs no second consent, and a role revoked on LinkedIn drops the Page from the picker without anyone reconnecting. Company Page posting is a deployment switch on the overads side; where it is off the picker answers `503`. Once connected the account appears in `GET /connections` with `platform: "linkedin"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 3,000 | | API id | `linkedin` | - Up to 9 images, JPEG or PNG. - Video, GIF and WebP are not supported on LinkedIn yet: a post that carries one fails when it is sent. - Media is optional. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.linkedin`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `visibility` | `PUBLIC` or `CONNECTIONS` | Accepted but not applied yet: every LinkedIn post goes out public. | platformSettings: ```json { "linkedin": { "visibility": "PUBLIC" } } ``` ## X Source: https://overads.io/docs/platforms/x X over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Publishes today. The API id for X is `twitter`: use it in `platformSettings`, `variants`, `platform` filters and in the `platforms` array on a post. ### Connecting OAuth through X, in the app. Once connected the account appears in `GET /connections` with `platform: "twitter"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 280, counted the way X counts: Chinese, Japanese and Korean characters and every emoji count 2, and every link counts 23 whatever its length. | | API id | `twitter` | - Up to 4 images, or 1 video up to 512 MB and 140 seconds. - Media is optional. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.twitter`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `replyTo` | string, up to 64 | Id of the post to reply to | | `quoteTweet` | string, up to 64 | Id of the post to quote | platformSettings: ```json { "twitter": { "quoteTweet": "1834567890123456789" } } ``` ## Bluesky Source: https://overads.io/docs/platforms/bluesky Bluesky over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Publishes today. No app review is involved. ### Connecting Two ways, both in the app. An app password: make one in Bluesky and paste it with the handle; it is stored encrypted and can be revoked from Bluesky at any time. Or Sign in with Bluesky: enter the handle, then log in on the account's own PDS and approve overads there (AT Protocol OAuth; the session is refreshed by overads). Sign in with Bluesky renders only on deployments that hold a signing key, and the deployed product does not today, so the app password is the way in. `GET /connections` does not say which one an account uses. Once connected the account appears in `GET /connections` with `platform: "bluesky"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 300 graphemes. Bluesky counts user-perceived characters, so an emoji or a combined character counts once. | | API id | `bluesky` | - Up to 4 images, or 1 mp4 video up to 100 MB and 3 minutes. - A post embeds images or a video, never both; a list with both is refused with `MEDIA_UNSUPPORTED` in the flat channel-limit body. - Video goes through Bluesky's video service and is processed before the post is created, so a video post can take a minute to land. - Links, mentions and hashtags in the text are turned into rich-text facets automatically. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Bluesky has no per-platform settings. A `platformSettings.bluesky` object is refused with `PLATFORM_SETTING_UNKNOWN`. ## Mastodon Source: https://overads.io/docs/platforms/mastodon Mastodon over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Publishes today, on whichever instance the account lives. No app review is involved. ### Connecting Name the instance, then OAuth on that instance, in the app. Once connected the account appears in `GET /connections` with `platform: "mastodon"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 500 by default. The instance's own limit is read at connect time and wins over the default, so an instance with a 5,000-character limit gets 5,000. The content warning (`spoilerText`) counts toward the same limit. | | API id | `mastodon` | - Up to 4 images, or 1 video up to 99 MB (Mastodon's default; instances may set their own). - Media is optional. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.mastodon`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `visibility` | `public`, `unlisted`, `private` or `direct` | Mastodon's four visibility levels | | `spoilerText` | string, up to 200 | Content warning shown above the post | | `language` | BCP 47 tag such as `en` or `pt-br` | Language the post is written in | platformSettings: ```json { "mastodon": { "visibility": "unlisted", "language": "en" } } ``` ## Threads Source: https://overads.io/docs/platforms/threads Threads over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Awaiting Meta approval. Connectable, not publishing for customers. Built and connectable, but it does not publish for customers. Meta did not approve `threads_basic` and `threads_content_publish` at App Review on 2026-10-13; until a resubmission clears, only accounts added as Threads Testers on the overads Meta app can connect. ### Connecting OAuth through Threads, in the app. Once connected the account appears in `GET /connections` with `platform: "threads"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 500 | | API id | `threads` | - Up to 20 items in a carousel; images and videos can mix. - Video up to 5 minutes and 1 GB. - Media is optional. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.threads`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `replyControl` | `everyone`, `accounts_you_follow` or `mentioned_only` | Who can reply | platformSettings: ```json { "threads": { "replyControl": "accounts_you_follow" } } ``` > A post to a channel that is not live is accepted and stored, and its target row records the refusal at delivery time. Do not schedule customer-facing posts there until this page says live. ## YouTube Source: https://overads.io/docs/platforms/youtube YouTube over the overads API: status, connection, limits, media rules and platformSettings keys. Status: In review. Connect and test with a test account; delivery for customers switches on when the review clears. Built, connectable, and waiting on Google's OAuth verification. Until then only listed test users can connect, their tokens expire after seven days, and it does not publish for customers. ### Connecting OAuth through Google, in the app. Once connected the account appears in `GET /connections` with `platform: "youtube"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 5,000 for the description. The title comes from the `title` setting or, when absent, the first line of the content. | | API id | `youtube` | - Exactly one video, required, up to 12 hours (the 500 MB upload cap applies first). A post with no video or with images is refused. - Each upload spends 1,600 of the app's 10,000 daily YouTube API units, about six uploads a day across all customers until Google extends the quota. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.youtube`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `privacy` | `public`, `unlisted` or `private` | Video privacy | | `title` | string, up to 100 | Video title | | `tags` | string[], up to 50, each up to 30, 500 in total | Video tags | | `categoryId` | numeric string | A YouTube category id | | `madeForKids` | boolean | COPPA flag | platformSettings: ```json { "youtube": { "privacy": "unlisted", "title": "Launch walkthrough", "tags": ["overads", "launch"] } } ``` > A post to a channel that is not live is accepted and stored, and its target row records the refusal at delivery time. Do not schedule customer-facing posts there until this page says live. ## TikTok Source: https://overads.io/docs/platforms/tiktok TikTok over the overads API: status, connection, limits, media rules and platformSettings keys. Status: In review. Connect and test with a test account; delivery for customers switches on when the review clears. Built and in TikTok's audit. Until it passes, TikTok accepts private (`SELF_ONLY`) posts only and labels them as from an unaudited app, so it does not publish publicly for customers. The deployment also has to hold TikTok client credentials, and the deployed product does not today, so the channel reports itself as not configured and cannot be connected yet. ### Connecting OAuth through TikTok, in the app. The app reads `creator_info` after connecting and offers only the privacy levels the account may use. Once connected the account appears in `GET /connections` with `platform: "tiktok"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 2,200 for the caption. | | API id | `tiktok` | - One video (up to 10 minutes and 4 GB, the 500 MB upload cap applies first), or a photo carousel of up to 35 images. Never both, and never text alone; a text-only post is refused with `MEDIA_REQUIRED`. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.tiktok`. Every key is optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `privacyLevel` | `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR` or `SELF_ONLY` | Checked against what the account may use | | `disableComment` | boolean | | | `disableDuet` | boolean | | | `disableStitch` | boolean | | | `brandContentToggle` | boolean | Paid partnership disclosure | | `brandOrganicToggle` | boolean | Own-brand promotion disclosure | platformSettings: ```json { "tiktok": { "privacyLevel": "SELF_ONLY", "disableDuet": true } } ``` > A post to a channel that is not live is accepted and stored, and its target row records the refusal at delivery time. Do not schedule customer-facing posts there until this page says live. ## Pinterest Source: https://overads.io/docs/platforms/pinterest Pinterest over the overads API: status, connection, limits, media rules and platformSettings keys. Status: Live. Posts created over the API deliver to this channel today. Publishes today: one image or one video per pin, to a board you pick. ### Connecting OAuth through Pinterest, in the app. The app lists the account's boards for `boardId`. Once connected the account appears in `GET /connections` with `platform: "pinterest"`. Use its `id` in `connectionIds`. ### Limits | | | | --- | --- | | Characters | 800 for the description; 100 for the title. | | API id | `pinterest` | - One image, or one video (mp4, mov or m4v, 4 seconds to 15 minutes, up to 2 GB). Media is required. - A video pin needs a cover image: attach one image beside the video and it is used as the cover. A video pin without one is accepted when scheduled but fails when it is sent. - A video is registered with Pinterest, uploaded and processed before the pin is created, so a video pin can take a few minutes to land. Limits are checked when the post is created or edited, so an over-limit post is refused then with a sentence naming the cap, not at the scheduled minute. These refusals answer `400` with the flat body `{ statusCode, code, message }`, not the `{ error }` envelope: `TOO_LONG`, `TOO_MANY_ITEMS`, `MEDIA_UNSUPPORTED`, `MEDIA_REQUIRED`, `MEDIA_TOO_LARGE` (over the channel's video size cap), `MEDIA_TOO_LONG` (over its duration cap). The full list is under [Channel-limit refusals](https://overads.io/docs/errors). ### Settings Pass these under `platformSettings.pinterest`. `boardId` is required to schedule: a Pinterest post without it is refused with `BOARD_REQUIRED` in the flat channel-limit body (a draft may still be missing it). The other keys are optional. An unknown key is refused with `PLATFORM_SETTING_UNKNOWN` and the dotted path of the key. | Key | Value | Meaning | | --- | --- | --- | | `boardId` | string, up to 64 | The board to pin to, required | | `title` | string, up to 100 | Pin title | | `link` | https URL, up to 2,048 | Destination link | | `altText` | string, up to 500 | Alt text for the image | platformSettings: ```json { "pinterest": { "boardId": "1234567890", "title": "Launch day", "link": "https://overads.io/blog" } } ``` # Guides Recipes for the things people build first. ## Cross-platform posting Source: https://overads.io/docs/guides/cross-platform-posting One request, several channels: shared copy, per-platform variants, per-platform settings, per-account results. A post is one group. `content` is the copy every channel gets; `variants` overrides it per platform; `platformSettings` tunes delivery per platform; `targets` reports the result per account. ### Pick connections, not platforms `connectionIds` is the only way to say where a post goes. The post's `platforms` array is derived from the connections you named. Two LinkedIn pages are two connection ids on one post. ### Fit every channel Each target is checked against its platform's limits at creation: X takes 280 characters, Bluesky 300 graphemes, LinkedIn 3,000. When the shared copy is too long for one channel, give that channel a variant instead of shortening everything. curl: ```bash curl -X POST https://api.overads.io/public/v1/posts -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "content": "We rebuilt the scheduler from the ground up. Per-account delivery logs, approvals, webhooks, and an MCP server so your assistant can post for you. Full notes on the blog.", "connectionIds": ["LINKEDIN_ID", "X_ID", "BLUESKY_ID", "MASTODON_ID"], "variants": { "twitter": { "content": "The scheduler is new: per-account delivery logs, approvals, webhooks, MCP. Notes: overads.io/blog" }, "bluesky": { "content": "The scheduler is new: per-account delivery logs, approvals, webhooks, MCP. overads.io/blog" } }, "platformSettings": { "mastodon": { "visibility": "public", "language": "en" } }, "scheduledAt": "2026-09-14T16:00:00Z" }' ``` Settings for a platform the post does not target are refused with `PLATFORM_SETTING_UNKNOWN`, because a script that sends LinkedIn settings to an X-only post has the wrong connection id and should hear so. ### Read the result per account After delivery, `GET /posts/:id` carries one target per connection. The group `status` is `published` when every target landed, `partial_published` when some did, `failed` when none did. A failed target has the platform's error text in `error`; the others have `postedId` and `permalink`. ```json { "status": "partial_published", "targets": [ { "platform": "linkedin", "status": "published", "postedId": "urn:li:share:7241…", "permalink": "https://www.linkedin.com/feed/update/urn:li:share:7241…", "error": null }, { "platform": "twitter", "status": "failed", "postedId": null, "permalink": null, "error": "403 Forbidden: Your account is not permitted to create tweets." } ] } ``` Subscribe to `post.published` and `post.failed` to be told per target instead of polling. ### Media across channels One `mediaUrls` list is attached to every target, and each channel's rules are applied to it: Bluesky takes up to 4 images or one video (never both), YouTube exactly one video, Pinterest one image or one video with an optional cover image. A list that breaks one channel's rule is refused for the whole post. Split into two posts when the channels want different media. ## Scheduling and approvals Source: https://overads.io/docs/guides/scheduling-and-approvals How scheduledAt is interpreted, what pending means, and how a post gets through approval. ### Scheduling - `scheduledAt` is ISO 8601 with a timezone. Send UTC (`Z`) to avoid surprises. - Omit it and the post is a draft. Set it later with `PATCH /posts/:id` to schedule. - Up to five minutes in the past is moved to now with a `SCHEDULED_TIME_COERCED` warning, so a script that computed the time before a slow upload does not fail. Earlier than that is refused with `SCHEDULED_TIME_IN_PAST`. - `POST /posts/:id/publish` skips the queue and delivers now, waiting for the outcome. - `GET /best-times` suggests slots from the workspace's own history. ### The approval rule A scheduled post can be routed through review. Two things route it: - The workspace setting `requirePostApproval`. When it is on, every scheduled post created over the API lands as `pending`. Owners and admins turn it on in Settings > Rules > Require approval. It holds posts made in the app, by the assistant and by workflows too, and the composer shows its approval switch on and locked while it is on. - `requireApproval: true` on the post itself, the same checkbox the composer shows. Use it when one post needs a second pair of eyes. Either way the create call succeeds. The post has `approvalStatus: "pending"`, and when the workspace rule caused it the response carries the warning: ```json { "data": { "id": "…", "status": "scheduled", "approvalStatus": "pending" }, "warnings": [ { "code": "APPROVAL_REQUIRED", "message": "This workspace requires approval for scheduled posts. The post is queued as pending and will not publish until a member approves it in the app." } ] } ``` ### What pending means - The post is scheduled and visible in the app's approval queue, but the scheduler will not deliver it until it is `approved`. - `POST /posts/:id/publish` on a pending post is refused with `409` and code `APPROVAL_REQUIRED`. There is no API call that approves a post; a member does it in the app. - Editing a pending post is allowed. Scheduling a draft through `PATCH` applies the workspace rule the same way create does. - `post.approval_required` fires when the post enters the queue and `post.approved` when a reviewer approves it, with `approvedBy`. Subscribe to both to know when to expect delivery. > Over MCP the same warning rides on `create_post` and `update_post`. The server's instructions tell the model to say so and not retry; if you build your own agent, do the same. ## Media by URL and presigned uploads Source: https://overads.io/docs/guides/media When to pass a URL, when to upload, and the exact three-call upload sequence. ### By URL Put public https URLs in `mediaUrls` on the post. The server downloads each one into overads storage before the post is saved, so the source can go away afterwards. Up to 10 URLs, 15 MB per image, 25 MB per video. The file type is sniffed from the bytes, not the extension. curl: ```bash curl -X POST https://api.overads.io/public/v1/posts -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "content": "Two angles.", "connectionIds": ["…"], "mediaUrls": ["https://cdn.example.com/a.jpg", "https://cdn.example.com/b.jpg"] }' ``` A refusal is `MEDIA_URL_REFUSED` with the reason and the host. Private addresses, plain http, non-2xx answers and non-media content are all refused. Signed URLs work as long as they are public https and still valid when the server fetches them. ### By upload For private files, or video over 25 MB, reserve a presigned PUT, send the bytes, then complete. Images up to 15 MB, video up to 500 MB. The reserved URL lasts 15 minutes. Node: ```javascript const base = "https://api.overads.io/public/v1"; const headers = { Authorization: "Bearer " + process.env.OVERADS_API_KEY, "Content-Type": "application/json" }; const file = await fs.promises.readFile("reel.mp4"); // 1. reserve const reserve = await fetch(base + "/media/upload-url", { method: "POST", headers, body: JSON.stringify({ kind: "video", contentType: "video/mp4", bytes: file.byteLength }), }).then((r) => r.json()); const { media, uploadUrl, headers: putHeaders } = reserve.data; // 2. PUT the bytes with exactly the returned headers await fetch(uploadUrl, { method: "PUT", headers: putHeaders, body: file }); // 3. complete, then attach by id await fetch(base + "/media/" + media.id + "/complete", { method: "POST", headers }); await fetch(base + "/posts", { method: "POST", headers, body: JSON.stringify({ content: "New reel", connectionIds: ["…"], mediaIds: [media.id] }), }); ``` - `complete` answers `MEDIA_NOT_READY` (409) if the PUT has not landed. Wait for the PUT to return 200 before completing. - `complete` answers `MEDIA_TOO_LARGE` if the object in storage is over the cap for its kind, and marks the asset `failed`. - An asset stays `uploading` until completed; only `ready` assets can be attached through `mediaIds`. - `DELETE /media/:id` removes the object. A post that already references its URL keeps the string but the file is gone. ### Both on one post `mediaUrls` and `mediaIds` can be mixed. The post stores the final list of URLs in overads storage in the order given: URLs first, then ids. ## n8n Source: https://overads.io/docs/guides/n8n HTTP Request node recipes for posting, a Webhook node for events, and the MCP Client Tool for agents. n8n needs nothing overads-specific: the HTTP Request node speaks the REST API, the Webhook node receives events, and the MCP Client Tool node gives an AI Agent the tools. ### Credential Create one Header Auth credential and reuse it on every node: Name `Authorization`, Value `Bearer sk_live_...`. Keep the key out of node parameters so it is not exported with the workflow. ### Create a scheduled post | HTTP Request node | | | --- | --- | | Method | `POST` | | URL | `https://api.overads.io/public/v1/posts` | | Authentication | Generic Credential Type > Header Auth (the credential above) | | Send Headers | `Idempotency-Key` = `{{ $execution.id }}-{{ $itemIndex }}` | | Send Body | JSON, Specify Body: Using JSON | JSON body: ```json { "content": "{{ $json.copy }}", "connectionIds": ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], "scheduledAt": "{{ $json.publishAt }}" } ``` The response lands in `$json.data` with `$json.warnings` beside it. Add an IF node on `{{ $json.warnings.some(w => w.code === 'APPROVAL_REQUIRED') }}` to notify a reviewer. ### List today's scheduled posts | HTTP Request node | | | --- | --- | | Method | `GET` | | URL | `https://api.overads.io/public/v1/posts` | | Send Query Parameters | `status` = `scheduled`, `from` = `{{ $today.toISO() }}`, `to` = `{{ $today.plus({days: 1}).toISO() }}`, `limit` = `200` | ### Receive events - Add a Webhook node, method `POST`, and copy its production URL. - Register it: `POST https://api.overads.io/public/v1/webhooks` with `{ "name": "n8n", "url": "", "events": ["post.published", "post.failed"] }`. Store the returned `secret` as an n8n credential. - In the Webhook node set Response to `Immediately` and Raw Body on, so the signature can be checked over the exact bytes. - Add a Crypto node: Action `Hmac`, Type `SHA256`, Value `{{ $json.body }}` (the raw body), Secret from the credential, Encoding `hex`. Compare its output to `{{ $json.headers['x-overads-signature'] }}` in an IF node and stop on mismatch. ### Give an agent the tools | MCP Client Tool node | | | --- | --- | | Endpoint | `https://api.overads.io/public/v1/mcp` | | Server transport | HTTP Streamable | | Authentication | Bearer auth | | Token | `sk_live_...` | Attach it to an AI Agent node. The agent sees every tool the key is scoped for; see [MCP tools](https://overads.io/docs/mcp/tools). ## Make Source: https://overads.io/docs/guides/make HTTP module recipes for posting from a Make scenario and a Custom Webhook for events. Use the HTTP app's Make a request module for every call, and a Custom webhook module to receive events. ### Create a scheduled post | HTTP > Make a request | | | --- | --- | | URL | `https://api.overads.io/public/v1/posts` | | Method | `POST` | | Headers | `Authorization`: `Bearer sk_live_...`; `Idempotency-Key`: `{{execution.id}}` | | Body type | Raw | | Content type | JSON (application/json) | | Parse response | Yes | Request content: ```json { "content": "{{1.copy}}", "connectionIds": ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], "scheduledAt": "{{formatDate(1.publishAt; "YYYY-MM-DDTHH:mm:ss"; "UTC")}}Z" } ``` With Parse response on, the post is at `data` and the warnings at `warnings[]`. Add a Router with a filter on `warnings[].code` equal to `APPROVAL_REQUIRED` to alert a reviewer. ### Read one post | HTTP > Make a request | | | --- | --- | | URL | `https://api.overads.io/public/v1/posts/{{2.data.id}}` | | Method | `GET` | | Headers | `Authorization`: `Bearer sk_live_...` | | Parse response | Yes | ### Receive events - Add Webhooks > Custom webhook and copy its address. - Register it with `POST https://api.overads.io/public/v1/webhooks`; store the returned `secret`. - Make parses JSON bodies before the scenario runs, so the raw bytes are not available for a signature check inside the scenario. Keep the webhook address private, subscribe only to the events you need, and re-read anything you act on with `GET /posts/:id` using the `postId` from the payload. If you need a verified signature, put a small function in front (Cloudflare Workers, Lambda) using the [Node example](https://overads.io/docs/webhooks) and forward to Make from there. ## Zapier Source: https://overads.io/docs/guides/zapier Webhooks by Zapier recipes for posting, Catch Hook for events, and the MCP server for Zapier Agents. There is no overads app in the Zapier directory yet. Webhooks by Zapier covers every call, and Zapier Agents can use the MCP server directly. ### Create a scheduled post | Webhooks by Zapier > Custom Request | | | --- | --- | | Method | `POST` | | URL | `https://api.overads.io/public/v1/posts` | | Data Pass-Through | No | | Data | the JSON below | | Headers | `Authorization` \| `Bearer sk_live_...`; `Content-Type` \| `application/json`; `Idempotency-Key` \| the Zap run id | Data: ```json { "content": "{{copy}}", "connectionIds": ["3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11"], "scheduledAt": "{{publish_at}}" } ``` Zapier flattens the response, so the post id is `data__id` and the first warning code is `warnings__0__code`. Add a Filter step on `warnings__0__code` exactly matches `APPROVAL_REQUIRED` to branch. ### List scheduled posts | Webhooks by Zapier > GET | | | --- | --- | | URL | `https://api.overads.io/public/v1/posts` | | Query String Params | `status` \| `scheduled`, `limit` \| `50` | | Headers | `Authorization` \| `Bearer sk_live_...` | ### Receive events - Add Webhooks by Zapier > Catch Raw Hook as the trigger and copy the URL. Catch Raw Hook keeps the body as a string, which is what the signature is computed over. - Register it with `POST https://api.overads.io/public/v1/webhooks`; store the returned `secret` in Zapier's storage or as a Code step secret. - Add a Code by Zapier (JavaScript) step that computes `crypto.createHmac('sha256', secret).update(rawBody).digest('hex')` and compares it to the `X-Overads-Signature` header with `crypto.timingSafeEqual`, then parses the JSON. ### Zapier Agents | Tools > Add MCP server | | | --- | --- | | Server URL | `https://api.overads.io/public/v1/mcp` | | Authentication | Custom header | | Header | `Authorization` | | Value | `Bearer sk_live_...` | Available on Zapier plans that allow custom MCP servers. The agent sees the same tool list as any other client. ## Workflows over the API Source: https://overads.io/docs/guides/workflows List, run and poll the workspace's automations from a script or an agent. Workflows are the automations built in the app. Over the API they are exposed as three MCP tools under the `workflows:run` scope: `list_workflows`, `run_workflow`, `get_workflow_run`. There is no REST route for workflows yet; a script calls the MCP endpoint with a JSON-RPC body, which is one POST per call. The starter workflows a workspace can clone are listed publicly at [overads.io/templates](https://overads.io/templates); `templateKey` on a listed workflow names the one it came from. ### List curl: ```bash curl -sS https://api.overads.io/public/v1/mcp -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_workflows","arguments":{}}}' ``` Each row has `id`, `name`, `description`, `enabled`, `autonomy`, `triggers` (the trigger node types), `templateKey`, `updatedAt`. A disabled workflow can still be run manually. ### Run curl: ```bash curl -sS https://api.overads.io/public/v1/mcp -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_workflow","arguments":{"workflowId":"WORKFLOW_ID","triggerMeta":{"source":"nightly-job"},"idempotencyKey":"nightly-2026-09-11"}}}' ``` The run is queued and executes in the background; the result is `{ runId, workflowId, status, dryRun }`. Pass `dryRun: true` to walk the data and condition nodes and report what the action nodes would stage without staging it. `triggerMeta` values are exposed to nodes as `{{trigger.*}}`. A run is refused with `plan_limit_reached` when the plan's daily run cap is used up, and with a budget message when the monthly AI budget is spent. Report those as the plan limit, not as a failure of the workflow. ### Poll curl: ```bash curl -sS https://api.overads.io/public/v1/mcp -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_workflow_run","arguments":{"runId":"RUN_ID"}}}' ``` - `status` is `queued`, `running`, `succeeded`, `failed` or `skipped`. - `costMicros` is null until the run finishes; null is not free. - `steps` are in order. A step that staged an action for a human to confirm says so; the action has not been performed. - `proposalCount` and `draftPostCount` say what the run left behind for a member to confirm or schedule. Or skip polling: subscribe to `workflow.run.completed` and `workflow.run.failed` on a [webhook](https://overads.io/docs/webhooks). Both carry `workflowId` and `runId`. > The curl calls above use the legacy single-POST shape (no `Accept: text/event-stream`, no session), which the server keeps for one release. A long-lived integration should speak Streamable HTTP through an MCP client library instead; see [MCP server](https://overads.io/docs/mcp). ## Managed workspaces Source: https://overads.io/docs/guides/managed-workspaces For agencies and B2B: create a workspace per client, let the client connect channels on a hosted page, then post for them. An agency key with `workspaces:manage` can create client workspaces, hand each client a hosted page to connect their own channels without an overads login, and then act on the client workspace with the agency key plus a header, or with a key minted for the client. Nothing here needs the client to sign up. ### 1. Create the workspace curl: ```bash curl -X POST https://api.overads.io/public/v1/workspaces -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-coffee-create" \ -d '{ "name": "Acme Coffee" }' ``` The workspace is real: it inherits the agency's plan, is owned by the agency workspace's owner, appears in the app switcher and under Settings > Client Accounts. Keep the returned `id`. The agency's subscription covers it: Pro and Scale put no cap on publishing channels, and if the agency's plan ends, the client workspace pauses with it. ### 2. Let the client connect their channels curl: ```bash curl -X POST https://api.overads.io/public/v1/workspaces/WORKSPACE_ID/connect-url -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "expiresInDays": 14, "brand": { "name": "Northwind Social", "logoUrl": "https://cdn.northwind.example/logo.png" } }' ``` Send the returned `url` to the client. The page at `/connect/ovc_...` shows your brand name and logo, one button per channel, and the accounts already connected. The client signs in to each platform with the platform's own login; tokens land in their workspace, encrypted, exactly as if they had connected in the app. The link is the credential: it grants reading that page and connecting channels, nothing else. - Default lifetime 7 days, up to 90. An expired link says so and asks for a new one. - `rotate: true` revokes every earlier link for the workspace first. Use it when a link leaked or a contact left. - Detaching the workspace revokes every link for it. - Channels whose credentials the deployment does not hold show on the page marked as not configured; channels in review connect for test accounts only. ### 3. Act on the client workspace Two options. With the agency key, add the header to any public route or MCP call: curl: ```bash curl https://api.overads.io/public/v1/connections -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "x-overads-workspace: WORKSPACE_ID" ``` Or mint a key that belongs to the client workspace and hand it to whatever runs for that client. It needs no header and can never hold `workspaces:manage`: curl: ```bash curl -X POST https://api.overads.io/public/v1/workspaces/WORKSPACE_ID/api-keys -H "Authorization: Bearer sk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Acme integration", "scopes": ["connections:read", "posts:read", "posts:write", "media:write"] }' ``` From here everything is the ordinary API: `GET /connections` lists what the client connected, `POST /posts` schedules into it, webhooks registered in that workspace fire for its posts. Scopes, rate limits and idempotency all apply to the workspace being acted on. ### Rules worth knowing - The header is honoured on every public route and every MCP tool, except the `/workspaces/*` routes themselves, which always act on the agency and refuse it with `WORKSPACE_HEADER_NOT_ALLOWED`. - Only a direct child counts. A managed workspace cannot manage workspaces of its own, and a key minted for a client cannot name other workspaces in the header. - A header naming a workspace the key cannot act for answers `403` with `WORKSPACE_NOT_MANAGED`, never the agency's own data. A malformed header answers `400` with the same code. - `DELETE /workspaces/:id` detaches. The workspace, its posts and its connections stay with their owner; keys minted for it keep working; connect links are revoked. Nothing is deleted. - Every create and detach is written to the agency's audit log with the id of the key that did it. > White label in this phase is the brand name and logo on the hosted connect page. The client workspace itself is an overads workspace with overads branding.