# Adeli API > One REST API for your customers' Instagram, Facebook, TikTok, YouTube, X, and Bluesky accounts: > hosted OAuth connect, publishing, comments, message history, analytics, and Facebook ads. > Authenticate with `Authorization: Bearer rk_live_...`. A key belongs to your > organization; every request names a profile with profileId or an accountId. Base URL: https://app.tryadeli.com/api/v1 MCP server: https://app.tryadeli.com/mcp (Streamable HTTP, same Bearer key) Documentation: https://www.tryadeli.com/docs This file is the complete documentation corpus. Every page is also served on its own as markdown: append .md (or .txt) to its URL. --- # Getting started ## Introduction URL: https://www.tryadeli.com/docs Markdown: https://www.tryadeli.com/docs.md What Adeli is, which providers it covers, and how profiles model your customers. # Adeli API > What Adeli is, which providers it covers, and how profiles model your customers. Canonical: One REST API for your customers' social accounts. Connect an Instagram professional account, a Facebook Page, a TikTok account, or a YouTube channel once, then publish posts, manage comments, read message history, and pull analytics through a single normalized interface — without writing against Meta's Graph API, TikTok API for Business, and the YouTube Data API separately. Your first three connected accounts are free. See [Pricing](https://www.tryadeli.com/docs/pricing). **cURL** ```bash curl https://app.tryadeli.com/api/v1/profiles \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}` }, }); const { profiles } = await response.json(); ``` **Python** ```python import os, requests response = requests.get( "https://app.tryadeli.com/api/v1/profiles", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) profiles = response.json()["profiles"] ``` New here? [The quickstart](https://www.tryadeli.com/docs/quickstart) takes you from a fresh API key to a published post in six steps. Using an AI agent? Connect it through the [MCP server](https://www.tryadeli.com/docs/mcp) with the same key, and it can call the whole API as tools. ## How it fits together A **profile** is one of your customers. Your profiles live in your **organization**, the workspace your team shares, and you issue an API key for the organization in the Adeli dashboard. One key reaches every profile, so every request names the one it acts on, with a `profileId` or with an `accountId` that belongs to it. Nothing defaults to a profile, so a request cannot quietly act on the wrong customer. Create profiles in the dashboard or with `POST /api/v1/profiles`. Inside a profile you connect **accounts** — one Instagram account, one TikTok account, one YouTube channel. You do that by sending your customer through [the connect flow](https://www.tryadeli.com/docs/api/connect), which hands you an authorization URL to open in their browser and a session to poll. Adeli stores and refreshes the provider tokens; you never handle them. From there, every endpoint takes an `accountId` and returns a normalized shape. The same `POST /api/v1/posts` call publishes to Instagram, Facebook, TikTok, YouTube, X, or Bluesky depending on the `platform` you pass. ## What each provider supports | | Instagram | TikTok | YouTube | | ----------------------------------------- | -------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Connect via hosted OAuth | Yes | Yes | Yes, one channel per profile | | List posts | Yes | Yes, with views, shares, and reach | Yes, with views | | Publish a single image | Yes | Yes | No | | Publish a carousel | Yes, 2–10 images | Yes, 1–35 photos | No | | Publish a video | No | Yes, public or as a draft | Yes, public, unlisted, private, or scheduled; vertical videos up to 3 minutes become Shorts | | Read, reply to, hide, and delete comments | Yes | Yes, plus likes; deletes the account's own comments only | Yes; hiding holds a comment for review, and others' comments are rejected rather than deleted | | Read message history | Yes | No | No — YouTube has no messaging API | | Send a message | Yes, text | No | No | | Account analytics | Views, reach, interactions | Daily views, followers, and engagement; follower demographics | Daily views, watch time, and subscribers; viewer demographics | | Post analytics | No | Watch time, retention, impression sources | Views, watch time, average view percentage | > **Note: Not available in v1** > > WhatsApp sends approved templates only, and numbers are connected in the > dashboard. There is no post > editing or deletion, and no outbound webhooks. YouTube uploads stay private > until Adeli passes YouTube's API audit. Instagram post listing follows > provider pagination on your behalf but exposes no cursors of its own — see > [Core concepts](https://www.tryadeli.com/docs/concepts) for the limits that apply to every read. ## Base URL All endpoints live under `/api/v1`, and every response is JSON. ```bash https://app.tryadeli.com/api/v1 ``` The whole API is also described as an OpenAPI 3.1 document at [`/docs/openapi.json`](https://www.tryadeli.com/docs/openapi.json). Import it into Postman or Insomnia, generate a client from it, or hand it to an AI coding tool. Running Adeli locally? Use `http://localhost:3000/api/v1` instead. The examples throughout these docs assume two environment variables: ```bash export ADELI_URL=https://app.tryadeli.com export ADELI_API_KEY='rk_live_...' ``` ## Next steps - **[Quickstart](https://www.tryadeli.com/docs/quickstart)** — connect an account and publish a post. - **[Authentication](https://www.tryadeli.com/docs/authentication)** — how API keys work and how to keep them safe. - **[Core concepts](https://www.tryadeli.com/docs/concepts)** — profiles, partial responses, and limits. - **[MCP server](https://www.tryadeli.com/docs/mcp)** — give an AI agent the whole API as tools, with the same key. - **[API reference](https://www.tryadeli.com/docs/api)** — every endpoint, parameter, and response. - **[OpenAPI spec](https://www.tryadeli.com/docs/openapi.json)** — the same reference as a machine-readable OpenAPI 3.1 document. ## Quickstart URL: https://www.tryadeli.com/docs/quickstart Markdown: https://www.tryadeli.com/docs/quickstart.md From a new API key to a published post in six steps. # Quickstart > From a new API key to a published post in six steps. Canonical: Six steps: create a key, pick a profile for your customer, connect their Instagram account to it, and publish an image. Everything here runs against a real account — there is no sandbox mode, so use an account you do not mind posting to. You will need an Instagram professional (Business or Creator) account. TikTok and YouTube follow the same shape; the differences are called out in [the Posts reference](https://www.tryadeli.com/docs/api/posts). ## 1. Create an API key Sign in at [`/sign-in`](https://app.tryadeli.com/sign-in) with Google. Any Google account works, and your first sign-in creates your account, an organization you own, and a default profile in it. Open **Settings → API keys** in the dashboard sidebar, or go straight to [`/settings/api-keys`](https://app.tryadeli.com/settings/api-keys). Give the key a label such as `Local testing` and select **Create key**. > **Warning: The secret is shown once** > > Copy it immediately — Adeli stores only a hash and cannot show it to you > again. A key belongs to your organization and can act on every profile in it, > and deleting a key revokes it instantly. Save it alongside the base URL: ```bash export ADELI_URL=https://app.tryadeli.com export ADELI_API_KEY='rk_live_...' ``` ## 2. List your profiles, or create one A profile is one of your customers. Every request names the profile it acts on, with a `profileId` or with an `accountId` that belongs to it, and nothing defaults to one. This call lists your organization's profiles, the default first, and confirms the key works. > **Note: Changed on 2026-10-08** > > Keys used to be bound to one profile, and requests without a `profileId` used > it. Keys now belong to the organization, and a request that names no profile > returns `400 profile_required`. See [Authentication](https://www.tryadeli.com/docs/authentication#breaking-change-2026-10-08). **cURL** ```bash curl --fail-with-body "$ADELI_URL/api/v1/profiles" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const adeli = (path, init) => fetch(`${process.env.ADELI_URL}/api/v1${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", ...init?.headers, }, }).then((response) => response.json()); const { profiles } = await adeli("/profiles"); const profileId = profiles[0].id; ``` **Python** ```python import os, requests BASE = f"{os.environ['ADELI_URL']}/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"} profiles = requests.get(f"{BASE}/profiles", headers=HEADERS).json()["profiles"] profile_id = profiles[0]["id"] ``` ```json { "profiles": [ { "id": "00000000-0000-4000-8000-000000000001", "name": "Client A", "externalId": "customer-123", "metadata": {}, "isDefault": true, "createdAt": "2026-09-08T20:00:00.000Z", "updatedAt": "2026-09-08T20:00:00.000Z" } ] } ``` To give a new customer their own profile instead, create one. The optional `Idempotency-Key` makes a retry return the same profile rather than a `409 profile_name_conflict`: ```bash curl --fail-with-body -X POST "$ADELI_URL/api/v1/profiles" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: customer-123" \ -d '{"name":"Client A","externalId":"customer-123"}' ``` Save the profile's `id` — the next three calls need it. ```bash export PROFILE_ID=00000000-0000-4000-8000-000000000001 ``` ## 3. Start a connection This returns an `authUrl`. Open it in **your customer's browser** — they authorize Instagram directly with Meta, and Adeli never sees their credentials. **cURL** ```bash curl --fail-with-body -X POST \ "$ADELI_URL/api/v1/profiles/$PROFILE_ID/connect" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"platform":"instagram","redirectUrl":"https://client.example/callback"}' ``` **JavaScript** ```javascript const session = await adeli(`/profiles/${profileId}/connect`, { method: "POST", body: JSON.stringify({ platform: "instagram", redirectUrl: "https://client.example/callback", }), }); // Send the customer here. Do not fetch it from your backend. console.log(session.authUrl); ``` **Python** ```python session = requests.post( f"{BASE}/profiles/{profile_id}/connect", headers=HEADERS, json={ "platform": "instagram", "redirectUrl": "https://client.example/callback", }, ).json() print(session["authUrl"]) # Send the customer here. ``` ```json { "id": "00000000-0000-4000-8000-000000000002", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "status": "pending_authorization", "accountId": null, "expiresAt": "2026-09-08T20:10:00.000Z", "completedAt": null, "error": null, "authUrl": "https://www.instagram.com/oauth/authorize?..." } ``` > **Note: redirectUrl must be allowlisted** > > Its origin has to appear in the server's > `PUBLIC_API_CONNECT_REDIRECT_ORIGINS` allowlist and use HTTPS, except for > `localhost` and `127.0.0.1` during development. An origin that is not on the > list returns `400 invalid_redirect_uri`. You can omit `redirectUrl` entirely > and just poll instead. ## 4. Poll until the connection lands The session expires ten minutes after it is created. Poll it until `status` is one of the three terminal values: `connected`, `failed`, or `expired`. **cURL** ```bash curl --fail-with-body \ "$ADELI_URL/api/v1/profiles/$PROFILE_ID/connect/$CONNECTION_SESSION_ID" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const TERMINAL = new Set(["connected", "failed", "expired"]); let state = session; while (!TERMINAL.has(state.status)) { await new Promise((resolve) => setTimeout(resolve, 2000)); state = await adeli(`/profiles/${profileId}/connect/${session.id}`); } if (state.status !== "connected") throw new Error(state.error?.code ?? state.status); const accountId = state.accountId; ``` **Python** ```python import time TERMINAL = {"connected", "failed", "expired"} state = session while state["status"] not in TERMINAL: time.sleep(2) state = requests.get( f"{BASE}/profiles/{profile_id}/connect/{session['id']}", headers=HEADERS, ).json() assert state["status"] == "connected", state["error"] account_id = state["accountId"] ``` If you supplied a `redirectUrl`, the browser comes back to it with `connectionSessionId`, `status`, and — on success — `accountId` in the query string. Treat those as a hint for your UI and poll the session for the authoritative answer. ## 5. List the connected accounts **cURL** ```bash curl --fail-with-body "$ADELI_URL/api/v1/accounts?profileId=$PROFILE_ID&platform=instagram" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const { accounts } = await adeli(`/accounts?profileId=${profileId}&platform=instagram`); ``` **Python** ```python accounts = requests.get( f"{BASE}/accounts", headers=HEADERS, params={"profileId": profile_id, "platform": "instagram"} ).json()["accounts"] ``` ```json { "status": "complete", "accounts": [ { "id": "00000000-0000-0000-0000-000000000000", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "providerId": "17841400000000000", "displayName": "@adeli", "displayIdentifier": "adeli", "connectionStatus": "connected" } ], "errors": [] } ``` Use `accountId` — Adeli's UUID — in every later request. It belongs to exactly one profile, so a request that passes it needs no `profileId`. `providerId` is informational. ## 6. Publish an image Images are sent as base64 and must be JPEG, PNG, or WebP, decoding to no more than 8 MiB. Adeli normalizes them to JPEG before handing them to Instagram. **cURL** ```bash # Build the request body without putting base64 on the command line. node -e ' const fs = require("node:fs"); fs.writeFileSync("/tmp/adeli-post.json", JSON.stringify({ platform: "instagram", accountId: process.env.ACCOUNT_ID, caption: "Published through Adeli", image: { contentType: "image/png", base64: fs.readFileSync("./photo.png").toString("base64") }, })); ' curl --fail-with-body -X POST "$ADELI_URL/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ --data-binary @/tmp/adeli-post.json ``` **JavaScript** ```javascript import { readFile } from "node:fs/promises"; const post = await adeli("/posts", { method: "POST", body: JSON.stringify({ platform: "instagram", accountId, caption: "Published through Adeli", image: { contentType: "image/png", base64: (await readFile("./photo.png")).toString("base64"), }, }), }); ``` **Python** ```python import base64, pathlib post = requests.post( f"{BASE}/posts", headers=HEADERS, json={ "platform": "instagram", "accountId": account_id, "caption": "Published through Adeli", "image": { "contentType": "image/png", "base64": base64.b64encode(pathlib.Path("photo.png").read_bytes()).decode(), }, }, ).json() ``` A successful publish returns `201` and the normalized post: ```json { "id": "instagram_post_00000000-0000-0000-0000-000000000000_17900000000000000", "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "providerId": "17900000000000000", "caption": "Published through Adeli", "media": [{ "type": "IMAGE", "url": "https://media.example/staging/key.jpg", "thumbnailUrl": "https://media.example/staging/key.jpg" }], "permalink": null, "engagement": { "likes": null, "comments": null }, "publishedAt": "2026-04-01T12:00:00.000Z" } ``` Pass `images` instead of `image` — an array of 2 to 10 — to publish a carousel. ## Where to go next - **[Authentication](https://www.tryadeli.com/docs/authentication)** — key scoping and how to keep keys out of the browser. - **[Core concepts](https://www.tryadeli.com/docs/concepts)** — partial responses, and the limits on every read. - **[Posts](https://www.tryadeli.com/docs/api/posts)** — TikTok video, photo, and carousel publishing, and YouTube uploads. - **[Errors](https://www.tryadeli.com/docs/errors)** — every code the API can return. ## Authentication URL: https://www.tryadeli.com/docs/authentication Markdown: https://www.tryadeli.com/docs/authentication.md Bearer API keys, how they reach every profile in your organization, and how to keep them safe. # Authentication > Bearer API keys, how they reach every profile in your organization, and how to keep them safe. Canonical: Every request to `/api/v1` carries a bearer API key. There is no `X-API-Key` header, no OAuth client-credentials flow, and no unauthenticated endpoint. ```http Authorization: Bearer rk_live_... Accept: application/json ``` ## Creating a key Keys are issued from the dashboard, not through the API. Owners and admins of an organization can create and delete them; every member can see the list, which shows who created each key. 1. Open **Settings → API keys**, or go to [`/settings/api-keys`](https://app.tryadeli.com/settings/api-keys). 2. Add an optional label and select **Create key**. 3. Copy the secret. > **Warning: The secret is shown exactly once** > > Adeli stores a SHA-256 hash of your key and the first few characters for > display. Nothing in the product can recover the plaintext, so if you lose it, > delete the key and create another. Keys look like `rk_live_` followed by 43 URL-safe characters. The dashboard lists them by their prefix, such as `rk_live_Ab3dEf9…`, so you can tell which key is which without ever revealing one. ## Keys belong to your organization A key belongs to your organization, not to a profile, and can act on every profile in it. Because of that, every request names the profile it acts on: - With a `profileId` in the path, as in `/api/v1/profiles/{profileId}/accounts`, or in the query string or body. - Or with an `accountId`. Each connected account belongs to exactly one profile, so the `accountId` names it, and you can leave `profileId` out. Nothing defaults to a profile: - A request with neither returns `400 profile_required`. - A `profileId` in another organization returns `404 profile_not_found`, and an `accountId` in another organization returns `404 account_not_found`, not `403`. The API does not confirm whether something you cannot reach exists. - A `profileId` and `accountId` that disagree return `404 account_not_found`. Three endpoints act on the whole organization and take no profile: `GET /api/v1/profiles`, `POST /api/v1/profiles` and `GET /api/v1/usage/x`. There are no scopes or per-endpoint permissions. A key can do everything the API offers, for every profile in your organization. > **Note: Keep customers apart with profiles** > > If you serve multiple end customers, give each one its own profile and always > pass its `profileId` or one of its `accountId`s. Treat a key as access to all > of them: a leaked key exposes your whole organization, so rotate it. ## Breaking change (2026-10-08) Until 2026-10-08, a key was bound to one profile, and a request without a `profileId` used that profile. Every existing key was then promoted to its organization and can now reach every profile in it. If you shared a key with anyone outside your organization, such as one customer, rotate it. Requests must now name a profile with `profileId` or an `accountId`, or they return `400 profile_required`. `POST /api/v1/profiles` now creates a profile instead of returning `403 profile_scope_violation`. ## Revoking a key Delete the key from **Settings → API keys**. Revocation is immediate — there is no cache and no grace period, and the next request with that key returns `401`. Deleting a profile does not delete any key. ## Keep keys on your server An Adeli key can publish posts and send messages on your customer's behalf. Treat it like a database password. - Never ship a key in browser JavaScript, a mobile app bundle, or a public repo. - Never put one in a URL — they end up in logs, proxies, and analytics. - Call Adeli from your backend and expose only the results to your frontend. The `@adeli/sandbox` workspace in the Adeli repository is a working example of this shape: the browser posts to a small server route, and that route is the only place the key exists. ## Failed authentication A missing, malformed, or revoked key returns `401` with the standard error envelope: ```json { "error": { "code": "unauthorized", "message": "A valid Bearer API key is required" } } ``` Check a key quickly with a request that has no side effects: **cURL** ```bash curl -i "$ADELI_URL/api/v1/profiles" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch(`${process.env.ADELI_URL}/api/v1/profiles`, { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}` }, }); console.log(response.status); // 200 when the key is valid, 401 when it is not ``` **Python** ```python import os, requests response = requests.get( f"{os.environ['ADELI_URL']}/api/v1/profiles", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.status_code) # 200 when the key is valid, 401 when it is not ``` See [Errors](https://www.tryadeli.com/docs/errors) for everything else the API can return. ## Core concepts URL: https://www.tryadeli.com/docs/concepts Markdown: https://www.tryadeli.com/docs/concepts.md Profiles, accounts, the partial-response contract, and the limits that apply to every read. # Core concepts > Profiles, accounts, the partial-response contract, and the limits that apply to every read. Canonical: Four ideas explain most of the API's behavior: profiles, accounts, the partial-response contract, and the limits on aggregate reads. ## Profiles A profile is a client boundary — one of your end customers or brands. It owns the provider connections made on its behalf. Profiles live in your **organization**, the workspace your team shares, and an API key belongs to the organization, so one key can act on every profile in it. **Profile** | Name | Type | Required | Description | | ------------ | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `id` | `uuid` | | Adeli's identifier. Use it in every path that takes a profileId. | | `name` | `string` | | Display name, unique within your organization. 1–200 characters. | | `externalId` | `string \| null` | | Your own identifier for this customer, so you do not have to store a mapping. Unique within your organization when set. | | `metadata` | `object \| null` | | Arbitrary JSON you control, up to 16 KiB encoded as UTF-8. | | `isDefault` | `boolean` | | Whether this is the profile new dashboard sessions start on. | Profiles are created in the dashboard or with [`POST /api/v1/profiles`](https://www.tryadeli.com/docs/api/profiles), and listed with `GET /api/v1/profiles`. Deleting one cascades: its connected accounts and cached history go with it. API keys belong to the organization and are not affected. ## Accounts An account is one provider connection inside a profile — an Instagram professional account, a Facebook Page, a TikTok account, or a YouTube channel. A profile holds at most one of each. Every endpoint that acts on a provider takes `accountId`, which is **Adeli's** UUID. The provider's own identifier comes back as `providerId` and is informational; passing it where an `accountId` is expected returns `400 invalid_request`. **Account** | Name | Type | Required | Description | | ------------------- | ---------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `accountId` | `uuid` | | The identifier to use in requests. `id` is the same value. | | `platform` | `"instagram" \| "tiktok" \| "facebook" \| "youtube"` | | Which provider this connection belongs to. | | `providerId` | `string` | | The provider's identifier — an Instagram user id, TikTok's app-scoped open\_id, or a YouTube channel id. | | `displayName` | `string` | | Human-readable name, such as "@adeli". | | `displayIdentifier` | `string` | | The handle or open\_id, without decoration. | | `connectionStatus` | `"connected" \| "expired" \| "reconnect_required"` | | Whether Adeli can still act on this account. Anything but connected means the customer has to authorize again. | Instagram long-lived tokens last roughly 60 days. Adeli refreshes them, and `POST /api/v1/profiles/:profileId/accounts/refresh` forces the attempt, but a connection that has lapsed needs the customer to go back through [the connect flow](https://www.tryadeli.com/docs/api/connect). ## Scoping a request Every request names the profile it acts on, and nothing defaults to one. - **Pass `profileId`** in the path, the query string, or the body. - **Or pass `accountId`.** An account belongs to exactly one profile, so it names that profile and `profileId` can be left out. If you pass both, they must agree, or you get `404 account_not_found`. - **Pass neither** and you get `400 profile_required`. - **Pass a profile or account in another organization** and you get `404 profile_not_found` or `404 account_not_found`, whether or not it exists. Only `GET` and `POST /api/v1/profiles` and `GET /api/v1/usage/x` act on the whole organization and take no profile. Add `accountId` to narrow a read to one connection, and `platform` to narrow it to one provider. ## Partial responses Aggregate reads — accounts, posts, messages — can touch more than one provider in a single request. One provider failing does not throw away the data from the others. | HTTP | `status` | Meaning | | ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | `complete` | Every attempted provider succeeded. `errors` is empty. | | `207` | `partial` | At least one succeeded and at least one failed. Successful records are still in the response; `errors` says which account failed and why. | | `404` / `422` | — | Every attempted provider failed for the same reason, and that reason has a meaningful status: `account_not_found` is `404`, `connection_expired` and `provider_not_configured` are `422`. | | `502` | — | Every attempted provider failed for mixed or upstream reasons. | A partial response looks like this: ```json { "status": "partial", "messages": [], "errors": [ { "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "code": "provider_error", "message": "The provider request failed" } ] } ``` > **Warning: Treat 207 as success with caveats** > > A client that only checks `response.ok` will silently accept `207` and act on > an incomplete list. Check `status` and inspect `errors` before you treat an > aggregate read as the whole picture. Provider error bodies are never passed through. An upstream failure becomes a sanitized `provider_error` so that provider credentials and internal URLs cannot leak into your logs. ## Limits **What applies to every request** | Name | Type | Required | Description | | ----------------- | ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Pagination` | `none` | | Adeli follows each provider's cursors server-side and returns one flat array. There are no cursors in the public API. Filter by `platform` and `accountId` to keep responses small. | | `Result cap` | `10,000` | | The maximum number of records in one collection response. Exceeding it truncates the array and adds a `result_limit` entry to `errors`, which makes the response `207`. | | `Rate limiting` | `none` | | Not implemented in v1. Do not rely on its absence — be reasonable, and expect limits to arrive before general availability. | | `Caching` | `none` | | Every response carries `cache-control: no-store`. Reads hit the provider. | | `Instagram image` | `8 MiB` | | Decoded size for a single image or each carousel entry. JPEG, PNG, and WebP are accepted and normalized to JPEG. | | `TikTok video` | `64 MiB / 1 GB` | | 64 MiB decoded for base64 JSON bodies; 1 GB for multipart uploads and public URL pulls. | | `TikTok photo` | `20 MB` | | Per photo, up to 35 photos, normalized to JPEG at 1080×1920. | | `YouTube video` | `1 GB / 4 GiB` | | 1 GB for a public `video.url` pull; 4 GiB for a multipart upload to `POST /api/v1/posts/youtube`. Base64 JSON bodies are capped by the request size. | | `YouTube quota` | `10,000 units/day` | | YouTube's daily quota belongs to Adeli's Google Cloud project and is shared by every connected channel. Reads cost 1 unit, every comment write and video edit 50, and uploads draw on a separate allowance of 100 a day. When the day's quota is spent, YouTube calls return `429 quota_exhausted` with the reset time, midnight Pacific. | ## Idempotency and retries There is no idempotency key on the public API. `POST /api/v1/posts` and `POST /api/v1/messages` are not safe to retry blindly — a retry after a timeout may publish twice. If a publish times out, list posts or query the TikTok or YouTube status endpoint to find out what actually happened before trying again. Reads are safe to retry. ## What is not here - **Post editing and deletion.** `PUT` and `DELETE` on posts are deliberately not part of v1. - **Outbound webhooks.** Adeli does not call your server. Poll instead. - **WhatsApp free-form replies and connecting a number.** The API sends WhatsApp templates; see [Messages](https://www.tryadeli.com/docs/api/messages). - **Comments.** Not exposed through the API. - **Scheduling.** `scheduled_date`, `add_to_queue`, and `async_upload` are rejected with `422 unsupported_feature`. ## Errors URL: https://www.tryadeli.com/docs/errors Markdown: https://www.tryadeli.com/docs/errors.md The error envelope, every status code the API returns, and what each error code means. # Errors > The error envelope, every status code the API returns, and what each error code means. Canonical: Every failure returns JSON in the same shape, whatever went wrong. ```json { "error": { "code": "invalid_request", "message": "accountId must be a valid UUID", "details": [{ "path": ["accountId"], "message": "Invalid uuid" }] } } ``` `code` is stable and safe to branch on. `message` is written for a human reading a log and may change. `details` is present only when there is something more to say — validation issues, or the per-account `errors` array from an aggregate read. ## Status codes | Status | When | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success, or an aggregate read where every provider succeeded. | | `201` | A resource was created — an Instagram post, a message, a connection session. | | `202` | A TikTok or YouTube publish, or a Facebook video, was accepted. It is not published yet; [poll for status](https://www.tryadeli.com/docs/api/posts). | | `207` | An aggregate read where some providers succeeded and some failed. | | `400` | The request is malformed: bad JSON, a failed schema check, a non-UUID identifier, or no profile named (`profile_required`). | | `402` | Billing is needed first: `billing_required` when a connect would pass the workspace's free accounts with no card on file (see [Billing](https://www.tryadeli.com/docs/billing#in-the-api)), or `x_billing_required` when X is used with no card on file (see [X API pricing](https://www.tryadeli.com/docs/pricing/x)). | | `403` | A connection has not granted a permission the call needs: `missing_permission`, or `ads_permission_required` for [Ads](https://www.tryadeli.com/docs/api/ads). | | `404` | The resource does not exist, **or** it exists but your key cannot reach it. | | `413` | The request body is too large. | | `415` | `Content-Type` is neither `application/json` nor `multipart/form-data`. | | `422` | The request is well-formed but the operation is not possible: an unsupported platform, an expired connection, invalid media, a deferred feature. | | `429` | The provider is throttling this account or app (TikTok), the account hit TikTok's daily posting limit, or YouTube's shared daily quota is spent. Retry later. | | `502` | Every attempted provider failed, or a database read failed. Provider details are sanitized away. | | `503` | X is unavailable to Adeli for a while: `temporarily_unavailable`. Retry later. | > **Note: 404 covers authorization, on purpose** > > Supplying a `profileId` or `accountId` that belongs to another organization > returns `404`, not `403`. The API will not confirm that a resource exists > outside your key's organization. A `profileId` and `accountId` that disagree > also return `404 account_not_found`, the same answer as an unknown account. ## Error codes ### Authentication and scope | Name | Type | Required | Description | | ------------------- | ----- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unauthorized` | `401` | | The Authorization header is missing, malformed, or names a key that has been deleted. | | `profile_required` | `400` | | The request named no profile. Pass `profileId`, or an `accountId` that belongs to a profile. Only `GET` and `POST /api/v1/profiles` and `GET /api/v1/usage/x` need neither. | | `profile_not_found` | `404` | | The `profileId` does not exist, or it belongs to another organization. | ### Billing | Name | Type | Required | Description | | ------------------ | ----- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `billing_required` | `402` | | Starting a connection would take the workspace past its three free accounts, and no card is on file. `details.billingUrl` is where the workspace owner adds one. See [Billing](https://www.tryadeli.com/docs/billing#in-the-api). | ### Request validation | Name | Type | Required | Description | | ------------------------ | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `invalid_request` | `400 / 413` | | Malformed JSON, a schema failure, a non-UUID identifier, or a body over the size limit. Check `details`. | | `invalid_profile` | `400` | | Profile fields failed their own checks — for example `metadata` over 16 KiB. | | `invalid_redirect_uri` | `400` | | The connect `redirectUrl` is not a valid URL, its origin is not allowlisted, or it is not HTTPS outside localhost. | | `unsupported_media_type` | `415` | | `Content-Type` must be `application/json` or `multipart/form-data`. | ### Conflicts | Name | Type | Required | Description | | ------------------------------ | ----- | -------- | ------------------------------------------------------------------------------------- | | `profile_name_conflict` | `409` | | Another profile already uses this name. `details.existingProfileId` names it. | | `profile_external_id_conflict` | `409` | | Another profile already uses this `externalId`. `details.existingProfileId` names it. | | `idempotency_conflict` | `409` | | The `Idempotency-Key` was already used with a different request body. | ### Accounts and connections | Name | Type | Required | Description | | ------------------------------ | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `account_not_found` | `404` | | No connected account with that id in your key's organization, or it is not on the `profileId` you also passed. | | `connection_session_not_found` | `404` | | No connection session with that id for this profile. | | `connection_expired` | `422` | | The stored provider token has lapsed, or the required scope was never granted. The customer has to reconnect. | | `connection_unavailable` | `—` | | Appears inside an aggregate errors array when one account's credentials could not be used for that read. | | `provider_not_configured` | `422` | | This Adeli deployment has no credentials for that provider. | | `provider_not_supported` | `422` | | The provider cannot do this. WhatsApp and Google Business Profile connect sessions, and TikTok and YouTube messaging, land here. | | `missing_scope` | `—` | | A YouTube, X, or Bluesky connect session's `error.code`: the customer withheld a permission on the provider's consent screen. | | `invalid_handle` | `400` | | A Bluesky `handle` did not resolve to an account, when starting a connect or as a connect session's `error.code`. | | `x_billing_required` | `402` | | X is billed per call and needs a card on file, even within your free accounts. Connecting, posting, deleting, and listing X posts all return it until a card is added. See [X API pricing](https://www.tryadeli.com/docs/pricing/x). | | `reconnect_required` | `409` | | Turning on an X capability needs a permission the connection was not granted. `details.missingScopes` lists them. | | `no_channel` | `—` | | A YouTube connect session's `error.code`: the Google account chosen has no YouTube channel. | ### Publishing | Name | Type | Required | Description | | --------------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `unsupported_platform` | `422` | | The endpoint does not serve that platform — listing posts covers Instagram, Facebook, TikTok, YouTube, X, and Bluesky, and messages are Instagram, Facebook, or WhatsApp only. | | `unsupported_feature` | `422` | | Scheduling, queueing, and background uploads are deferred. `scheduled_date`, `add_to_queue`, and `async_upload` are rejected. | | `invalid_image` | `422` | | The image is not decodable, not a supported format, or over the size limit. | | `invalid_video` | `422` | | The video is not a usable MP4, is over the size limit, or exceeds the creator's maximum duration. | | `privacy_level_unsupported` | `422` | | A TikTok video asked for a `privacy_level` other than `PUBLIC_TO_EVERYONE`. TikTok videos publish publicly; send `post_mode: "MEDIA_UPLOAD"` for a draft instead. | | `invalid_post_settings` | `422` | | The requested privacy, comment, duet, or stitch setting is not available to this creator, or branded content was requested without public visibility. | | `media_url_unverified` | `422` | | TikTok has not verified this deployment's media URL prefix, which it must before any TikTok post works. | | `upload_limit_exceeded` | `422` | | The YouTube channel has reached YouTube's own upload limit. Try again later. | | `provider_rejected` | `422` | | The provider refused the post's content or metadata, such as a YouTube category\_id that does not exist. | | `duplicate_post` | `409` | | X refused the post because the account published the same text recently. | | `invalid_media` | `422` | | An X media item is not valid base64, does not match its content\_type, is over its size limit, or X could not process it. | ### Comments | Name | Type | Required | Description | | ------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------ | | `post_not_found` | `404` | | The `postId` is not one of this account's posts. | | `comment_not_owned` | `422` | | TikTok deletes only comments the account itself wrote. Hide other people's comments instead. | | `not_supported` | `422` | | The platform cannot do this: liking a comment outside TikTok, or `cursor` paging outside TikTok and YouTube. | ### Upstream | Name | Type | Required | Description | | ------------------------- | ----- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider_error` | `502` | | The provider request failed. The upstream body is deliberately not passed through. | | `rate_limited` | `429` | | TikTok is throttling this account (40 requests a minute per endpoint) or the app, or YouTube is throttling per-second. Retry with backoff. | | `quota_exhausted` | `429` | | YouTube's daily API quota, shared by every Adeli customer, is used up. The message says when it resets, at midnight Pacific; retrying before then fails. | | `provider_unavailable` | `502` | | Every attempted provider failed for mixed reasons. `details` carries the per-account errors. | | `temporarily_unavailable` | `503` | | X is unavailable to Adeli for a while. Retry later. | | `database_error` | `502` | | Adeli could not read or write its own store. Safe to retry. | | `result_limit` | `—` | | Appears inside an aggregate `errors` array when a collection was truncated at 10,000 records, which makes the response `207`. | ## Handling errors Branch on `code`, not on `message`, and treat `207` as its own case. **JavaScript** ```javascript const response = await fetch(`${BASE}/accounts?profileId=${profileId}`, { headers }); const body = await response.json(); if (!response.ok) { switch (body.error.code) { case "unauthorized": throw new Error("Adeli key is invalid or revoked"); case "connection_expired": return promptCustomerToReconnect(); default: throw new Error(`${body.error.code}: ${body.error.message}`); } } // 200 and 207 both land here. if (body.status === "partial") { console.warn("Incomplete result", body.errors); } return body.accounts; ``` **Python** ```python response = requests.get(f"{BASE}/accounts", headers=HEADERS, params={"profileId": profile_id}) body = response.json() if not response.ok: code = body["error"]["code"] if code == "unauthorized": raise RuntimeError("Adeli key is invalid or revoked") if code == "connection_expired": return prompt_customer_to_reconnect() raise RuntimeError(f"{code}: {body['error']['message']}") # 200 and 207 both land here. if body["status"] == "partial": logging.warning("Incomplete result: %s", body["errors"]) return body["accounts"] ``` Retry `502`, `429`, and `database_error` with backoff. Do not retry `4xx` — the request will fail the same way — and be careful retrying a publish that timed out, since [there is no idempotency key](https://www.tryadeli.com/docs/concepts#idempotency-and-retries). --- # Pricing and billing ## Pricing URL: https://www.tryadeli.com/docs/pricing Markdown: https://www.tryadeli.com/docs/pricing.md What Adeli costs: three free connected accounts, then a graduated monthly price per account, with worked examples. # Pricing > What Adeli costs: three free connected accounts, then a graduated monthly price per account, with worked examples. Canonical: Adeli charges for connected social accounts, per month, and nothing else. Your first three are free. Posts, scheduling, analytics, API calls, and retries cost nothing extra. To see what your workspace would pay, try the [pricing calculator](https://tryadeli.com/pricing). ## What counts as an account One Instagram account, one Facebook Page, one TikTok account, one YouTube channel, one WhatsApp number, or one X account is one connected account. Accounts are counted across your **whole workspace**, every profile together. An agency with ten client profiles of three accounts each has thirty connected accounts. Only fully connected accounts count. An account that needs reconnecting, or a setup that never finished, is free until it works again. ## The price ladder Pricing is graduated. Each band's price applies only to the accounts inside that band. Crossing into a cheaper band never reprices the accounts below it. | Connected accounts | Price per account, per month | Monthly total at the top of the band | | ------------------ | ---------------------------- | ------------------------------------ | | 1–3 | Free | $0 at 3 accounts | | 4–10 | $5 | $35 at 10 accounts | | 11–50 | $3 | $155 at 50 accounts | | 51–100 | $2 | $255 at 100 accounts | | 101 and up | $1 | — | Prices are in US dollars and exclude sales tax. See [Billing](https://www.tryadeli.com/docs/billing#tax). ## Worked examples These assume every account is connected for the whole month. For other numbers, use the [pricing calculator](https://tryadeli.com/pricing). | Accounts | Calculation | Per month | | -------- | ----------------------------------- | --------- | | 10 | 3 free + 7 × $5 | **$35** | | 11 | 3 free + 7 × $5 + 1 × $3 | **$38** | | 50 | 3 free + 7 × $5 + 40 × $3 | **$155** | | 100 | 3 free + 7 × $5 + 40 × $3 + 50 × $2 | **$255** | An account connected for part of the month costs only that part. See [Billing](https://www.tryadeli.com/docs/billing#accounts-connected-for-part-of-a-month). ## What every account includes - Unlimited posts and scheduling - Post and account analytics - Comments and messages, where the provider supports them - Full API and MCP access > **Note: X API usage is the one extra** > > X charges for each API call. Adeli passes those calls on at X's own prices, > with no markup. See [X API pricing](https://www.tryadeli.com/docs/pricing/x). An X account also counts as a > connected account, like any other. ## X API pricing URL: https://www.tryadeli.com/docs/pricing/x Markdown: https://www.tryadeli.com/docs/pricing/x.md What X API calls cost, passed through at X's prices with no markup, and the endpoint that reports this month's total. # X API pricing > What X API calls cost, passed through at X's prices with no markup, and the endpoint that reports this month's total. Canonical: X bills its API per call. Every X account connects through Adeli's one X app, so Adeli pays X for each call and passes the cost on to you **at X's published prices, with no markup**. Adeli records each call against your account as it happens, and the endpoint below reports the running total. The other providers do not bill per call, so this page covers X only. > **Warning: X needs a card on file** > > X needs a card on file, even within your [free accounts](https://www.tryadeli.com/docs/billing#first-3-accounts-free). > Until you add one, connecting an X account returns `402 x_billing_required`, > and so does every call that would cost money. Add a card on the dashboard's > Accounts or Billing page. ## How you're charged X usage is charged on the same invoices as your connected accounts, as an "X API usage" line, and it counts toward the same [charge thresholds](https://www.tryadeli.com/docs/billing#when-your-card-is-charged): $6 of X usage and $4 of accounts reach the first $10 charge together. Invoices round the line to the cent. Usage from before you added a card is never charged. The dashboard's Billing page shows this month's X usage to a tenth of a cent, straight from our records. It reaches the upcoming invoice within a few minutes. ## What costs money Adeli records a call only when X accepts it, so a request X rejects is free. | Name | Type | Required | Description | | ----------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `post.create` | `$0.015` | | Publishing a post, or each post in a thread. | | `post.create_url` | `$0.200` | | Publishing a post whose text contains a link, including a bare domain such as example.com. | | `post.delete` | `$0.005` | | Deleting a post. | | `post.read_owned` | `$0.001` | | Each post returned when you list an X account's posts. Listing posts includes X only when you ask for `platform=x` or an X `accountId`. | | `user.read_owned` | `$0.001` | | Reading the account's profile, which happens once when it connects. | | `media.upload` | `$0.000` | | Uploading an image or video. X does not list a price; it is recorded so totals can be checked against X's own. | If the same resource is read twice in one UTC day, X charges once, and so does Adeli. Prices are those on X's pricing page on the date in `ratesAsOf`. ## `GET /api/v1/usage/x` — Get X usage Returns your organization's X usage for the current UTC calendar month, across every profile and account. Costs are integers in millionths of a dollar, so they add up exactly: `230000` is $0.23. ```bash curl --fail-with-body "$ADELI_URL/api/v1/usage/x" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` ```json { "from": "2026-10-01T00:00:00.000Z", "to": "2026-11-01T00:00:00.000Z", "ratesAsOf": "2026-10-06", "totalCostMicros": 230000, "operations": [ { "operation": "post.create", "resourceType": "post", "resourceCount": 2, "costMicros": 30000 }, { "operation": "post.create_url", "resourceType": "post", "resourceCount": 1, "costMicros": 200000 } ] } ``` **Errors** — `401 unauthorized`, `502 database_error`. ## Billing URL: https://www.tryadeli.com/docs/billing Markdown: https://www.tryadeli.com/docs/billing.md When your card is charged, accounts connected for part of a month, failed payments, stopping charges, tax, and the 402 billing_required a connect returns past the free accounts. # Billing > When your card is charged, accounts connected for part of a month, failed payments, stopping charges, tax, and the 402 billing\_required a connect returns past the free accounts. Canonical: What you're charged, and when. For the prices themselves, see [Pricing](https://www.tryadeli.com/docs/pricing). ## First 3 accounts free Your first three connected accounts are free, and you don't need a card for them. Connecting a fourth asks for a card first, through Stripe Checkout. In the dashboard you'll see a short dialog. In the API, you get a [`402 billing_required`](https://www.tryadeli.com/docs/billing#in-the-api). ## Accounts connected for part of a month You pay for each account only for the days it's connected. A full month counts as one account, half a month as half. At the end of the month we add up how long each account was connected and price the total with the [ladder](https://www.tryadeli.com/docs/pricing#the-price-ladder). **Example.** In a 30-day month you have 10 accounts all month, and an 11th for one week (7 days): | | Counts as | | ----------------------- | ------------------ | | 10 accounts, all month | 10 accounts | | 1 account, 7 of 30 days | 0.23 of an account | | **Total** | **10.23 accounts** | Priced with the ladder: 3 free, 7 × $5 = $35, and 0.23 × $3 = $0.70. The month costs **$35.70**. An account's charge starts the day you connect it and stops the day after you disconnect it. Reconnecting the same account on the same day isn't charged twice. ## When your card is charged We charge your card in small amounts as you go, rather than in one large bill at the end of the month: 1. The first charge happens when this month's unpaid usage reaches $10. 2. After each successful charge, the next one waits for twice as much: $20, then $40, $80 and $160, up to $200 at a time. 3. Whatever is left at the end of the month is charged on the 1st. **Example.** A new workspace connects 10 accounts on the 1st and keeps them all month. That month costs $35, charged as: | When | Charge | Why | | --------------- | ------ | -------------------------------- | | Around the 15th | $10 | Usage reached $10 | | Around the 28th | $20 | Usage reached the next step, $20 | | The 1st | $5 | What was left | The charge step carries over from month to month, so an established workspace gets fewer, larger charges. Every charge has an invoice on the dashboard's Billing page. ## X API usage X charges per API call, and we pass that on at X's prices: see [X API pricing](https://www.tryadeli.com/docs/pricing/x). It appears on the same invoices as an "X API usage" line, and counts toward the same charges above, so X usage and connected accounts together reach $10, then $20, and so on. X needs a card on file even within your free accounts. ## Failed payments If a charge fails, Stripe retries your card for about two weeks, and everything keeps working in the meantime. The dashboard's Billing page shows a warning so you can add a working card. If every retry fails, you can't connect accounts beyond your three free ones until the balance is paid. The accounts you already have keep working. The next charge step starts again at $10. ## Stopping charges You pay only for connected accounts and X usage, so disconnect accounts until you have three or fewer, and stop using X, and your bill drops to $0 from the next day. Your card stays on file, so connecting more later is instant. Removing your only card from the Billing page cancels billing altogether. We charge any usage not yet billed, X stops working, and connecting more than three accounts asks for a card again. Adding a card starts billing again. ## Tax Prices exclude tax. Where we have to collect sales tax, VAT, or GST, we add it to each charge based on your billing address. A business can add its VAT or GST number in its billing details, and where reverse charge applies, we don't add VAT. ## Managing billing The dashboard's Billing page shows: - your upcoming invoice, the next charge threshold, and what you've paid this month - your billing email, business name, name, address, and tax ID, which you can edit. Changes apply to future invoices. - your cards. You can keep up to five and choose the default. - your invoices, each with a link to view it or download the PDF You enter card details on Stripe's pages, so Adeli never sees or stores them. ## In the API Billing affects the API in one place. A workspace past its three free accounts needs a card before it can connect another. Nothing else is limited, there's no cap on the number of accounts, and existing accounts keep working even if a payment fails. ### `402 billing_required` Starting a connection with [`POST /api/v1/profiles/{profileId}/connect`](https://www.tryadeli.com/docs/api/connect/start-connection) returns `402` when the new account would take the workspace past its free accounts and no card is on file. Accounts are counted for your API key's organization, across all of its profiles. The `402` comes back before any `authUrl` exists, so your customer never goes through a provider's consent screen for nothing. ```json { "error": { "code": "billing_required", "message": "The first 3 connected accounts are free. Add a card to connect more.", "details": { "billingUrl": "https://app.tryadeli.com/settings/billing?reason=account_limit&provider=instagram" } } } ``` `details.billingUrl` links to the dashboard's Billing page. The workspace owner adds a card there, and that's you, not your end customer. Once a card is on file, the same request succeeds. Reconnecting or replacing an account that a profile already has connected never returns `402`, because it does not add an account. ### Handling it Check `error.code`, not only the status: **JavaScript** ```javascript const response = await fetch(`${ADELI_URL}/api/v1/profiles/${profileId}/connect`, { method: "POST", headers: { Authorization: `Bearer ${ADELI_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ platform: "instagram", redirectUrl }), }); if (response.status === 402) { const { error } = await response.json(); if (error.code === "billing_required") { // Tell your team (the Adeli workspace owner) to add a card. notifyBillingOwner(error.details.billingUrl); return showMessage("Connecting more accounts is temporarily unavailable."); } } ``` **cURL** ```bash curl -i -X POST "$ADELI_URL/api/v1/profiles/$PROFILE_ID/connect" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"platform":"instagram"}' # HTTP/1.1 402 Payment Required # {"error":{"code":"billing_required", ... "details":{"billingUrl":"..."}}} ``` ### Other 402s `402 x_billing_required` is a separate rule for X, which charges per API call: X needs a card on file even within your free accounts. See [X API pricing](https://www.tryadeli.com/docs/pricing/x). ### MCP The MCP `start_connect` tool wraps the same endpoint, so it returns the same `billing_required` error with the same `billingUrl`. See [MCP tools](https://www.tryadeli.com/docs/mcp/tools). --- # MCP ## MCP server URL: https://www.tryadeli.com/docs/mcp Markdown: https://www.tryadeli.com/docs/mcp.md Connect Claude Code, Cursor, Codex, or any MCP client to Adeli with your existing API key. # MCP server > Connect Claude Code, Cursor, Codex, or any MCP client to Adeli with your existing API key. Canonical: Adeli runs a [Model Context Protocol](https://modelcontextprotocol.io) server, so an AI agent can use the API as typed tools instead of hand-written HTTP requests. The tools are the REST API: each one calls the same endpoint with the same validation, profile scoping, errors, and side effects. Nothing is available over MCP that is not available over REST. ## Endpoint and authentication ```http POST https://app.tryadeli.com/mcp Authorization: Bearer rk_live_... ``` - **Transport** — Streamable HTTP. The server is stateless and answers each request with JSON; there is no session to keep and no event stream to hold open. - **Key** — the same `rk_live_` API key you use for REST, created under [Settings → API keys](https://app.tryadeli.com/settings/api-keys). See [Authentication](https://www.tryadeli.com/docs/authentication). - **Scope** — the key belongs to your organization and reaches every profile in it. Every tool acts on one profile, and nothing defaults: pass `profileId`, or an `accountId`, which names its own profile. `list_profiles` and `create_profile` are the exceptions, acting on the whole organization. A missing or revoked key fails the connection with `401 unauthorized` and a `WWW-Authenticate: Bearer` header, before any tool runs. > **Warning: The key lives in your client's config** > > MCP clients store the header in a local config file. Read the key from an > environment variable where your client supports it, give each machine or > agent its own key, and never commit the file. If one leaks, delete that key > under Settings → API keys — revocation is immediate. ## Connect your client **Claude Code** ```bash claude mcp add --transport http adeli https://app.tryadeli.com/mcp \ --header "Authorization: Bearer $ADELI_API_KEY" ``` **Cursor** ```json // .cursor/mcp.json { "mcpServers": { "adeli": { "url": "https://app.tryadeli.com/mcp", "headers": { "Authorization": "Bearer ${env:ADELI_API_KEY}" } } } } ``` **VS Code** ```json // .vscode/mcp.json { "inputs": [ { "type": "promptString", "id": "adeli-key", "description": "Adeli API key", "password": true } ], "servers": { "adeli": { "type": "http", "url": "https://app.tryadeli.com/mcp", "headers": { "Authorization": "Bearer ${input:adeli-key}" } } } } ``` **Codex** ```toml # ~/.codex/config.toml [mcp_servers.adeli] url = "https://app.tryadeli.com/mcp" bearer_token_env_var = "ADELI_API_KEY" ``` **stdio clients** ```json // Claude Desktop's claude_desktop_config.json, or any client that only runs local servers { "mcpServers": { "adeli": { "command": "npx", "args": ["-y", "mcp-remote", "https://app.tryadeli.com/mcp", "--header", "Authorization:${ADELI_AUTH}"], "env": { "ADELI_AUTH": "Bearer rk_live_..." } } } } ``` Claude Code expands `$ADELI_API_KEY` when you run the command and stores the result in its config. Clients that can only launch local processes reach the server through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which bridges stdio to HTTP. ## Check the connection List the tools with a single request. You should get back the tools described in [MCP tools](https://www.tryadeli.com/docs/mcp/tools). **cURL** ```bash curl https://app.tryadeli.com/mcp \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` To call tools interactively, run `npx @modelcontextprotocol/inspector`, choose **Streamable HTTP**, and add the `Authorization` header. ## How tools behave - **Results are REST bodies.** A tool returns exactly the JSON the matching endpoint returns, as `structuredContent` and as text. - **Partial is not an error.** Reads that span providers can succeed for some accounts and fail for others. That result carries `status: "partial"` and an `errors` list, as described in [Core concepts](https://www.tryadeli.com/docs/concepts), and is not marked as a tool error. - **Errors keep the envelope.** Any `4xx` or `5xx` becomes a tool error whose content is the usual `{ "error": { "code", "message", "details" } }` body. See [Errors](https://www.tryadeli.com/docs/errors) for every code. - **Tools say what they change.** Read-only tools are annotated `readOnlyHint`. `disconnect_account` and `ads_set_status` are annotated `destructiveHint`, so clients that ask before acting will ask. - **Large reads are truncated in text only.** Past about 100 KB, the text copy is cut short and `structuredContent` still holds the whole body. Filter by `platform` or `accountId` to keep responses small. ## What MCP does not do - **No OAuth sign-in.** The server takes an API key, so clients that can only connect through an OAuth flow, such as claude.ai custom connectors, cannot add it directly yet. Use `mcp-remote` from a desktop client instead. - **No multipart uploads.** `create_post` takes the JSON body of `POST /api/v1/posts`. For TikTok and YouTube video, pass a public HTTPS `video.url` rather than base64. Send large files through REST. - **No key management, and no profile deletion.** Create keys in the dashboard. Agents can list and create profiles, but deleting a profile is not a tool. - **WhatsApp sends templates only.** `send_message` sends one of the business's approved WhatsApp templates; free-form WhatsApp replies stay in the dashboard inbox. Connecting a WhatsApp number is done on the dashboard's Accounts page. ## For agents If you are an AI agent reading this page, the essentials are: 1. Call `list_profiles` first, and pick the profile the user means; use `create_profile` for a new customer. Nothing defaults to a profile. 2. Call `list_accounts` with that `profileId`. Every account-level tool takes an `accountId` from it, which names its profile, so `profileId` can then be left out. Every ads tool takes an `adAccountId` from `ads_list_accounts`. 3. Ask the user before calling `create_post`, `send_message`, `disconnect_account`, `ads_create_campaign`, `ads_create_account`, `ads_update_account`, or `ads_delete_campaign`. These act on a real customer's real accounts, and an ad account, once created, cannot be deleted. 4. **Never** call `ads_set_status` with `ACTIVE` unless the user has confirmed the budget and dates in this conversation. It starts spending money. 5. Publishing to TikTok and YouTube, and Facebook Reels and videos, is asynchronous: poll the matching status tool with the returned `publishId`. Ask the user for a YouTube video's visibility and whether it is made for kids; never choose either for them. 6. For anything not covered here, read the `adeli://docs/llms.txt` resource. It is this entire documentation site as plain text. ## MCP tools URL: https://www.tryadeli.com/docs/mcp/tools Markdown: https://www.tryadeli.com/docs/mcp/tools.md Every tool the Adeli MCP server exposes, what it calls, and which ones change or spend. # MCP tools > Every tool the Adeli MCP server exposes, what it calls, and which ones change or spend. Canonical: Each tool wraps one REST endpoint and takes that endpoint's parameters as arguments. Path, query, and body parameters are all flattened into one argument object, except where a body has several shapes: `create_post` and `send_message` take the whole body as a single `post` or `message` argument. Nothing defaults to a profile. Every tool except `list_profiles`, `create_profile` and `get_x_usage` acts on one profile: the profile tools, the account tools and the connect tools take a required `profileId`, and the other tools take an `accountId`, which names its own profile, with `profileId` optional. Pass both and they must agree. Tools marked **changes data** act on the customer's real accounts. The one marked **spends money** can start paid ad delivery. ## Profile ### `list_profiles` List every profile in your key's organization, the default first. Call this first: every other tool acts on one profile, named by `profileId` or by an `accountId` on it. Wraps [`GET /api/v1/profiles`](https://www.tryadeli.com/docs/api/profiles/list-profiles). Read-only. ### `create_profile` Create a profile for a new customer or brand from `name`, `externalId`, and `metadata`. Names and `externalId`s are unique within the organization. Pass `idempotencyKey`, sent as the `Idempotency-Key` header, so a retry returns the same profile instead of a `409 profile_name_conflict`. Wraps [`POST /api/v1/profiles`](https://www.tryadeli.com/docs/api/profiles/create-profile). **Changes data.** ### `update_profile` Replace the `name`, `externalId`, and `metadata` of the profile named by `profileId`. This is a full replace: omitted fields are cleared. Wraps [`PUT /api/v1/profiles/{profileId}`](https://www.tryadeli.com/docs/api/profiles/update-profile). **Changes data.** ## Accounts and connecting ### `list_accounts` List the connected Instagram, Facebook, TikTok, YouTube, X, and Bluesky accounts, optionally filtered by `platform` or `accountId`. Pass the `profileId` from `list_profiles`. Call this before any account-level tool. Wraps [`GET /api/v1/accounts`](https://www.tryadeli.com/docs/api/accounts/list-accounts). Read-only. ### `get_account` Return one account by `profileId` and `accountId`. Wraps [`GET /api/v1/profiles/{profileId}/accounts/{accountId}`](https://www.tryadeli.com/docs/api/accounts/get-account). Read-only. ### `refresh_accounts` Refresh the provider tokens of every account on the profile. Safe to repeat. Wraps [`POST /api/v1/profiles/{profileId}/accounts/refresh`](https://www.tryadeli.com/docs/api/accounts/refresh-tokens). ### `disconnect_account` Remove Adeli's credentials and cached history for one account. The account itself is not deleted on the provider, but reconnecting needs its owner to authorize again. Wraps [`DELETE /api/v1/profiles/{profileId}/accounts/{accountId}`](https://www.tryadeli.com/docs/api/accounts/disconnect-account). **Changes data. Destructive.** ### `set_x_capabilities` Turn an X account's paid background reads, `analytics` and `inbox`, on or off. Both are off by default, because every X read is billed; neither syncs anything yet. Wraps [`PATCH /api/v1/profiles/{profileId}/accounts/{accountId}`](https://www.tryadeli.com/docs/api/accounts/update-x-account). **Changes data.** ### `start_connect` Start connecting an account on `platform` (`instagram`, `facebook`, `tiktok`, `youtube`, or `x`), with an optional `redirectUrl`. X returns `402 x_billing_required` until the workspace has a card on file. Returns an `authUrl` that the account owner opens in a browser. Wraps [`POST /api/v1/profiles/{profileId}/connect`](https://www.tryadeli.com/docs/api/connect/start-connection). Past the workspace's three free accounts with no card on file, it returns `billing_required`, with a `billingUrl` where the workspace owner adds one. See [Billing](https://www.tryadeli.com/docs/billing#in-the-api). ### `get_connect_session` Poll a session from `start_connect` by `connectionSessionId` until it is `connected`, `failed`, or `expired`. Wraps [`GET /api/v1/profiles/{profileId}/connect/{connectionSessionId}`](https://www.tryadeli.com/docs/api/connect/get-connection-session). Read-only. ## Posts ### `list_posts` List recent Instagram, Facebook, TikTok, YouTube, X, and Bluesky posts, optionally filtered by `platform` or `accountId`. X posts are billed reads, so X is listed only when asked for by `platform` or `accountId`. Wraps [`GET /api/v1/posts`](https://www.tryadeli.com/docs/api/posts/list-posts). Read-only. ### `create_post` Publish to Instagram, Facebook, TikTok, YouTube, X, or Bluesky. The `post` argument is exactly the JSON body of [`POST /api/v1/posts`](https://www.tryadeli.com/docs/api/posts), including its `platform` and `accountId`. For TikTok, call `get_tiktok_creator_info` first and prefer a public `video.url` to base64. TikTok videos publish publicly; `post_mode: "MEDIA_UPLOAD"` sends a draft to the creator's inbox instead. For YouTube, `title`, `privacy_status`, and `made_for_kids` are required; ask the user for the last two rather than choosing them. Until Adeli passes YouTube's API audit every upload stays private, which the result reports as `privacyRestricted`. Multipart uploads (`POST /api/v1/posts/youtube`) are REST-only. For X, `text` is up to 280 characters and `thread` adds replies; every post is billed at X's price, and a thread that fails part-way returns the posts that went live. **Changes data. Spends money on X.** ### `get_facebook_post_status` Check an asynchronous Facebook Reel or video publish by `accountId` and `publishId`. Wraps [`GET /api/v1/posts/facebook/status`](https://www.tryadeli.com/docs/api/posts/get-facebook-video-status). Read-only. ### `get_tiktok_creator_info` Return the TikTok creator's current posting options for `accountId`. Wraps [`GET /api/v1/posts/tiktok/creator-info`](https://www.tryadeli.com/docs/api/posts/get-tiktok-creator-info). Read-only. ### `get_tiktok_post_status` Check an asynchronous TikTok publish by `accountId` and `publishId`. The final statuses are `PUBLISH_COMPLETE`, `SEND_TO_USER_INBOX`, and `FAILED`. Wraps [`GET /api/v1/posts/tiktok/status`](https://www.tryadeli.com/docs/api/posts/get-tiktok-publish-status). Read-only. ### `get_youtube_post_status` Check a YouTube upload by `accountId` and `videoId` (the `publishId` `create_post` returned). `status` is `processing`, `published`, or `failed`; `privacyStatus` is what YouTube applied. Wraps [`GET /api/v1/posts/youtube/status`](https://www.tryadeli.com/docs/api/posts/get-youtube-upload-status). Read-only. ### `delete_x_post` Delete a post from an X account by `accountId` and `postId` (its `providerId`). It cannot be undone, and X bills it. Wraps [`DELETE /api/v1/posts/x/{postId}`](https://www.tryadeli.com/docs/api/posts/delete-x-post). **Changes data. Destructive.** ### `delete_bluesky_post` Delete a post from a Bluesky account by `accountId` and `postId` (its record key, or its AT URI). It cannot be undone. Wraps [`DELETE /api/v1/posts/bluesky/{postId}`](https://www.tryadeli.com/docs/api/posts/delete-bluesky-post). **Changes data. Destructive.** ### `get_x_usage` Return what your X API calls have cost this UTC month, by operation. Wraps [`GET /api/v1/usage/x`](https://www.tryadeli.com/docs/pricing/x#get-api-v1-usage-x). Read-only. ## Comments ### `list_comments` The comments on one Instagram, Facebook, TikTok, or YouTube post, with the account's recent posts. Wraps [`GET /api/v1/comments`](https://www.tryadeli.com/docs/api/comments/list-comments). Read-only. ### `reply_to_comment` Reply to a comment, or comment on one of the account's own posts (Facebook, TikTok, and YouTube). Each YouTube comment write spends 50 units of a daily quota shared by every Adeli customer. Wraps [`POST /api/v1/comments`](https://www.tryadeli.com/docs/api/comments/create-comment). **Changes data.** ### `update_comment` Hide or unhide a comment, or like or unlike one on TikTok. On YouTube, hiding holds the comment for review. Wraps [`PATCH /api/v1/comments/{commentId}`](https://www.tryadeli.com/docs/api/comments/update-comment). **Changes data.** ### `delete_comment` Delete a comment permanently. TikTok deletes only the account's own comments. YouTube deletes the channel's own and rejects anyone else's; `banAuthor` also bans that commenter from the channel. Wraps [`DELETE /api/v1/comments/{commentId}`](https://www.tryadeli.com/docs/api/comments/delete-comment). **Changes data.** ## Messages ### `list_messages` Read Instagram and Facebook direct message history, optionally filtered by `platform` or `accountId`. Wraps [`GET /api/v1/messages`](https://www.tryadeli.com/docs/api/messages/list-messages). Read-only. ### `send_message` Send a text direct message. The `message` argument is exactly the JSON body of [`POST /api/v1/messages`](https://www.tryadeli.com/docs/api/messages/send-message). **Changes data.** ## Analytics ### `get_instagram_insights` Instagram account insights for `accountId`. Wraps [`GET /api/v1/analytics/instagram/account-insights`](https://www.tryadeli.com/docs/api/analytics/instagram-account-insights). Read-only. ### `get_facebook_page_metrics` Facebook Page follower count, recent engagement, and 28-day Page Insights when the connection has granted `read_insights`. Without `accountId`, pass `profileId` to use that profile's Facebook connection. Wraps [`GET /api/v1/analytics/facebook/page-metrics`](https://www.tryadeli.com/docs/api/analytics/facebook-page-metrics). Read-only. ### `get_tiktok_analytics` TikTok profile and aggregate statistics for `accountId`. Wraps [`GET /api/v1/analytics/tiktok/account-analytics`](https://www.tryadeli.com/docs/api/analytics/tiktok-account-analytics). Read-only. ### `get_tiktok_account_insights` Daily TikTok account metrics and follower demographics for a UTC date window. Wraps [`GET /api/v1/analytics/tiktok/account-insights`](https://www.tryadeli.com/docs/api/analytics/tiktok-account-insights). Read-only. ### `get_tiktok_video_insights` Counters and watch metrics for up to 20 TikTok posts. Wraps [`GET /api/v1/analytics/tiktok/video-insights`](https://www.tryadeli.com/docs/api/analytics/tiktok-post-insights). Read-only. ### `get_youtube_channel_insights` Daily YouTube views, watch time, and subscribers, the top ten videos, and viewer demographics for a UTC date window. Needs the connection to have granted analytics. Wraps [`GET /api/v1/analytics/youtube/channel-insights`](https://www.tryadeli.com/docs/api/analytics/youtube-channel-insights). Read-only. ### `get_youtube_video_insights` Views, watch time, and average view percentage for up to 50 YouTube videos. Wraps [`GET /api/v1/analytics/youtube/video-insights`](https://www.tryadeli.com/docs/api/analytics/youtube-video-insights). Read-only. ## Ads Every ads tool takes an optional `accountId` for the Facebook connection. Without it, pass `profileId`, and the tool uses that profile's Facebook account. ### `ads_list_accounts` List the ad accounts the Facebook connection can reach. Wraps [`GET /api/v1/ads/accounts`](https://www.tryadeli.com/docs/api/ads/list-ad-accounts). Read-only. ### `ads_list_businesses` The Business portfolios the Facebook connection administers — where `ads_create_account` can create one. Wraps [`GET /api/v1/ads/businesses`](https://www.tryadeli.com/docs/api/ads/list-businesses). Read-only. ### `ads_create_account` Create an ad account in a portfolio from `ads_list_businesses`. Takes the body of [`POST /api/v1/ads/accounts`](https://www.tryadeli.com/docs/api/ads/create-ad-account) plus a required `idempotencyKey`, sent as the `Idempotency-Key` header; retrying with the same key returns the first result. **Changes data, permanently:** Meta has no API to close an ad account, and its currency and time zone are fixed. ### `ads_get_account` One ad account's status, amount spent, spending limit, and whether a payment method is on file. Wraps [`GET /api/v1/ads/accounts/{adAccountId}`](https://www.tryadeli.com/docs/api/ads/get-ad-account). Read-only. ### `ads_update_account` Rename an ad account, or set or remove (`spendCap: null`) its spending limit. Wraps [`PATCH /api/v1/ads/accounts/{adAccountId}`](https://www.tryadeli.com/docs/api/ads/update-ad-account). **Changes data.** ### `ads_get_tree` Campaigns, ad sets, and ads for `adAccountId`, with metrics for an optional `since`–`until` range. Wraps [`GET /api/v1/ads/tree`](https://www.tryadeli.com/docs/api/ads/get-campaign-tree). Read-only. ### `ads_get_insights` Daily performance for `adAccountId`, or for one `objectId` within it. Wraps [`GET /api/v1/ads/insights`](https://www.tryadeli.com/docs/api/ads/get-insights). Read-only. ### `ads_list_leads` The Page's lead forms, or with `formId`, that form's submissions. Wraps [`GET /api/v1/ads/leads`](https://www.tryadeli.com/docs/api/ads/list-leads). Read-only. ### `ads_create_campaign` Create a campaign, ad set, and ad that promote a Page post, all **paused**. Takes the body of [`POST /api/v1/ads/campaigns`](https://www.tryadeli.com/docs/api/ads/create-campaign) plus a required `idempotencyKey`, which is sent as the `Idempotency-Key` header. Retrying with the same key returns the first result instead of creating a second campaign. **Changes data.** ### `ads_delete_campaign` Delete a campaign with its ad sets and ads. Wraps [`DELETE /api/v1/ads/campaigns/{campaignId}`](https://www.tryadeli.com/docs/api/ads/delete-campaign). **Destructive and irreversible.** ### `ads_set_status` Set an ad or ad set to `ACTIVE` or `PAUSED`. Wraps [`PUT /api/v1/ads/status`](https://www.tryadeli.com/docs/api/ads/update-status). **Spends money** when set to `ACTIVE`. ## Not exposed as tools - `DELETE /api/v1/profiles/{profileId}` — deletes the profile and every connection on it. Too destructive for one tool call; use the dashboard. - `GET /api/v1/profiles/{profileId}` and `GET /api/v1/profiles/{profileId}/accounts` — the same data as `list_profiles` and `list_accounts`. - `POST /api/v1/posts/youtube` — a multipart file upload. `create_post` takes the same YouTube post as JSON with a video URL or base64. --- # API Reference ## API Reference URL: https://www.tryadeli.com/docs/api Markdown: https://www.tryadeli.com/docs/api.md The base URL, authentication, the error envelope, the OpenAPI document, and every resource the API exposes. # API Reference > The base URL, authentication, the error envelope, the OpenAPI document, and every resource the API exposes. Canonical: Every endpoint in the Adeli REST API has its own page here: what it does, the parameters it takes, a request you can copy in cURL, JavaScript, or Python, and the responses it returns. For how the API fits together, start with the [Overview](https://www.tryadeli.com/docs). ## Base URL All endpoints live under `/api/v1`, and every response is JSON. ```bash https://app.tryadeli.com/api/v1 ``` ## Authentication Every request carries your API key as a bearer token. Keys are created in the dashboard and belong to your organization; see [Authentication](https://www.tryadeli.com/docs/authentication). ```http Authorization: Bearer rk_live_... ``` The examples on these pages read the key from an environment variable: ```bash export ADELI_API_KEY='rk_live_...' ``` ## Errors Every failure returns the same envelope, with a stable `code` to branch on and a `message` for humans. [Errors](https://www.tryadeli.com/docs/errors) lists every status and code. ```json { "error": { "code": "profile_not_found", "message": "Profile not found" } } ``` ## OpenAPI The whole API is described as an OpenAPI 3.1 document at [`/docs/openapi.json`](https://www.tryadeli.com/docs/openapi.json). Import it into Postman or Insomnia, or generate a client from it. ## Resources - [Profiles](https://www.tryadeli.com/docs/api/profiles.md) — List, create, read, update, and delete the profiles in your organization. (5 endpoints) - [Connect](https://www.tryadeli.com/docs/api/connect.md) — Run your customer through Instagram, Facebook, TikTok, YouTube, X, or Bluesky authorization without an Adeli login. (2 endpoints) - [Accounts](https://www.tryadeli.com/docs/api/accounts.md) — List connected accounts, refresh their tokens, and disconnect them. (6 endpoints) - [Posts](https://www.tryadeli.com/docs/api/posts.md) — List Instagram, Facebook, TikTok, YouTube, X, and Bluesky posts, and publish to all six. (14 endpoints) - [Comments](https://www.tryadeli.com/docs/api/comments.md) — Read, reply to, hide, like, and delete comments on Instagram, Facebook, TikTok, and YouTube posts. (4 endpoints) - [Messages](https://www.tryadeli.com/docs/api/messages.md) — Read Instagram and Facebook message history, and send text replies to both. (2 endpoints) - [Analytics](https://www.tryadeli.com/docs/api/analytics.md) — Instagram account insights, Facebook Page metrics, TikTok profile statistics, account insights, and post insights, and YouTube channel and video insights. (7 endpoints) - [Ads](https://www.tryadeli.com/docs/api/ads.md) — Facebook ad accounts, campaign performance, creating campaigns from Page posts, and lead form submissions. (11 endpoints) --- # Profiles ## Profiles URL: https://www.tryadeli.com/docs/api/profiles Markdown: https://www.tryadeli.com/docs/api/profiles.md List, create, read, update, and delete the profiles in your organization. # Profiles > List, create, read, update, and delete the profiles in your organization. Canonical: A profile represents one of your end customers or brands. Your API key belongs to your organization and can act on every profile in it, so every other endpoint names the profile it acts on, with a `profileId` or with an `accountId` that belongs to it. See [Core concepts](https://www.tryadeli.com/docs/concepts#profiles) for the model. The endpoints here list and create profiles. They are the only ones, with `GET /api/v1/usage/x`, that act on the whole organization and take no profile. ## Endpoints - `GET /api/v1/profiles` — [List profiles](https://www.tryadeli.com/docs/api/profiles/list-profiles.md) - `POST /api/v1/profiles` — [Create profile](https://www.tryadeli.com/docs/api/profiles/create-profile.md) - `GET /api/v1/profiles/{profileId}` — [Get profile](https://www.tryadeli.com/docs/api/profiles/get-profile.md) - `PUT /api/v1/profiles/{profileId}` — [Update profile](https://www.tryadeli.com/docs/api/profiles/update-profile.md) - `DELETE /api/v1/profiles/{profileId}` — [Delete profile](https://www.tryadeli.com/docs/api/profiles/delete-profile.md) ## List profiles URL: https://www.tryadeli.com/docs/api/profiles/list-profiles Markdown: https://www.tryadeli.com/docs/api/profiles/list-profiles.md Every profile in your organization, the default profile first. # List profiles > Every profile in your organization, the default profile first. Canonical: `GET https://app.tryadeli.com/api/v1/profiles` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns every profile in your organization, the default profile first. Call it to find the `profileId` for a customer, or match on the `externalId` you set. This endpoint, [Create profile](https://www.tryadeli.com/docs/api/profiles/create-profile), and `GET /api/v1/usage/x` are the only ones that act on the whole organization and take no profile. **Errors** — `401 unauthorized`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/profiles" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/profiles", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "profiles": [ { "id": "00000000-0000-4000-8000-000000000001", "name": "Acme Agency", "externalId": null, "metadata": {}, "isDefault": true, "createdAt": "2026-09-08T20:00:00.000Z", "updatedAt": "2026-09-08T20:00:00.000Z" }, { "id": "00000000-0000-4000-8000-000000000002", "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro" }, "isDefault": false, "createdAt": "2026-09-09T14:30:00.000Z", "updatedAt": "2026-09-09T14:30:00.000Z" } ] } ``` ### 401 ```json { "error": { "code": "unauthorized", "message": "A valid Bearer API key is required" } } ``` ## Create profile URL: https://www.tryadeli.com/docs/api/profiles/create-profile Markdown: https://www.tryadeli.com/docs/api/profiles/create-profile.md Create a profile for one of your customers, idempotently if you pass a key. # Create profile > Create a profile for one of your customers, idempotently if you pass a key. Canonical: `POST https://app.tryadeli.com/api/v1/profiles` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Creates a profile in your organization and returns it with `201`. Create one per customer when they sign up in your app, and store its `id`. **Headers** | Name | Type | Required | Description | | ----------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Idempotency-Key` | `string` | | Optional. Any unique string, such as your own customer id. Repeating it with the same body returns the profile it created instead of a conflict; repeating it with a different body returns 409 idempotency\_conflict. | **Body** | Name | Type | Required | Description | | ------------ | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Yes | 1–200 characters after trimming. Must be unique across your organization's profiles. | | `externalId` | `string \| null` | | Your own id for this customer, up to 200 characters. Must be unique across your organization's profiles when not null. | | `metadata` | `object \| null` | | Any JSON object, up to 16 KiB encoded as UTF-8. Arrays and scalars are rejected. | A name or `externalId` that is already taken returns `409` with the existing profile's id in `details.existingProfileId`, so you can use that profile instead. **Errors** — `401 unauthorized`, `400 invalid_request`, `400 invalid_profile`, `409 profile_name_conflict`, `409 profile_external_id_conflict`, `409 idempotency_conflict`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/profiles" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: customer-123" \ -d '{ "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro" } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "customer-123", }, body: JSON.stringify({ name: "Client A", externalId: "customer-123", metadata: { plan: "pro", }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/profiles", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}", "Idempotency-Key": "customer-123"}, json={ "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro", }, }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "00000000-0000-4000-8000-000000000002", "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro" }, "isDefault": false, "createdAt": "2026-09-09T14:30:00.000Z", "updatedAt": "2026-09-09T14:30:00.000Z" } ``` ### 409 ```json { "error": { "code": "profile_name_conflict", "message": "A profile with this name already exists", "details": { "existingProfileId": "00000000-0000-4000-8000-000000000009" } } } ``` ## Get profile URL: https://www.tryadeli.com/docs/api/profiles/get-profile Markdown: https://www.tryadeli.com/docs/api/profiles/get-profile.md One profile, as a bare object. # Get profile > One profile, as a bare object. Canonical: `GET https://app.tryadeli.com/api/v1/profiles/{profileId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns the profile as a bare object — not wrapped in `profiles`. **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000002" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000002", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000002", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-4000-8000-000000000002", "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro" }, "isDefault": false, "createdAt": "2026-09-09T14:30:00.000Z", "updatedAt": "2026-09-09T14:30:00.000Z" } ``` ### 404 ```json { "error": { "code": "profile_not_found", "message": "Profile not found" } } ``` ## Update profile URL: https://www.tryadeli.com/docs/api/profiles/update-profile Markdown: https://www.tryadeli.com/docs/api/profiles/update-profile.md Replace a profile's name, externalId, and metadata. # Update profile > Replace a profile's name, externalId, and metadata. Canonical: `PUT https://app.tryadeli.com/api/v1/profiles/{profileId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Replaces the mutable fields. This is a full replace, not a merge: omitting `externalId` or `metadata` clears them. **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | **Body** | Name | Type | Required | Description | | ------------ | ---------------- | -------- | --------------------------------------------------------------------------------------- | | `name` | `string` | Yes | 1–200 characters after trimming. Must be unique across your organization's profiles. | | `externalId` | `string \| null` | | Up to 200 characters. Must be unique across your organization's profiles when not null. | | `metadata` | `object \| null` | | Any JSON object, up to 16 KiB encoded as UTF-8. Arrays and scalars are rejected. | Both conflict responses carry the colliding profile in `details.existingProfileId`, as they do on [create](https://www.tryadeli.com/docs/api/profiles/create-profile). **Errors** — `401 unauthorized`, `400 invalid_request`, `400 invalid_profile`, `404 profile_not_found`, `409 profile_name_conflict`, `409 profile_external_id_conflict`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X PUT "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000002" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro" } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000002", { method: "PUT", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Client A", externalId: "customer-123", metadata: { plan: "pro", }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.put( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000002", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro", }, }, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-4000-8000-000000000002", "name": "Client A", "externalId": "customer-123", "metadata": { "plan": "pro" }, "isDefault": false, "createdAt": "2026-09-09T14:30:00.000Z", "updatedAt": "2026-10-09T16:00:00.000Z" } ``` ### 409 ```json { "error": { "code": "profile_external_id_conflict", "message": "A profile with this externalId already exists", "details": { "existingProfileId": "00000000-0000-4000-8000-000000000009" } } } ``` ## Delete profile URL: https://www.tryadeli.com/docs/api/profiles/delete-profile Markdown: https://www.tryadeli.com/docs/api/profiles/delete-profile.md Delete a profile and everything connected to it. Cannot be undone. # Delete profile > Delete a profile and everything connected to it. Cannot be undone. Canonical: `DELETE https://app.tryadeli.com/api/v1/profiles/{profileId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) > **Danger: This cascades and cannot be undone** > > Deleting a profile removes its connected accounts, their cached history, and > its connection sessions. Your API keys belong to the organization and keep > working. It does not delete anything on Instagram, Facebook, TikTok, YouTube, X, or Bluesky. **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X DELETE "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.delete( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-4000-8000-000000000001", "deleted": true } ``` ### 404 ```json { "error": { "code": "profile_not_found", "message": "Profile not found" } } ``` --- # Connect ## Connect URL: https://www.tryadeli.com/docs/api/connect Markdown: https://www.tryadeli.com/docs/api/connect.md Run your customer through Instagram, Facebook, TikTok, YouTube, X, or Bluesky authorization without an Adeli login. # Connect > Run your customer through Instagram, Facebook, TikTok, YouTube, X, or Bluesky authorization without an Adeli login. Canonical: The connect flow lets your customer authorize their Instagram, Facebook, TikTok, YouTube, X, or Bluesky account from **your** application. They never see an Adeli dashboard, and you never handle a provider token. ## How it works 1. Your server calls [`POST /api/v1/profiles/:profileId/connect`](https://www.tryadeli.com/docs/api/connect/start-connection) and gets back an `authUrl` plus a session id. 2. You send your customer's browser to `authUrl`. They authorize with the provider directly. 3. The provider returns to Adeli, which exchanges the code server-side and stores the token. 4. Adeli sends the browser back to your `redirectUrl` with the session id and status in the query string. 5. Your server [polls the session](https://www.tryadeli.com/docs/api/connect/get-connection-session) until it reaches a terminal state. > **Warning: Open authUrl in the customer's browser** > > Do not fetch it from your backend. It is an authorization page that requires a > human to grant consent; requesting it server-side will not produce a > connection. The session is valid for **ten minutes** and its OAuth state can be consumed once. A callback that arrives late, twice, or with a tampered state is rejected without re-exchanging anything with the provider. For the full internal sequence, see the repository's `docs/nested-oauth-connect-flow.md`. ## Endpoints - `POST /api/v1/profiles/{profileId}/connect` — [Start connection](https://www.tryadeli.com/docs/api/connect/start-connection.md) - `GET /api/v1/profiles/{profileId}/connect/{connectionSessionId}` — [Get connection session](https://www.tryadeli.com/docs/api/connect/get-connection-session.md) ## The browser return When you supply a `redirectUrl`, Adeli sends the browser back with these query parameters: | Name | Type | Required | Description | | --------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `connectionSessionId` | `uuid` | | The session to poll. | | `status` | `string` | | The status at redirect time. | | `accountId` | `uuid` | | Present only on success. | | `workflowId` | `string` | | An internal hint — `connecting-accounts` for Instagram and Facebook, `connecting-tiktok` for TikTok, `connecting-youtube` for YouTube. | > **Note: Poll, do not trust the query string** > > These parameters are convenient for rendering an immediate result in your UI, > but they arrive through the customer's browser. Confirm the outcome with a > server-side poll before acting on it. ## Provider requirements **Instagram** needs a professional (Business or Creator) account. Adeli requests `instagram_business_basic`, `instagram_business_manage_comments`, `instagram_business_manage_insights`, `instagram_business_manage_messages`, and `instagram_business_content_publish`. ### Instagram login methods An Instagram professional account can be connected two ways, and a profile's Instagram connection uses exactly one of them: - **`instagram_login`** (the default) — your customer signs in on Instagram. Adeli requests the `instagram_business_*` permissions above. - **`facebook_login`** — your customer signs in with Facebook and grants the Facebook Page their Instagram account is linked to. Adeli requests `instagram_basic`, `instagram_content_publish`, `instagram_manage_comments`, `instagram_manage_insights`, `instagram_manage_messages`, `pages_show_list`, `pages_read_engagement`, `pages_manage_metadata`, and `business_management`. They must be able to manage messages on that Page. Both produce the same account: posting, comments, messages, and insights work identically, and [Accounts](https://www.tryadeli.com/docs/api/accounts) reports which method it uses. Starting a connect with the method the profile's Instagram does **not** use returns `409 auth_method_conflict` before your customer reaches Meta. To switch, disconnect the account first. A Facebook Login session that cannot finish fails with one of these `error.code` values: | Name | Type | Required | Description | | ----------------------------- | ---- | -------- | ------------------------------------------------------------------------------------- | | `no_instagram_account` | | | None of the Pages your customer granted has an Instagram professional account linked. | | `multiple_instagram_accounts` | | | More than one was granted. Start again and choose only one. | | `missing_permissions` | | | Facebook did not grant a permission the connection needs. | | `page_messaging_task_missing` | | | Your customer's role on the Page cannot manage messages. | | `auth_method_conflict` | | | The profile was connected with Instagram Login while this session was open. | **Facebook** connects one Page. Adeli uses Facebook Login for Business and requests `public_profile`, `pages_show_list`, `pages_read_engagement`, `pages_manage_metadata`, `pages_manage_posts`, `pages_read_user_content`, `pages_manage_engagement`, and `pages_messaging`. Facebook is the one provider whose connection is not finished when the authorization callback returns. Authorizing yields a _user_ token, which lists the Pages your customer manages; a Page still has to be chosen before anything can be published or read. Until it is, the session stays `processing` and the redirect carries `status=pending_page_selection`. The account exists at that point and appears in [Accounts](https://www.tryadeli.com/docs/api/accounts) with `connectionStatus: "pending_page_selection"`, but every write against it returns `409 page_not_selected`. Only Pages your customer holds a direct role on are offered. A Page granted to them solely through a business portfolio is not connectable, because Adeli does not request `business_management`. **TikTok** connects through TikTok API for Business and requests `user.info.basic`, `user.info.username`, `user.info.profile`, `user.info.stats`, `user.account.type`, `user.insights`, `video.list`, `video.insights`, `video.publish`, `video.upload`, `comment.list`, `comment.list.manage`, and `biz.spark.auth`. Both personal accounts and TikTok Business Accounts can connect. The account identity and video list scopes — the first five and `video.list` — are required; without them the connection fails outright. The rest are stored as granted, and a call that needs a missing one returns `422 connection_expired` until the customer reconnects and approves it. TikTok always shows its consent screen, even to a customer who authorized before. TikTok connections made before Adeli moved to TikTok API for Business show `reconnect_required` and must be connected again. Reconnecting keeps the same `accountId`. **YouTube** connects one channel through its own Google sign-in, separate from Adeli's dashboard sign-in. Adeli requests `https://www.googleapis.com/auth/youtube.force-ssl`, which covers uploads, comments, and moderation, and `https://www.googleapis.com/auth/yt-analytics.readonly` for [YouTube analytics](https://www.tryadeli.com/docs/api/analytics). Google's account chooser decides which channel is connected: your customer's own, or one of their Brand Account channels. Reconnecting the same channel keeps the same `accountId`; connecting a different channel on the profile replaces it. Google always shows its consent screen. A YouTube session that cannot finish fails with one of these `error.code` values: | Name | Type | Required | Description | | --------------- | ---- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | `missing_scope` | | | Your customer unticked the YouTube permission on Google's consent screen. Nothing is stored. | | `no_channel` | | | The Google account chosen has no YouTube channel. Start again and choose the account or Brand Account that owns the channel. | The analytics scope is optional: a connection without it keeps every other feature, and the analytics endpoints return `422 connection_expired` until the customer reconnects and approves it. Google revokes a grant when your customer removes Adeli from their Google account, and after six months unused; the account then shows `reconnect_required`. **X** connects one account through Adeli's X app with OAuth 2.0. Adeli requests `tweet.read`, `tweet.write`, `users.read`, `media.write`, and `offline.access`, all required: a session where your customer withholds one fails with `missing_scope` and stores nothing. Reconnecting the same X account keeps the same `accountId`; connecting a different one on the profile replaces it. X shows every customer a warning that the app is requesting sensitive permissions and is not affiliated with X. It appears for any app that can post, and cannot be removed, so tell your customers to expect it. Every X call is billed to you at X's price, including the one read of the account's profile that connecting makes. Until the workspace has a card on file, starting a connect returns `402 x_billing_required` before any `authUrl` exists. That applies even within the free accounts. See [X API pricing](https://www.tryadeli.com/docs/pricing/x). Instagram and Facebook both accept partial consent: the connection is stored with whatever was granted, and the calls needing a missing permission return `403 missing_permission`. ### Bluesky **Bluesky** connects one account with AT Protocol OAuth. Your customer signs in on their account's own server: bsky.social for Bluesky-hosted accounts, which is where a connect without `handle` starts. A customer who runs their own server, or uses another provider's, must be connected with `handle`, or they won't find their account at bsky.social. The consent screen names Adeli and asks for broad access to the account, because the permissions Bluesky offers today are not finer-grained than that. Adeli uses it to publish, list, and delete posts. It does not read direct messages. Reconnecting the same account keeps the same `accountId`; connecting a different one on the profile replaces it. Bluesky's API is free: connecting and posting cost nothing beyond the connected account itself. `error.code` values beyond the common ones: | Name | Type | Required | Description | | ---------------- | ---- | -------- | -------------------------------------------------------------------------------------- | | `invalid_handle` | | | The `handle` did not resolve to a Bluesky account. Starting the connect returns `400`. | | `missing_scope` | | | The grant lacked a permission Adeli needs. It was revoked, and nothing is stored. | A customer can revoke Adeli in the Bluesky app's settings at any time; the account then shows `reconnect_required`. ## Disconnecting `DELETE /api/v1/profiles/:profileId/accounts/:accountId` removes Adeli's stored credentials and cached history. See [Accounts](https://www.tryadeli.com/docs/api/accounts/disconnect-account). It never deletes anything on the provider side. ## Start connection URL: https://www.tryadeli.com/docs/api/connect/start-connection Markdown: https://www.tryadeli.com/docs/api/connect/start-connection.md Start a hosted OAuth connection and get the URL to send your customer to. # Start connection > Start a hosted OAuth connection and get the URL to send your customer to. Canonical: `POST https://app.tryadeli.com/api/v1/profiles/{profileId}/connect` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) **Body** | Name | Type | Required | Description | | ------------- | ------------------------------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "tiktok" \| "facebook" \| "youtube" \| "x" \| "bluesky"` | Yes | X returns `402 x_billing_required` until the workspace has a card on file; see [X API pricing](https://www.tryadeli.com/docs/pricing/x). WhatsApp and Google Business Profile are accepted by the schema but return `422 provider_not_supported`. A business connects its WhatsApp number itself, through Embedded Signup on the dashboard's Accounts page. | | `redirectUrl` | `string` | | Where to send the browser when authorization finishes. Its origin must be allowlisted on the server, and it must use HTTPS unless the host is localhost or 127.0.0.1. Omit it to poll only. | | `authMethod` | `"instagram_login" \| "facebook_login"` | | Instagram only, and optional. `instagram_login` (the default) signs in on Instagram; `facebook_login` signs in with Facebook and connects the Instagram account linked to one of your customer's Pages. See [Instagram login methods](https://www.tryadeli.com/docs/api/connect#instagram-login-methods). Sending it with any other platform returns `400 invalid_request`. | | `handle` | `string` | | Bluesky only, and optional: your customer's handle, such as `alice.example.com`, or their DID. The connect then starts at the server their account lives on. Leave it out for accounts hosted on Bluesky itself (`*.bsky.social` and most custom domains), which sign in at bsky.social. See [Bluesky](https://www.tryadeli.com/docs/api/connect#bluesky). Sending it with any other platform returns `400 invalid_request`. | A successful start returns `201`. Instagram sessions also carry the `authMethod` they were started with. **Errors** — `401 unauthorized`, `400 invalid_request`, `400 invalid_redirect_uri`, `404 profile_not_found`, `402 billing_required` (past the workspace's free accounts with no card on file; see [Billing](https://www.tryadeli.com/docs/billing#in-the-api)), `409 auth_method_conflict`, `422 provider_not_supported`, `422 provider_not_configured`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/connect" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "instagram", "redirectUrl": "https://client.example/callback" }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/connect", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "instagram", redirectUrl: "https://client.example/callback", }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/connect", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "instagram", "redirectUrl": "https://client.example/callback", }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "00000000-0000-4000-8000-000000000002", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "authMethod": "instagram_login", "status": "pending_authorization", "accountId": null, "expiresAt": "2026-09-08T20:10:00.000Z", "completedAt": null, "error": null, "authUrl": "https://www.instagram.com/oauth/authorize?..." } ``` ### 400 ```json { "error": { "code": "invalid_redirect_uri", "message": "redirectUrl origin is not allowlisted" } } ``` ## Get connection session URL: https://www.tryadeli.com/docs/api/connect/get-connection-session Markdown: https://www.tryadeli.com/docs/api/connect/get-connection-session.md Poll a connection session until it completes, fails, or expires. # Get connection session > Poll a connection session until it completes, fails, or expires. Canonical: `GET https://app.tryadeli.com/api/v1/profiles/{profileId}/connect/{connectionSessionId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) The same object without `authUrl`. Poll until `status` is terminal. **Session status** | Name | Type | Required | Description | | ----------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `pending_authorization` | | | Waiting for your customer to authorize. Becomes expired ten minutes after creation. | | `processing` | | | The customer authorized and Adeli is exchanging the code with the provider. This is brief, and may finish after expiresAt has passed. | | `connected` | `terminal` | | Done. `accountId` is the connection to use in every later request. | | `failed` | `terminal` | | Authorization was denied or the exchange failed. `error.code` says why. | | `expired` | `terminal` | | Nobody completed authorization in time. Start a new session. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `404 connection_session_not_found`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/connect/00000000-0000-4000-8000-000000000002" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/connect/00000000-0000-4000-8000-000000000002", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/connect/00000000-0000-4000-8000-000000000002", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-4000-8000-000000000002", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "authMethod": "instagram_login", "status": "connected", "accountId": "00000000-0000-0000-0000-000000000000", "expiresAt": "2026-09-08T20:10:00.000Z", "completedAt": "2026-09-08T20:03:11.000Z", "error": null } ``` ### 404 ```json { "error": { "code": "connection_session_not_found", "message": "Connection session not found" } } ``` --- # Accounts ## Accounts URL: https://www.tryadeli.com/docs/api/accounts Markdown: https://www.tryadeli.com/docs/api/accounts.md List connected accounts, refresh their tokens, and disconnect them. # Accounts > List connected accounts, refresh their tokens, and disconnect them. Canonical: An account is one provider connection inside a profile. Its `accountId` is the identifier every other endpoint takes. ## The account object [List accounts](https://www.tryadeli.com/docs/api/accounts/list-accounts) returns these records, and [Get account](https://www.tryadeli.com/docs/api/accounts/get-account) returns one bare. **Account fields** | Name | Type | Required | Description | | ------------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `accountId` | `uuid` | | Use this everywhere. `id` is the same value, kept for clients that expect it. | | `providerId` | `string` | | Instagram's user id, or TikTok's app-scoped open\_id. Informational — do not send it back as an accountId. A TikTok account's open\_id changed once, when it reconnected through TikTok API for Business. For YouTube, the channel id (UC…). For X, the user id, which survives a change of username. For Bluesky, the account's DID (did:plc:…), which survives a change of handle. | | `displayName` | `string` | | For Instagram, "@" plus the username. For TikTok, the profile display name. For YouTube, the channel title. For X, the account name. For Bluesky, the display name, or the handle when there is none. | | `displayIdentifier` | `string` | | The Instagram username, or the TikTok username (its open\_id for a connection that has not reconnected since the move to TikTok API for Business), or the YouTube channel handle, such as @adeli (its channel id when it has none), or the X username, or the Bluesky handle. | | `authMethod` | `"instagram_login" \| "facebook_login"` | | Instagram only: the login the account was connected with. Reconnecting uses the same one; see [Instagram login methods](https://www.tryadeli.com/docs/api/connect#instagram-login-methods). | | `connectionStatus` | `string` | | `connected`; `expired` when an Instagram connection has lapsed or, for Facebook Login, its Page can no longer be reached; `reconnect_required` when TikTok, Facebook, YouTube, X, or Bluesky needs fresh consent; `pending_page_selection` when a Facebook connection exists but no Page has been chosen. | ## Endpoints - `GET /api/v1/accounts` — [List accounts](https://www.tryadeli.com/docs/api/accounts/list-accounts.md) - `GET /api/v1/profiles/{profileId}/accounts` — [List profile accounts](https://www.tryadeli.com/docs/api/accounts/list-profile-accounts.md) - `GET /api/v1/profiles/{profileId}/accounts/{accountId}` — [Get account](https://www.tryadeli.com/docs/api/accounts/get-account.md) - `PATCH /api/v1/profiles/{profileId}/accounts/{accountId}` — [Update X account](https://www.tryadeli.com/docs/api/accounts/update-x-account.md) - `POST /api/v1/profiles/{profileId}/accounts/refresh` — [Refresh tokens](https://www.tryadeli.com/docs/api/accounts/refresh-tokens.md) - `DELETE /api/v1/profiles/{profileId}/accounts/{accountId}` — [Disconnect account](https://www.tryadeli.com/docs/api/accounts/disconnect-account.md) ## List accounts URL: https://www.tryadeli.com/docs/api/accounts/list-accounts Markdown: https://www.tryadeli.com/docs/api/accounts/list-accounts.md Every connected account in one profile. # List accounts > Every connected account in one profile. Canonical: `GET https://app.tryadeli.com/api/v1/accounts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns every connected account in one profile, sorted by platform and then by id. Uses the [aggregate response contract](https://www.tryadeli.com/docs/concepts#partial-responses). Each account has the fields described in [The account object](https://www.tryadeli.com/docs/api/accounts#the-account-object). **Query parameters** | Name | Type | Required | Description | | ----------- | ------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "tiktok" \| "facebook" \| "youtube" \| "x" \| "bluesky"` | | Narrow to one provider. | | `accountId` | `uuid` | | Narrow to one connection. It names its profile, so `profileId` can be left out. An id outside your organization returns `404 account_not_found`. | | `profileId` | `uuid` | | The profile to list. Required unless you pass `accountId`; with neither, the API returns `400 profile_required`. A profile outside your organization returns `404 profile_not_found`. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `404 account_not_found`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/accounts?profileId=00000000-0000-4000-8000-000000000001&platform=instagram" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/accounts?profileId=00000000-0000-4000-8000-000000000001&platform=instagram", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/accounts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "status": "complete", "accounts": [ { "id": "00000000-0000-0000-0000-000000000000", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "providerId": "17841400000000000", "displayName": "@adeli", "displayIdentifier": "adeli", "authMethod": "instagram_login", "connectionStatus": "connected" } ], "errors": [] } ``` ### 400 ```json { "error": { "code": "profile_required", "message": "Pass profileId, or an accountId that belongs to a profile" } } ``` ## List profile accounts URL: https://www.tryadeli.com/docs/api/accounts/list-profile-accounts Markdown: https://www.tryadeli.com/docs/api/accounts/list-profile-accounts.md The same list, scoped by path instead of query. # List profile accounts > The same list, scoped by path instead of query. Canonical: `GET https://app.tryadeli.com/api/v1/profiles/{profileId}/accounts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) The same `accounts` aggregate as [List accounts](https://www.tryadeli.com/docs/api/accounts/list-accounts), scoped by path instead of query. Takes no filters. Useful when your code already has the profile id in hand. **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "status": "complete", "accounts": [ { "id": "00000000-0000-0000-0000-000000000000", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "providerId": "17841400000000000", "displayName": "@adeli", "displayIdentifier": "adeli", "authMethod": "instagram_login", "connectionStatus": "connected" } ], "errors": [] } ``` ### 404 ```json { "error": { "code": "profile_not_found", "message": "Profile not found" } } ``` ## Get account URL: https://www.tryadeli.com/docs/api/accounts/get-account Markdown: https://www.tryadeli.com/docs/api/accounts/get-account.md One connected account, as a bare object. # Get account > One connected account, as a bare object. Canonical: `GET https://app.tryadeli.com/api/v1/profiles/{profileId}/accounts/{accountId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns a **bare** account object — no `status`, no `errors` wrapper. Its fields are described in [The account object](https://www.tryadeli.com/docs/api/accounts#the-account-object). **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | | `accountId` | `uuid` | Yes | A connected account in that profile. Any other value returns 404 account\_not\_found. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `404 account_not_found`, `502 database_error`. An X account adds `xCapabilities`, its paid background reads; see [Update X account](https://www.tryadeli.com/docs/api/accounts/update-x-account). ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-0000-0000-000000000000", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "tiktok", "providerId": "app-scoped-open-id", "displayName": "Creator", "displayIdentifier": "creator", "connectionStatus": "connected" } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "Account not found" } } ``` ## Update X account URL: https://www.tryadeli.com/docs/api/accounts/update-x-account Markdown: https://www.tryadeli.com/docs/api/accounts/update-x-account.md Turn an X account's billed background reads on or off. # Update X account > Turn an X account's billed background reads on or off. Canonical: `PATCH https://app.tryadeli.com/api/v1/profiles/{profileId}/accounts/{accountId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Every X read is billed to you ([X API pricing](https://www.tryadeli.com/docs/pricing/x)), so the reads that would run in the background on an X account are off until you turn them on: `analytics` (post metrics and follower counts) and `inbox` (direct messages). Neither syncs anything yet; the switches are stored so that both features respect them when they arrive. Publishing and deleting always work. **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | | `accountId` | `uuid` | Yes | An X account in that profile. Another platform's account returns 400 unsupported\_platform; any other value returns 404 account\_not\_found. | **Body** | Name | Type | Required | Description | | --------------- | -------- | -------- | ------------------------------------------------------------------------------------------------ | | `xCapabilities` | `object` | Yes | `{ analytics?: boolean, inbox?: boolean }`, at least one. Fields you leave out keep their value. | Returns the account with its updated `xCapabilities`. Turning `inbox` on needs the `dm.read` and `dm.write` permissions, which a connection made today has not granted: that returns `409 reconnect_required` with the missing scopes in `details.missingScopes`. **Errors** — `401 unauthorized`, `400 invalid_request`, `400 unsupported_platform` (not an X account), `404 profile_not_found`, `404 account_not_found`, `409 reconnect_required`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X PATCH "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "xCapabilities": { "analytics": true } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ xCapabilities: { analytics: true, }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.patch( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "xCapabilities": { "analytics": True, }, }, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-0000-0000-000000000000", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "x", "providerId": "1234567890", "displayName": "Adeli", "displayIdentifier": "adeli", "connectionStatus": "connected", "xCapabilities": { "analytics": true, "inbox": false } } ``` ### 409 ```json { "error": { "code": "reconnect_required", "message": "Reconnect this X account to grant dm.read, dm.write", "details": { "missingScopes": ["dm.read", "dm.write"] } } } ``` ## Refresh tokens URL: https://www.tryadeli.com/docs/api/accounts/refresh-tokens Markdown: https://www.tryadeli.com/docs/api/accounts/refresh-tokens.md Refresh the OAuth tokens of every connection in a profile. # Refresh tokens > Refresh the OAuth tokens of every connection in a profile. Canonical: `POST https://app.tryadeli.com/api/v1/profiles/{profileId}/accounts/refresh` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Attempts an OAuth token refresh for every Instagram, TikTok, Facebook, and YouTube connection in the profile. For YouTube it also re-reads the channel's title, avatar, and counts. Takes no body. Each account appears in the response only after its new token and expiry have been written, so a success here means the credential is durable, not merely fetched. > **Warning: This response is not a list of accounts** > > It uses the aggregate envelope with the `accounts` key, but the **elements are > a different shape** — refresh results, not account records. Code that reuses an > account parser here will not find `displayName` or `connectionStatus`. **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | -------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | Per-account error codes: `provider_not_configured`, `connection_expired`, `account_not_found`, `provider_error`. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/refresh" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/refresh", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/refresh", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "status": "partial", "accounts": [ { "id": "00000000-0000-0000-0000-000000000000", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "platform": "instagram", "refreshed": true, "expiresAt": "2026-11-07T12:00:00.000Z" } ], "errors": [ { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000001", "code": "connection_expired", "message": "TikTok requires reconnection" } ] } ``` ### 404 ```json { "error": { "code": "profile_not_found", "message": "Profile not found" } } ``` ## Disconnect account URL: https://www.tryadeli.com/docs/api/accounts/disconnect-account Markdown: https://www.tryadeli.com/docs/api/accounts/disconnect-account.md Delete Adeli's credentials and cached history for one connection. # Disconnect account > Delete Adeli's credentials and cached history for one connection. Canonical: `DELETE https://app.tryadeli.com/api/v1/profiles/{profileId}/accounts/{accountId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Deletes Adeli's stored credentials and cached history for that connection. It never deletes the account, its posts, or its messages on the provider. For TikTok, YouTube, X, and Bluesky, Adeli first tries to revoke the authorization with the provider and then guarantees local deletion either way. X usage already recorded stays on your bill. `revocationFailed: true` means the local credential is gone but the provider may still list the authorization, and the customer can remove it from their TikTok settings or their [Google account permissions](https://security.google.com/settings/security/permissions). **Path parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------------------------------------------------- | | `profileId` | `uuid` | Yes | A profile in your organization. Any other value returns 404 profile\_not\_found. | | `accountId` | `uuid` | Yes | A connected account in that profile. Any other value returns 404 account\_not\_found. | **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `404 account_not_found`, `502 database_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X DELETE "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.delete( "https://app.tryadeli.com/api/v1/profiles/00000000-0000-4000-8000-000000000001/accounts/00000000-0000-0000-0000-000000000000", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "00000000-0000-0000-0000-000000000000", "disconnected": true } ``` ### 200 ```json { "id": "00000000-0000-0000-0000-000000000001", "disconnected": true, "revocationFailed": false } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "Account not found" } } ``` --- # Posts ## Posts URL: https://www.tryadeli.com/docs/api/posts Markdown: https://www.tryadeli.com/docs/api/posts.md List Instagram, Facebook, TikTok, YouTube, X, and Bluesky posts, and publish to all six. # Posts > List Instagram, Facebook, TikTok, YouTube, X, and Bluesky posts, and publish to all six. Canonical: One endpoint publishes to every provider, but they behave differently enough to be worth reading separately. Instagram publishes synchronously and returns `201`, and so do X and Bluesky. TikTok and YouTube publish asynchronously and return `202` with an id you poll. Facebook does **both**, decided by the media: a photo or multi-photo post returns `201` with the finished post, while a Reel or feed video returns `202`. Editing and deleting posts are deliberately not part of the public API in v1, except deleting an [X post](https://www.tryadeli.com/docs/api/posts/delete-x-post) or a [Bluesky post](https://www.tryadeli.com/docs/api/posts/delete-bluesky-post). ## Endpoints - `GET /api/v1/posts` — [List posts](https://www.tryadeli.com/docs/api/posts/list-posts.md) - `POST /api/v1/posts` — [Publish to Instagram](https://www.tryadeli.com/docs/api/posts/publish-instagram.md) - `POST /api/v1/posts` — [Publish to Facebook](https://www.tryadeli.com/docs/api/posts/publish-facebook.md) - `GET /api/v1/posts/facebook/status` — [Facebook video status](https://www.tryadeli.com/docs/api/posts/get-facebook-video-status.md) - `GET /api/v1/posts/tiktok/creator-info` — [TikTok creator info](https://www.tryadeli.com/docs/api/posts/get-tiktok-creator-info.md) - `POST /api/v1/posts` — [Publish to TikTok](https://www.tryadeli.com/docs/api/posts/publish-tiktok.md) - `GET /api/v1/posts/tiktok/status` — [TikTok publish status](https://www.tryadeli.com/docs/api/posts/get-tiktok-publish-status.md) - `POST /api/v1/posts` — [Publish to YouTube](https://www.tryadeli.com/docs/api/posts/publish-youtube.md) - `POST /api/v1/posts/youtube` — [Upload YouTube file](https://www.tryadeli.com/docs/api/posts/upload-youtube-video-file.md) - `GET /api/v1/posts/youtube/status` — [YouTube upload status](https://www.tryadeli.com/docs/api/posts/get-youtube-upload-status.md) - `POST /api/v1/posts` — [Publish to X](https://www.tryadeli.com/docs/api/posts/publish-x.md) - `DELETE /api/v1/posts/x/{postId}` — [Delete X post](https://www.tryadeli.com/docs/api/posts/delete-x-post.md) - `POST /api/v1/posts` — [Publish to Bluesky](https://www.tryadeli.com/docs/api/posts/publish-bluesky.md) - `DELETE /api/v1/posts/bluesky/{postId}` — [Delete Bluesky post](https://www.tryadeli.com/docs/api/posts/delete-bluesky-post.md) ## Media limits | Name | Type | Required | Description | | --------------------------------- | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Instagram image` | `8 MiB` | | Decoded, per image. JPEG, PNG, and WebP in; normalized to JPEG. | | `Instagram carousel` | `2–10 images` | | | | `Facebook photo` | `4 MiB` | | Decoded, per photo — lower than Instagram's limit. Meta rejects anything larger. | | `Facebook multi-photo` | `2–10 photos` | | | | `Facebook video (JSON)` | `64 MiB` | | Decoded from base64, MP4 only. Use multipart for MOV or WebM. | | `TikTok video (JSON)` | `64 MiB` | | Decoded from base64, MP4 only. | | `TikTok video (multipart or URL)` | `1 GB` | | MP4, MOV, or WebM; 3 seconds up to the creator's maximum duration. Remote URLs must be public HTTPS, and are fetched with private-network, redirect, timeout, and size protections. | | `TikTok photo` | `20 MB` | | Per photo, 1–35 per post, normalized to JPEG at 1080×1920. Remote photo URLs are not accepted. | | `YouTube video (URL)` | `1 GB` | | MP4, MOV, or WebM from a public HTTPS URL. | | `YouTube video (multipart)` | `4 GiB` | | MP4, MOV, or WebM, sent to `POST /api/v1/posts/youtube`. | | `YouTube thumbnail` | `50 MB` | | JPEG or PNG; custom thumbnails need a verified channel. | | `X image` | `5 MB` | | JPEG, PNG, or WebP, up to 4 per post. The file's bytes must match its content\_type. | | `X GIF` | `15 MB` | | One per post, alone. | | `X video` | `512 MB` | | MP4 as base64 (within the 96 MiB body limit), or MP4 or MOV from a public HTTPS URL. One per post, alone. | | `Bluesky image` | `20 MB in, 2 MB posted` | | JPEG, PNG, or WebP, up to 4 per post. Larger than 2 MB is resized to JPEG; a resized PNG loses transparency. | | `Bluesky video` | `100 MB` | | MP4 as base64 or from a public HTTPS URL. One per post, alone. Bluesky's own length and daily limits apply, read from Bluesky at upload time. | | `JSON request body` | `96 MiB` | | Beyond this the request is rejected with 413 before parsing. | | `Multipart request` | `4 GiB` | | Total across all parts. | ## Errors Beyond the shared codes in [Errors](https://www.tryadeli.com/docs/errors): | Name | Type | Required | Description | | --------------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unsupported_feature` | `422` | | You sent `scheduled_date`, `add_to_queue`, or `async_upload`. Scheduling is deferred. | | `invalid_post_settings` | `422` | | The privacy, comment, duet, or stitch setting is not available to this creator right now, or branded content was requested without public visibility. Re-query creator info. | | `media_url_unverified` | `422` | | TikTok has not verified this deployment's media URL prefix, which every TikTok post needs. | | `privacy_level_unsupported` | `422` | | A TikTok video asked for a privacy level other than PUBLIC\_TO\_EVERYONE. Publish it publicly, or send it as a MEDIA\_UPLOAD draft. | | `rate_limited` | `429` | | TikTok is throttling the account. Retry later. | | `page_not_selected` | `409` | | The Facebook connection exists but no Page has been chosen yet. | | `missing_permission` | `403` | | The connection was authorized without a permission this action needs. Reconnect and approve it. | | `quota_exhausted` | `429` | | YouTube's daily API quota, shared by every Adeli customer, is used up. The message says when it resets: midnight Pacific. | | `upload_limit_exceeded` | `422` | | The YouTube channel has reached YouTube's own upload limit, or the Bluesky account has reached its daily video limit or has not verified its email. Try again later. | | `provider_rejected` | `422` | | YouTube refused the video's metadata, such as an unknown category\_id. | | `x_billing_required` | `402` | | The workspace has no card on file, which X needs. See [X API pricing](https://www.tryadeli.com/docs/pricing/x). | | `duplicate_post` | `409` | | X refused the post because the account published the same text recently. | | `invalid_media` | `422` | | A Bluesky image is not valid base64, is not a JPEG, PNG, or WebP, or cannot be resized under 2 MB. Or an X media item is not valid base64, does not match its content\_type, is over its size limit, or X could not process it. | | `temporarily_unavailable` | `503` | | X is unavailable to Adeli for a while. Retry later. | | `unsupported_media_type` | `415` | | Content-Type must be `application/json` or `multipart/form-data`. | ## List posts URL: https://www.tryadeli.com/docs/api/posts/list-posts Markdown: https://www.tryadeli.com/docs/api/posts/list-posts.md Posts from every account in a profile, newest first. # List posts > Posts from every account in a profile, newest first. Canonical: `GET https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Lists posts from the Instagram accounts, Facebook Pages, TikTok accounts, YouTube channels, and Bluesky accounts in one profile, newest first. Passing `platform=whatsapp` returns `422 unsupported_platform`. Uses the [aggregate response contract](https://www.tryadeli.com/docs/concepts#partial-responses), and follows each provider's pagination internally. TikTok is read 20 posts per request and rate-limited per account, so its listing stops at the 60 most recent posts. YouTube reads cost quota shared by every Adeli customer, so its listing is the 50 most recent videos per channel. X bills every post it returns ([X API pricing](https://www.tryadeli.com/docs/pricing/x)), so X is listed only when you ask for it with `platform=x` or an X `accountId`, and then only the 10 most recent posts. Bluesky lists the 10 most recent top-level posts per account, without replies or reposts; reposts are reported as `shares`. **Query parameters** | Name | Type | Required | Description | | ----------- | ----------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook" \| "tiktok" \| "youtube" \| "x"` | | Optional. Narrow to one provider. X is listed only when asked for. | | `accountId` | `uuid` | | Narrow to one connected account. | | `profileId` | `uuid` | | Required unless you pass accountId, which names its profile. If you pass both, they must agree. | TikTok posts add `shares`, `views`, and `reach` to `engagement`, and their `media[].url` is TikTok's embeddable player. For watch time and retention, pass the `providerId` to [TikTok post insights](https://www.tryadeli.com/docs/api/analytics/tiktok-post-insights). YouTube videos add `views` to `engagement`; `caption` is the video's title and `media[].url` its watch page. Pass the `providerId` to [YouTube video insights](https://www.tryadeli.com/docs/api/analytics/youtube-video-insights) for watch time. X posts add `shares` (reposts) and `views` (impressions) to `engagement`; `comments` counts replies, and `caption` is the post's text. **Errors** — `401 unauthorized`, `400 invalid_request`, `422 unsupported_platform`, `404 profile_not_found`, `404 account_not_found`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/posts?profileId=00000000-0000-4000-8000-000000000001" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts?profileId=00000000-0000-4000-8000-000000000001", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "profileId": "00000000-0000-4000-8000-000000000001", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "status": "complete", "posts": [ { "id": "instagram_post_00000000-0000-0000-0000-000000000000_17900000000000000", "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "providerId": "17900000000000000", "caption": "Hello", "media": [{ "type": "IMAGE", "url": "https://...", "thumbnailUrl": "https://..." }], "permalink": "https://www.instagram.com/p/...", "engagement": { "likes": 12, "comments": 3 }, "publishedAt": "2026-04-01T12:00:00.000Z" }, { "id": "tiktok_post_00000000-0000-0000-0000-000000000002_7300000000000000000", "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000002", "profileId": "00000000-0000-4000-8000-000000000001", "providerId": "7300000000000000000", "caption": "Backyard birds", "media": [{ "type": "VIDEO", "url": "https://www.tiktok.com/player/v1/7300000000000000000", "thumbnailUrl": "https://p16-sign-va.tiktokcdn.com/..." }], "permalink": "https://www.tiktok.com/@creator/video/7300000000000000000", "engagement": { "likes": 30, "comments": 6, "shares": 4, "views": 2400, "reach": 1900 }, "publishedAt": "2026-03-30T12:00:00.000Z" } ], "errors": [] } ``` ### 422 ```json { "error": { "code": "unsupported_platform", "message": "Only Instagram, Facebook, TikTok, YouTube, X, and Bluesky posts are listed by this endpoint" } } ``` ## Publish to Instagram URL: https://www.tryadeli.com/docs/api/posts/publish-instagram Markdown: https://www.tryadeli.com/docs/api/posts/publish-instagram.md Publish an Instagram image or carousel. # Publish to Instagram > Publish an Instagram image or carousel. Canonical: `POST https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Send `application/json` with base64 media. Images may be JPEG, PNG, or WebP, and each must decode to no more than 8 MiB. Adeli normalizes them to JPEG and stages them where Instagram can fetch them. **Body** | Name | Type | Required | Description | | ---------------- | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"instagram"` | Yes | | | `accountId` | `uuid` | Yes | A connected Instagram account in your organization. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `caption` | `string` | | Up to 2,200 characters, 30 hashtags, and 20 `@` mentions. Hashtags and mentions are written inline — Instagram reads them from the caption. | | `image` | `object` | | A single image: `{ contentType, base64, altText?, userTags? }`. Mutually exclusive with `images`. | | `images` | `object[]` | | A carousel of 2 to 10 images, each shaped like `image`. Mutually exclusive with `image`. | | `image.altText` | `string` | | Up to 1,000 characters of alt text for screen readers. Per image, carousels included. | | `image.userTags` | `object[]` | | Up to 20 people tagged in the image, each `{ username, x, y }`. `x` and `y` are fractions from 0 to 1 measured from the image's top-left corner. A leading `@` is accepted. | | `collaborators` | `string[]` | | Up to 3 usernames invited as co-authors. Each must accept the invite in Instagram before the post shows them. | | `isAiGenerated` | `boolean` | | Adds Instagram's AI-generated label to the post. | A carousel swaps `image` for `images`. Alt text and people tags belong to each image; the caption, collaborators, and AI label belong to the post: ```json { "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "caption": "Three backyard birds with @centralparkbirders #birding #spring", "collaborators": ["centralparkbirders"], "isAiGenerated": false, "images": [ { "contentType": "image/webp", "base64": "...", "altText": "A cardinal on a snowy branch" }, { "contentType": "image/png", "base64": "...", "userTags": [{ "username": "birder", "x": 0.4, "y": 0.6 }] }, { "contentType": "image/jpeg", "base64": "..." } ] } ``` Unknown fields are rejected with `400 invalid_request` rather than ignored. A tagged person or collaborator Instagram cannot resolve — a private account or a misspelled username — fails with `422 provider_rejected` and Instagram's own explanation as the message. Success returns `201` and the normalized post, with `media` mirroring the staged images. `engagement` is null on a fresh post — the counts are not available until Instagram has them. **Errors** — `400 invalid_request`, `413 invalid_request`, `415 unsupported_media_type`, `422 invalid_image`, `422 connection_expired`, `422 provider_not_configured`, `422 provider_rejected`, `404 account_not_found`, `404 profile_not_found`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "caption": "Hello", "image": { "contentType": "image/png", "base64": "" } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "instagram", accountId: "00000000-0000-0000-0000-000000000000", caption: "Hello", image: { contentType: "image/png", base64: "", }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "caption": "Hello", "image": { "contentType": "image/png", "base64": "", }, }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "instagram_post_00000000-0000-0000-0000-000000000000_17900000000000000", "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "providerId": "17900000000000000", "caption": "Hello", "media": [{ "type": "IMAGE", "url": "https://...", "thumbnailUrl": "https://..." }], "permalink": "https://www.instagram.com/p/...", "engagement": { "likes": null, "comments": null }, "publishedAt": "2026-04-01T12:00:00.000Z" } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "Instagram connection has expired" } } ``` ## Publish to Facebook URL: https://www.tryadeli.com/docs/api/posts/publish-facebook Markdown: https://www.tryadeli.com/docs/api/posts/publish-facebook.md Publish text, a link, photos, or a video to a Facebook Page. # Publish to Facebook > Publish text, a link, photos, or a video to a Facebook Page. Canonical: `POST https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Four shapes, chosen by which media field you send rather than by a kind string, so an impossible combination cannot be expressed. As with Instagram, media is staged on Adeli's HTTPS media host because Meta fetches it by URL. | Name | Type | Required | Description | | ----------- | ------------ | -------- | ---------------------------------------------------------------- | | `platform` | `"facebook"` | Yes | | | `accountId` | `uuid` | Yes | A connected Facebook Page in your organization. | | `message` | `string` | | Up to 2200 characters. The caption shown with the post. | | `image` | `object` | | One photo — `contentType` and `base64`. Publishes synchronously. | | `images` | `object[]` | | 2–10 photos, published as one multi-photo post. Synchronous. | | `reel` | `object` | | One vertical video, published as a Reel. Asynchronous. | | `video` | `object` | | One video, published to the Page feed. Asynchronous. | | `title` | `string` | | Only valid alongside `video`. Up to 255 characters. | A photo response is the same normalized post Instagram returns, with `platform: "facebook"`. A Reel or feed video returns `202` with a `publishId`; pass it to [Facebook video status](https://www.tryadeli.com/docs/api/posts/get-facebook-video-status). > **Note: A Page must be chosen first** > > A Facebook account in `pending_page_selection` has no Page token and cannot > publish. Every write against it returns `409 page_not_selected` until your > customer picks a Page. See [Connect](https://www.tryadeli.com/docs/api/connect#provider-requirements). ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "message": "Hello", "image": { "contentType": "image/jpeg", "base64": "" } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "facebook", accountId: "00000000-0000-0000-0000-000000000000", message: "Hello", image: { contentType: "image/jpeg", base64: "", }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "message": "Hello", "image": { "contentType": "image/jpeg", "base64": "", }, }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "facebook_post_00000000-0000-0000-0000-000000000000_100000000000000_200000000000000", "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "providerId": "100000000000000_200000000000000", "caption": "Hello", "media": [{ "type": "IMAGE", "url": "https://...", "thumbnailUrl": "https://..." }], "permalink": "https://www.facebook.com/...", "engagement": { "likes": null, "comments": null }, "publishedAt": "2026-04-01T12:00:00.000Z" } ``` ### 202 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-0000-0000-000000000000", "publishId": "1234567890", "status": "ACCEPTED", "statusUrl": "/api/v1/posts/facebook/status?accountId=...&profileId=...&publishId=1234567890" } ``` ### 409 ```json { "error": { "code": "page_not_selected", "message": "Choose a Facebook Page first" } } ``` ## Facebook video status URL: https://www.tryadeli.com/docs/api/posts/get-facebook-video-status Markdown: https://www.tryadeli.com/docs/api/posts/get-facebook-video-status.md Check whether a Facebook Page video has finished publishing. # Facebook video status > Check whether a Facebook Page video has finished publishing. Canonical: `GET https://app.tryadeli.com/api/v1/posts/facebook/status` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Polls a Reel or feed video that [Publish to Facebook](https://www.tryadeli.com/docs/api/posts/publish-facebook) accepted with `202`. Photos publish synchronously and have nothing to poll. **Query parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | | | `publishId` | `string` | Yes | From the publish response. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | `status` is `PROCESSING`, `READY`, or `ERROR`. A `READY` response carries `permalink`; an `ERROR` carries `failureReason`, which is Meta's own account of what was wrong with the video — usually its length or aspect ratio. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/posts/facebook/status?accountId=00000000-0000-0000-0000-000000000000&publishId=1234567890" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts/facebook/status?accountId=00000000-0000-0000-0000-000000000000&publishId=1234567890", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/posts/facebook/status", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", "publishId": "1234567890", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "publishId": "1234567890", "status": "READY", "phase": "complete", "permalink": "https://www.facebook.com/reel/1234567890", "failureReason": null } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "Facebook connection requires reconnecting" } } ``` ## TikTok creator info URL: https://www.tryadeli.com/docs/api/posts/get-tiktok-creator-info Markdown: https://www.tryadeli.com/docs/api/posts/get-tiktok-creator-info.md What a TikTok creator currently allows, to read before a Direct Post. # TikTok creator info > What a TikTok creator currently allows, to read before a Direct Post. Canonical: `GET https://app.tryadeli.com/api/v1/posts/tiktok/creator-info` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Recommended before a Direct Post: TikTok revalidates what the creator currently allows, and those capabilities change. Use the response to build your UI and to pick a legal photo `privacy_level` for [Publish to TikTok](https://www.tryadeli.com/docs/api/posts/publish-tiktok). `nickname` is the account's display name. **Query parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | A connected TikTok account. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/posts/tiktok/creator-info?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts/tiktok/creator-info?accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/posts/tiktok/creator-info", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "creator": { "nickname": "Creator", "privacyLevelOptions": ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"], "commentDisabled": false, "duetDisabled": false, "stitchDisabled": false, "maxVideoPostDurationSec": 600 } } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "TikTok connection requires reconnecting" } } ``` ## Publish to TikTok URL: https://www.tryadeli.com/docs/api/posts/publish-tiktok Markdown: https://www.tryadeli.com/docs/api/posts/publish-tiktok.md Publish a TikTok video, photo, or carousel. # Publish to TikTok > Publish a TikTok video, photo, or carousel. Canonical: `POST https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) TikTok publishing is asynchronous. A successful request means TikTok accepted the content, not that it is live. Adeli publishes through TikTok API for Business, which pulls every video and photo from a URL: whatever you send — base64, multipart, or a URL — Adeli stages it on its own verified media host first. > **Note: Videos are public or drafts** > > TikTok for Business has no privacy setting for videos. A `DIRECT_POST` video > is published to everyone, so its `privacy_level` must be > `PUBLIC_TO_EVERYONE` or left out; anything else is > `422 privacy_level_unsupported` rather than a post that is more public than > you asked for. To keep a video private, send `post_mode: "MEDIA_UPLOAD"`: it > arrives as a draft in the creator's TikTok inbox. Photo posts accept every > `privacy_level` the creator currently allows. TikTok allows 15 API posts per account per day. Before a Direct Post, [check creator capabilities](https://www.tryadeli.com/docs/api/posts/get-tiktok-creator-info) first. **Body** | Name | Type | Required | Description | | ----------------------- | --------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"tiktok"` | Yes | | | `accountId` | `uuid` | Yes | A connected TikTok account. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `post_mode` | `"DIRECT_POST" \| "MEDIA_UPLOAD"` | | Defaults to `DIRECT_POST`, which publishes. `MEDIA_UPLOAD` sends a draft to the creator's TikTok inbox to finish in the app; a video draft carries the media only, a photo draft its title and description too. | | `tiktok_title` | `string` | | Up to 2,200 characters for a video, and up to 90 for photos. | | `tiktok_description` | `string` | | Up to 4,000 characters. Photo posts only. | | `privacy_level` | `string` | | Videos: `PUBLIC_TO_EVERYONE` or omitted. Photos: one of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY` that the creator currently allows; defaults to `PUBLIC_TO_EVERYONE`. | | `music_usage_confirmed` | `true` | Yes | Must be literally true. You are confirming the creator agreed to TikTok's music usage terms. | | `disable_comment` | `boolean` | | Defaults to false. | | `disable_duet` | `boolean` | | Video only. Defaults to false. | | `disable_stitch` | `boolean` | | Video only. Defaults to false. | | `cover_timestamp` | `integer` | | Video only. Milliseconds into the video to use as the cover. Defaults to 1000. | | `photo_cover_index` | `integer` | | Photos only. Defaults to 0, and must be less than the number of photos. | | `auto_add_music` | `boolean` | | Photos only. Defaults to false. | | `is_aigc` | `boolean` | | Marks the content as AI-generated. Defaults to false. | | `brand_content_toggle` | `boolean` | | Paid partnership. Requires `privacy_level: \"PUBLIC_TO_EVERYONE\"` on a Direct Post. | | `brand_organic_toggle` | `boolean` | | Promotes the creator's own brand. | | `video` | `object` | | Either `{ content_type: "video/mp4", base64 }` or `{ url }` pointing at public HTTPS. Mutually exclusive with `photos`. | | `photos` | `object[]` | | 1 to 35 photos, each `{ content_type, base64 }`. Remote photo URLs are not accepted. Mutually exclusive with `video`. | > **Note: Exactly one of video or photos** > > Sending both, or neither, is `400 invalid_request`. The request example sends a video from a public URL to the creator's inbox. A video published publicly: ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "tiktok_title": "Morning chorus #birds", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_duet": false, "disable_stitch": true, "music_usage_confirmed": true, "video": { "url": "https://media.example/video.mp4" } } ``` A photo carousel posted directly, visible only to the creator: ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "post_mode": "DIRECT_POST", "tiktok_title": "Backyard birds", "tiktok_description": "Cardinal, Titmouse, and Robin", "privacy_level": "SELF_ONLY", "photo_cover_index": 0, "auto_add_music": false, "music_usage_confirmed": true, "photos": [ { "content_type": "image/webp", "base64": "..." }, { "content_type": "image/jpeg", "base64": "..." } ] } ``` ## Multipart uploads For large videos, send `multipart/form-data` instead. Scalar fields use the same names, booleans must be the strings `"true"` or `"false"`, and the media is either one `video` file or 1–35 repeated `photos[]` files. Multipart is TikTok only. ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -F 'platform=tiktok' \ -F "accountId=$ACCOUNT_ID" \ -F 'post_mode=DIRECT_POST' \ -F 'privacy_level=PUBLIC_TO_EVERYONE' \ -F 'tiktok_title=Morning chorus' \ -F 'music_usage_confirmed=true' \ -F 'video=@video.mp4;type=video/mp4' ``` ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -F 'platform=tiktok' \ -F "accountId=$ACCOUNT_ID" \ -F 'post_mode=MEDIA_UPLOAD' \ -F 'music_usage_confirmed=true' \ -F 'photos[]=@cardinal.webp;type=image/webp' \ -F 'photos[]=@robin.jpg;type=image/jpeg' ``` ## The response Success returns `202` with a `publishId`. Pass it to [TikTok publish status](https://www.tryadeli.com/docs/api/posts/get-tiktok-publish-status) to learn whether the post went live. > **Note: statusUrl is a relative path** > > Join it to your base URL before requesting it. `warnings` is present only when > non-empty — inbox uploads report the settings TikTok ignored, since a video > sent to the inbox carries media and nothing else. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "post_mode": "MEDIA_UPLOAD", "tiktok_title": "Bird video", "music_usage_confirmed": true, "video": { "url": "https://media.example/video.mp4" } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "tiktok", accountId: "00000000-0000-0000-0000-000000000000", post_mode: "MEDIA_UPLOAD", tiktok_title: "Bird video", music_usage_confirmed: true, video: { url: "https://media.example/video.mp4", }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "post_mode": "MEDIA_UPLOAD", "tiktok_title": "Bird video", "music_usage_confirmed": True, "video": { "url": "https://media.example/video.mp4", }, }, ) print(response.json()) ``` ## Responses ### 202 ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "publishId": "v_pub_url~v1.2345123456789123456", "status": "ACCEPTED", "statusUrl": "/api/v1/posts/tiktok/status?accountId=...&profileId=...&publishId=...", "warnings": [ { "code": "video_draft_media_only", "message": "TikTok video drafts receive media only; post settings must be completed in TikTok" } ] } ``` ### 422 ```json { "error": { "code": "privacy_level_unsupported", "message": "TikTok videos publish publicly; use PUBLIC_TO_EVERYONE, or post_mode MEDIA_UPLOAD to send a draft" } } ``` ## TikTok publish status URL: https://www.tryadeli.com/docs/api/posts/get-tiktok-publish-status Markdown: https://www.tryadeli.com/docs/api/posts/get-tiktok-publish-status.md Poll a TikTok publish until it completes or fails. # TikTok publish status > Poll a TikTok publish until it completes or fails. Canonical: `GET https://app.tryadeli.com/api/v1/posts/tiktok/status` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Polls a post that [Publish to TikTok](https://www.tryadeli.com/docs/api/posts/publish-tiktok) accepted with `202`. **Query parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | | | `publishId` | `string` | Yes | From the publish response. 1–200 characters of \[A-Za-z0-9.\_\~-]. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | **Fields** | Name | Type | Required | Description | | ------------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `string` | | `PROCESSING_DOWNLOAD` while TikTok fetches the media; then `PUBLISH_COMPLETE`, `SEND_TO_USER_INBOX` (a draft was delivered), or `FAILED`. The last three are final. | | `publicPostIds` | `string[]` | | The TikTok post ids, once the post is public. They can arrive up to a few minutes after PUBLISH\_COMPLETE, so keep polling until they do. Absent for posts that are not public. | | `failureReason` | `string \| null` | | TikTok's machine-readable reason when `FAILED`, such as `frame_rate_check_failed`, `video_pull_failed`, or `spam_risk_too_many_posts` (the daily limit). | | `publiclyAvailable` | `boolean \| null` | | Whether the post is publicly viewable. `false` after TikTok reports it is no longer public — moderation, or the creator made it private. `null` when not yet known. | | `source` | `"webhook" \| "provider"` | | Whether the answer came from a TikTok webhook Adeli received or a live status call. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/posts/tiktok/status?accountId=00000000-0000-0000-0000-000000000000&publishId=v_pub_url%7Ev1.2345123456789123456" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts/tiktok/status?accountId=00000000-0000-0000-0000-000000000000&publishId=v_pub_url%7Ev1.2345123456789123456", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/posts/tiktok/status", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", "publishId": "v_pub_url~v1.2345123456789123456", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "publishId": "v_pub_url~v1.2345123456789123456", "status": "PUBLISH_COMPLETE", "publicPostId": "7300000000000000000", "publicPostIds": ["7300000000000000000"], "failureReason": null, "publiclyAvailable": true, "source": "webhook" } ``` ### 429 ```json { "error": { "code": "rate_limited", "message": "TikTok rate limit reached; retry later" } } ``` ## Publish to YouTube URL: https://www.tryadeli.com/docs/api/posts/publish-youtube Markdown: https://www.tryadeli.com/docs/api/posts/publish-youtube.md Upload a YouTube video or Short from a URL or base64. # Publish to YouTube > Upload a YouTube video or Short from a URL or base64. Canonical: `POST https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) YouTube takes one video per post. There is no separate Shorts endpoint: a vertical or square video up to three minutes long becomes a Short. Send `application/json` with a public HTTPS `video.url` (Adeli downloads it, up to 1 GB) or base64, or upload the file itself with [multipart](https://www.tryadeli.com/docs/api/posts/upload-youtube-video-file). > **Warning: Uploads are private until Adeli passes YouTube's audit** > > YouTube locks every video uploaded through an app to private until that app > passes the YouTube API Services audit. Until Adeli does, ask for any > visibility you like: the response reports what YouTube actually applied in > `appliedPrivacy`, and `privacyRestricted` is `true` when that is not what you > asked for. Change the visibility in YouTube Studio afterwards. **Body** | Name | Type | Required | Description | | -------------------------- | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"youtube"` | Yes | | | `accountId` | `uuid` | Yes | A connected YouTube channel in your organization. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `title` | `string` | Yes | 1–100 characters, with no `<` or `>`. | | `privacy_status` | `"public" \| "unlisted" \| "private"` | Yes | Who can see the video. There is no default. | | `made_for_kids` | `boolean` | Yes | Whether the video is made for kids, as YouTube requires under COPPA. This is the channel owner's own declaration: ask them, never guess. | | `video` | `object` | Yes | `{ url }`, a public HTTPS URL of up to 1 GB, or `{ content_type, base64 }` with `video/mp4`, `video/quicktime`, or `video/webm`. | | `description` | `string` | | Up to 5,000 bytes of UTF-8, with no `<` or `>`. | | `tags` | `string[]` | | Up to 500 characters in total, counting the commas YouTube puts between them. | | `category_id` | `string` | | A numeric YouTube category id. Defaults to `22`, People & Blogs. | | `contains_synthetic_media` | `boolean` | | Discloses realistic altered or synthetic content. | | `publish_at` | `string` | | An ISO 8601 time in the future. Requires `privacy_status: "private"`: YouTube makes the video public at that time. | | `notify_subscribers` | `boolean` | | Defaults to true. | Returns `202` once YouTube has the file. Processing continues after; poll [YouTube upload status](https://www.tryadeli.com/docs/api/posts/get-youtube-upload-status) with the `publishId`. **Fields** | Name | Type | Required | Description | | ---------------- | -------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `publishId` | `string` | | The YouTube video id, the same as `videoId`. Pass it to the status endpoint. | | `appliedPrivacy` | `string` | | The visibility YouTube applied. `privacyRestricted` is true when it is `private` but you asked for something else. | | `thumbnail` | `"set" \| "not_allowed" \| "failed" \| null` | | `null` when no thumbnail was sent; `not_allowed` when the channel cannot use custom thumbnails. | | `warnings` | `object[]` | | Present only when non-empty: `privacy_restricted`, `thumbnail_not_allowed`, or `thumbnail_failed`. | ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "youtube", "accountId": "00000000-0000-0000-0000-000000000000", "title": "Heron fishing at dawn", "privacy_status": "public", "made_for_kids": false, "video": { "url": "https://cdn.example/heron.mp4" } }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "youtube", accountId: "00000000-0000-0000-0000-000000000000", title: "Heron fishing at dawn", privacy_status: "public", made_for_kids: false, video: { url: "https://cdn.example/heron.mp4", }, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "youtube", "accountId": "00000000-0000-0000-0000-000000000000", "title": "Heron fishing at dawn", "privacy_status": "public", "made_for_kids": False, "video": { "url": "https://cdn.example/heron.mp4", }, }, ) print(response.json()) ``` ## Responses ### 202 ```json { "id": "youtube_post_00000000-0000-0000-0000-000000000000_dQw4w9WgXcQ", "platform": "youtube", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "publishId": "dQw4w9WgXcQ", "videoId": "dQw4w9WgXcQ", "permalink": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "status": "ACCEPTED", "requestedPrivacy": "public", "appliedPrivacy": "private", "privacyRestricted": true, "publishAt": null, "thumbnail": null, "statusUrl": "/api/v1/posts/youtube/status?accountId=...&profileId=...&videoId=dQw4w9WgXcQ", "warnings": [ { "code": "privacy_restricted", "message": "YouTube locked this upload to private: the app has not yet passed the YouTube API Services audit" } ] } ``` ### 422 ```json { "error": { "code": "upload_limit_exceeded", "message": "This channel has reached YouTube's upload limit; try again later" } } ``` ## Upload YouTube file URL: https://www.tryadeli.com/docs/api/posts/upload-youtube-video-file Markdown: https://www.tryadeli.com/docs/api/posts/upload-youtube-video-file.md Upload a YouTube video as a multipart file. # Upload YouTube file > Upload a YouTube video as a multipart file. Canonical: `POST https://app.tryadeli.com/api/v1/posts/youtube` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) [`POST /api/v1/posts`](https://www.tryadeli.com/docs/api/posts/publish-youtube) takes multipart for TikTok only, so a YouTube file upload has its own route. Fields use the same names; booleans are the strings `"true"` or `"false"`, and `tags` is comma-separated. Send one `video` file of up to 4 GiB and, optionally, a `thumbnail` (JPEG or PNG, up to 50 MB). YouTube allows custom thumbnails only on verified channels; a refused thumbnail is a warning, not a failed upload. It returns `202` once YouTube has the file, with the same response as [Publish to YouTube](https://www.tryadeli.com/docs/api/posts/publish-youtube). Processing continues after; poll [YouTube upload status](https://www.tryadeli.com/docs/api/posts/get-youtube-upload-status). ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts/youtube" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -F 'platform=youtube' \ -F 'accountId=00000000-0000-0000-0000-000000000000' \ -F 'title=Heron fishing at dawn' \ -F 'privacy_status=unlisted' \ -F 'made_for_kids=false' \ -F 'tags=birds,herons' \ -F "video=@heron.mp4;type=video/mp4" \ -F "thumbnail=@heron.jpg;type=image/jpeg" ``` **JavaScript** ```javascript import { openAsBlob } from "node:fs"; const form = new FormData(); form.append("platform", "youtube"); form.append("accountId", "00000000-0000-0000-0000-000000000000"); form.append("title", "Heron fishing at dawn"); form.append("privacy_status", "unlisted"); form.append("made_for_kids", "false"); form.append("tags", "birds,herons"); form.append("video", await openAsBlob("heron.mp4", { type: "video/mp4" }), "heron.mp4"); form.append("thumbnail", await openAsBlob("heron.jpg", { type: "image/jpeg" }), "heron.jpg"); const response = await fetch("https://app.tryadeli.com/api/v1/posts/youtube", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, body: form, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts/youtube", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, data={ "platform": "youtube", "accountId": "00000000-0000-0000-0000-000000000000", "title": "Heron fishing at dawn", "privacy_status": "unlisted", "made_for_kids": "false", "tags": "birds,herons", }, files={ "video": ("heron.mp4", open("heron.mp4", "rb"), "video/mp4"), "thumbnail": ("heron.jpg", open("heron.jpg", "rb"), "image/jpeg"), }, ) print(response.json()) ``` ## Responses ### 202 ```json { "id": "youtube_post_00000000-0000-0000-0000-000000000000_dQw4w9WgXcQ", "platform": "youtube", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "publishId": "dQw4w9WgXcQ", "videoId": "dQw4w9WgXcQ", "permalink": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "status": "ACCEPTED", "requestedPrivacy": "unlisted", "appliedPrivacy": "private", "privacyRestricted": true, "publishAt": null, "thumbnail": "set", "statusUrl": "/api/v1/posts/youtube/status?accountId=...&profileId=...&videoId=dQw4w9WgXcQ", "warnings": [ { "code": "privacy_restricted", "message": "YouTube locked this upload to private: the app has not yet passed the YouTube API Services audit" } ] } ``` ### 429 ```json { "error": { "code": "quota_exhausted", "message": "YouTube's daily API quota is used up; it resets at 2026-10-10T07:00:00.000Z" } } ``` ## YouTube upload status URL: https://www.tryadeli.com/docs/api/posts/get-youtube-upload-status Markdown: https://www.tryadeli.com/docs/api/posts/get-youtube-upload-status.md Poll a YouTube upload until processing finishes. # YouTube upload status > Poll a YouTube upload until processing finishes. Canonical: `GET https://app.tryadeli.com/api/v1/posts/youtube/status` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Polls a video uploaded with [Publish to YouTube](https://www.tryadeli.com/docs/api/posts/publish-youtube) or [Upload YouTube file](https://www.tryadeli.com/docs/api/posts/upload-youtube-video-file). **Query parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | | | `videoId` | `string` | Yes | The publishId from the upload response. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | `status` is `processing` until YouTube finishes, then `published` (live, scheduled, unlisted, or private — see `privacyStatus`) or `failed`, with YouTube's `failureReason`. `published` and `failed` are final. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/posts/youtube/status?accountId=00000000-0000-0000-0000-000000000000&videoId=dQw4w9WgXcQ" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts/youtube/status?accountId=00000000-0000-0000-0000-000000000000&videoId=dQw4w9WgXcQ", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/posts/youtube/status", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", "videoId": "dQw4w9WgXcQ", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "youtube", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "videoId": "dQw4w9WgXcQ", "status": "published", "uploadStatus": "processed", "processingStatus": "succeeded", "failureReason": null, "privacyStatus": "private", "publishAt": null, "permalink": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" } ``` ### 404 ```json { "error": { "code": "post_not_found", "message": "Video not found on this channel" } } ``` ## Publish to X URL: https://www.tryadeli.com/docs/api/posts/publish-x Markdown: https://www.tryadeli.com/docs/api/posts/publish-x.md Publish an X post or thread, with text, media, or both. # Publish to X > Publish an X post or thread, with text, media, or both. Canonical: `POST https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) An X post is text, media, or both. Send `thread` to publish several posts, each a reply to the one before. X publishes synchronously, so the response is the finished post. > **Warning: Every X post is billed** > > X charges per API call, and Adeli passes the charge on at X's price: $0.015 a > post, or $0.20 for a post containing a link. See [X API pricing](https://www.tryadeli.com/docs/pricing/x). Until > the workspace has a card on file, this returns `402 x_billing_required`. **Body** | Name | Type | Required | Description | | ----------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"x"` | Yes | | | `accountId` | `uuid` | Yes | A connected X account in your organization. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `text` | `string` | Yes | Up to 280 characters, counted as X counts them: every link is 23. Send `""` for a media-only post. Adeli cannot tell which accounts have X Premium, so the 280 limit applies to all. | | `media` | `object[]` | | Up to 4 images, or 1 GIF, or 1 video. Each is `{ content_type, base64 }` with `image/jpeg`, `image/png`, `image/webp`, `image/gif`, or `video/mp4`, or a video as `{ url }`, a public HTTPS MP4 or MOV. | | `thread` | `object[]` | | Up to 24 further posts, each `{ text, media }` with the same rules, published in order as replies. | `201` when every post is live. X cannot publish a thread atomically. When a post after the first fails, the posts before it are already live and stay live: the response is `207` with `status: "partial"`, the failed post's `error`, and `not_attempted` for the rest. Publish the remainder yourself as replies to the last `providerId` that went live. A failure on the first post publishes nothing and is an ordinary error. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "x", "accountId": "00000000-0000-0000-0000-000000000000", "text": "Herons fish at dawn. A thread:", "thread": [ { "text": "They stand still for minutes at a time." } ] }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "x", accountId: "00000000-0000-0000-0000-000000000000", text: "Herons fish at dawn. A thread:", thread: [ { text: "They stand still for minutes at a time.", }, ], }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "x", "accountId": "00000000-0000-0000-0000-000000000000", "text": "Herons fish at dawn. A thread:", "thread": [ { "text": "They stand still for minutes at a time.", }, ], }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "x_post_00000000-0000-0000-0000-000000000000_1840000000000000000", "platform": "x", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "status": "published", "providerId": "1840000000000000000", "permalink": "https://x.com/adeli/status/1840000000000000000", "posts": [ { "index": 0, "status": "published", "providerId": "1840000000000000000", "url": "https://x.com/adeli/status/1840000000000000000" }, { "index": 1, "status": "published", "providerId": "1840000000000000001", "url": "https://x.com/adeli/status/1840000000000000001" } ] } ``` ### 207 ```json { "status": "partial", "posts": [ { "index": 0, "status": "published", "providerId": "1840000000000000000", "url": "https://x.com/adeli/status/1840000000000000000" }, { "index": 1, "status": "failed", "error": { "code": "duplicate_post", "message": "X refused a duplicate of a recent post" } }, { "index": 2, "status": "not_attempted" } ] } ``` ### 402 ```json { "error": { "code": "x_billing_required", "message": "X requires billing to be set up for this account" } } ``` ## Delete X post URL: https://www.tryadeli.com/docs/api/posts/delete-x-post Markdown: https://www.tryadeli.com/docs/api/posts/delete-x-post.md Delete a post from an X account. # Delete X post > Delete a post from an X account. Canonical: `DELETE https://app.tryadeli.com/api/v1/posts/x/{postId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Deletes a post from the X account. `postId` is the post's `providerId`. It cannot be undone, and X bills the delete. **Path parameters** | Name | Type | Required | Description | | -------- | -------- | -------- | ------------------------------------------------------------------- | | `postId` | `string` | Yes | The post's providerId, as returned when it was published or listed. | **Query parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | The X account the post belongs to. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | ## Request examples **cURL** ```bash curl --fail-with-body -X DELETE "https://app.tryadeli.com/api/v1/posts/x/1840000000000000000?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts/x/1840000000000000000?accountId=00000000-0000-0000-0000-000000000000", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.delete( "https://app.tryadeli.com/api/v1/posts/x/1840000000000000000", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "x_post_00000000-0000-0000-0000-000000000000_1840000000000000000", "platform": "x", "accountId": "00000000-0000-0000-0000-000000000000", "providerId": "1840000000000000000", "deleted": true } ``` ### 404 ```json { "error": { "code": "post_not_found", "message": "Post not found on this account" } } ``` ## Publish to Bluesky URL: https://www.tryadeli.com/docs/api/posts/publish-bluesky Markdown: https://www.tryadeli.com/docs/api/posts/publish-bluesky.md Publish a Bluesky post or thread, with up to four images or one video. # Publish to Bluesky > Publish a Bluesky post or thread, with up to four images or one video. Canonical: `POST https://app.tryadeli.com/api/v1/posts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) A Bluesky post is text with up to four images or one video. Send `thread` to publish several posts, each a reply to the one before. Bluesky publishes synchronously, so the response is the finished post. Bluesky's API is free: nothing is billed per post. Links, `@mentions`, and `#hashtags` in `text` become links on Bluesky. A mention of a handle that doesn't exist stays plain text. **Body** | Name | Type | Required | Description | | ----------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"bluesky"` | Yes | | | `accountId` | `uuid` | Yes | A connected Bluesky account in your organization. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `text` | `string` | Yes | Up to 300 characters, counted as a reader sees them (an emoji is one), and 3,000 bytes. Links count at full length. Send `""` for an images-only post. | | `images` | `object[]` | | Up to 4, each `{ content_type, base64, altText }` with `image/jpeg`, `image/png`, or `image/webp`. Images over 2 MB are resized to fit, and the response lists them in `resizedImages`. `altText` is optional but recommended. | | `video` | `object` | | One MP4, never with `images`: `{ content_type: "video/mp4", base64, altText }` or `{ url, altText }` for a public HTTPS URL, up to 100 MB. Bluesky processes it before the post is created, which can take a minute. Bluesky limits videos per account per day and needs a verified email first; past that, this returns `422 upload_limit_exceeded` with Bluesky's reason. | | `langs` | `string[]` | | Up to 3 language tags, such as `en` or `pt-BR`, which Bluesky uses for feeds and translation. | | `thread` | `object[]` | | Up to 24 further posts, each `{ text, images, video }` with the same rules, published in order as replies. | `201` when every post is live. `providerId` is the post's AT URI. A thread that fails part-way returns `207` with `status: "partial"`, exactly as [for X](https://www.tryadeli.com/docs/api/posts/publish-x). ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/posts" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "bluesky", "accountId": "00000000-0000-0000-0000-000000000000", "text": "Herons fish at dawn. A thread:", "thread": [ { "text": "They stand still for minutes at a time." } ] }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "bluesky", accountId: "00000000-0000-0000-0000-000000000000", text: "Herons fish at dawn. A thread:", thread: [ { text: "They stand still for minutes at a time.", }, ], }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/posts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "bluesky", "accountId": "00000000-0000-0000-0000-000000000000", "text": "Herons fish at dawn. A thread:", "thread": [ { "text": "They stand still for minutes at a time.", }, ], }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "bluesky_post_00000000-0000-0000-0000-000000000000_3l6oveex3ii2l", "platform": "bluesky", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "status": "published", "providerId": "at://did:plc:abc123/app.bsky.feed.post/3l6oveex3ii2l", "permalink": "https://bsky.app/profile/adeli.bsky.social/post/3l6oveex3ii2l", "posts": [ { "index": 0, "status": "published", "providerId": "at://did:plc:abc123/app.bsky.feed.post/3l6oveex3ii2l", "url": "https://bsky.app/profile/adeli.bsky.social/post/3l6oveex3ii2l" }, { "index": 1, "status": "published", "providerId": "at://did:plc:abc123/app.bsky.feed.post/3l6ovefa2kq2k", "url": "https://bsky.app/profile/adeli.bsky.social/post/3l6ovefa2kq2k" } ] } ``` ### 422 ```json { "error": { "code": "invalid_media", "message": "The image could not be made small enough for Bluesky" } } ``` ## Delete Bluesky post URL: https://www.tryadeli.com/docs/api/posts/delete-bluesky-post Markdown: https://www.tryadeli.com/docs/api/posts/delete-bluesky-post.md Delete a post from a Bluesky account. # Delete Bluesky post > Delete a post from a Bluesky account. Canonical: `DELETE https://app.tryadeli.com/api/v1/posts/bluesky/{postId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Deletes a post from the Bluesky account. `postId` is the post's record key, the last segment of its `providerId` (`3l6oveex3ii2l` in [the publish example](https://www.tryadeli.com/docs/api/posts/publish-bluesky)), or the whole AT URI, URL-encoded. It cannot be undone. **Path parameters** | Name | Type | Required | Description | | -------- | -------- | -------- | -------------------------------------------------------- | | `postId` | `string` | Yes | The post's record key, or its whole AT URI, URL-encoded. | **Query parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | The Bluesky account the post belongs to. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | ## Request examples **cURL** ```bash curl --fail-with-body -X DELETE "https://app.tryadeli.com/api/v1/posts/bluesky/3l6oveex3ii2l?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/posts/bluesky/3l6oveex3ii2l?accountId=00000000-0000-0000-0000-000000000000", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.delete( "https://app.tryadeli.com/api/v1/posts/bluesky/3l6oveex3ii2l", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "id": "bluesky_post_00000000-0000-0000-0000-000000000000_3l6oveex3ii2l", "platform": "bluesky", "accountId": "00000000-0000-0000-0000-000000000000", "providerId": "at://did:plc:abc123/app.bsky.feed.post/3l6oveex3ii2l", "deleted": true } ``` ### 404 ```json { "error": { "code": "post_not_found", "message": "Post not found on this account" } } ``` --- # Comments ## Comments URL: https://www.tryadeli.com/docs/api/comments Markdown: https://www.tryadeli.com/docs/api/comments.md Read, reply to, hide, like, and delete comments on Instagram, Facebook, TikTok, and YouTube posts. # Comments > Read, reply to, hide, like, and delete comments on Instagram, Facebook, TikTok, and YouTube posts. Canonical: Moderate the comments on your customer's own posts: read them, reply, hide the ones that should not be seen, and delete. **Reads come from Adeli's copy; writes go to the platform.** Adeli keeps each connected account's recent posts and their comments, so listing comments answers immediately instead of waiting on the platform. The copy is refreshed from the platform in the background whenever it is read and is more than a minute old (ten minutes on YouTube, whose quota is shared by every Adeli customer), and Adeli updates it as soon as you reply, hide, like, or delete through this API. Every response says when its comments were last refreshed (`syncedAt`) and whether every thread is held yet (`complete`). Changes made outside Adeli, such as a comment hidden in the Instagram app, appear after the next refresh. The four platforms do not allow the same things: | | Instagram | Facebook | TikTok | YouTube | | --------------------------------- | ---------------------------------- | ------------------------------- | ------------------------------- | --------------------------------------------------------------------------- | | List comments and replies | Yes | Yes | Yes, paged with `cursor` | Yes, paged with `cursor` | | Reply to a comment | Yes | Yes | Yes | Yes | | Comment on the account's own post | No | Yes | Yes | Yes | | Hide and unhide | Yes | Yes | Yes | Yes, by holding for review | | Like and unlike | No | No | Yes | No | | Delete | Any comment on the account's posts | Any comment on the Page's posts | Only comments the account wrote | The channel's own; anyone else's is rejected, optionally banning its author | Comment ids and post ids are the provider's own: numeric strings everywhere but YouTube, whose ids are URL-safe base64 (a reply's is `{parent}.{reply}`). Post ids come from [`GET /api/v1/posts`](https://www.tryadeli.com/docs/api/posts) (`providerId`), or from the `posts` that [List comments](https://www.tryadeli.com/docs/api/comments/list-comments) returns. ## Endpoints - `GET /api/v1/comments` — [List comments](https://www.tryadeli.com/docs/api/comments/list-comments.md) - `POST /api/v1/comments` — [Reply or comment](https://www.tryadeli.com/docs/api/comments/create-comment.md) - `PATCH /api/v1/comments/{commentId}` — [Hide or like comment](https://www.tryadeli.com/docs/api/comments/update-comment.md) - `DELETE /api/v1/comments/{commentId}` — [Delete comment](https://www.tryadeli.com/docs/api/comments/delete-comment.md) ## Permissions Comment management needs a permission that connections made before it was requested may lack. A read or write against such a connection returns `422 connection_expired`; [reconnecting](https://www.tryadeli.com/docs/api/connect) the account and approving the permission fixes it. On TikTok, reading needs `comment.list` and writing needs `comment.list.manage`. On YouTube, both need `youtube.force-ssl`, which every YouTube connection holds. ## YouTube quota YouTube's API quota belongs to Adeli's Google Cloud project and is shared by every connected channel: 10,000 units a day by default, reset at midnight Pacific. A read costs 1 unit; every reply, comment, hide, and delete costs 50. Once the day's quota is spent, YouTube calls return `429 quota_exhausted` with the reset time, and reads keep answering from Adeli's copy. ## List comments URL: https://www.tryadeli.com/docs/api/comments/list-comments Markdown: https://www.tryadeli.com/docs/api/comments/list-comments.md An account's recent posts and the comments on one of them. # List comments > An account's recent posts and the comments on one of them. Canonical: `GET https://app.tryadeli.com/api/v1/comments` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns the account's recent posts and the comments on one of them — the post named by `postId`, or the newest post when it is omitted. Replies are returned alongside top-level comments and carry `parentId`. Comments are newest first. `posts` holds the account's newest 25 posts on Instagram and Facebook, and its newest 20 on TikTok, and its newest 25 videos on YouTube. `postId` may name an older post of the account's too. YouTube sends no notice of new comments. While you keep reading a channel's comments, Adeli checks the channel's newest comments across every video at most every two minutes, and re-reads only the videos that have new ones. The first time a post is read, Adeli fetches its newest comments from the platform while you wait and the rest in the background, so that response has `complete: false`. Read again a few seconds later for the remainder. **Query parameters** | Name | Type | Required | Description | | ----------- | ---------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook" \| "tiktok" \| "youtube"` | Yes | | | `accountId` | `uuid` | Yes | A connected account on that platform. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `postId` | `string` | | One of the account's posts. Defaults to the newest. | | `cursor` | `string` | | TikTok and YouTube only: the `nextCursor` of the previous page. | **Comment fields** | Name | Type | Required | Description | | ------------ | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `isCreator` | `boolean` | | Written by the connected account itself. | | `isHidden` | `boolean` | | Hidden from everyone but its author. On YouTube, held for review or flagged as likely spam: visible only to the channel. | | `liked` | `boolean` | | TikTok only: the connected account has liked it. | | `deletable` | `boolean` | | TikTok and YouTube: false for comments TikTok will not delete. Always true on YouTube, where any comment can be removed. | | `replyCount` | `number` | | TikTok and YouTube: how many replies the platform reports for the comment, which can exceed the replies returned so far. | | `nextCursor` | `string \| null` | | TikTok and YouTube: pass as cursor for the next page of comments, fetched live from the platform. Null once every thread is held, and always null on Instagram and Facebook. | | `complete` | `boolean` | | Whether every top-level comment on the post is held. False on a post's first read, and on a TikTok post Adeli has read only the newest page of. | | `syncedAt` | `string \| null` | | When these comments were last refreshed from the platform. | On TikTok each page holds up to 30 top-level comments. TikTok returns three replies per thread inline; Adeli fetches the rest of a few threads with each background refresh, so `replyCount` can briefly exceed the replies returned. A `cursor` request is the exception to reading from Adeli's copy: it is fetched from TikTok while you wait, and returns that page alone. On YouTube each page holds up to 100 top-level comments, and YouTube returns up to five replies per thread inline. `cursor` works the same way as on TikTok. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 post_not_found`, `404 profile_not_found`, `409 page_not_selected`, `422 connection_expired`, `422 not_supported`, `429 rate_limited`, `429 quota_exhausted`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/comments?platform=tiktok&accountId=00000000-0000-0000-0000-000000000000&postId=7300000000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/comments?platform=tiktok&accountId=00000000-0000-0000-0000-000000000000&postId=7300000000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/comments", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "postId": "7300000000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "postId": "7300000000000000000", "posts": [ { "id": "7300000000000000000", "caption": "Backyard birds", "thumbnailUrl": "https://...", "permalink": "https://www.tiktok.com/@creator/video/7300000000000000000", "publishedAt": "2026-09-30T12:00:00.000Z", "commentsCount": 2 } ], "comments": [ { "id": "7301000000000000001", "mediaId": "7300000000000000000", "parentId": null, "authorName": "Bird Fan", "authorFullName": "Bird Fan", "authorHandle": "birdfan", "avatarUrl": "https://...", "profileUrl": "https://www.tiktok.com/@birdfan", "body": "What a cardinal!", "likeCount": 3, "isCreator": false, "isHidden": false, "createdAt": "2026-09-30T13:00:00.000Z", "liked": false, "deletable": false }, { "id": "7301000000000000002", "mediaId": "7300000000000000000", "parentId": "7301000000000000001", "authorName": "Creator", "authorFullName": "Creator", "authorHandle": "creator", "avatarUrl": null, "profileUrl": "https://www.tiktok.com/@creator", "body": "Thank you!", "likeCount": 0, "isCreator": true, "isHidden": false, "createdAt": "2026-09-30T13:05:00.000Z", "liked": false, "deletable": true } ], "nextCursor": "30", "complete": false, "syncedAt": "2026-09-30T13:06:00.000Z" } ``` ### 404 ```json { "error": { "code": "post_not_found", "message": "Post not found on this account" } } ``` ## Reply or comment URL: https://www.tryadeli.com/docs/api/comments/create-comment Markdown: https://www.tryadeli.com/docs/api/comments/create-comment.md Reply to a comment, or comment on one of the account's own posts. # Reply or comment > Reply to a comment, or comment on one of the account's own posts. Canonical: `POST https://app.tryadeli.com/api/v1/comments` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Send `parentId` to reply to a comment, or `postId` alone to comment on one of the account's own posts. Instagram supports replies only. TikTok and YouTube need `postId` for a reply too. Comments are public as soon as the provider accepts them. **Body** | Name | Type | Required | Description | | ----------- | ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook" \| "tiktok" \| "youtube"` | Yes | | | `accountId` | `uuid` | Yes | | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `postId` | `string` | | The post. Required for a top-level comment, and for any TikTok or YouTube comment. | | `parentId` | `string` | | The comment being replied to. Required on Instagram. | | `message` | `string` | Yes | Up to 2,200 characters on Instagram, 1,200 on TikTok, 8,000 on Facebook, 10,000 on YouTube. | Returns `201` with `{ platform, accountId, profileId, comment }`. On TikTok and YouTube `comment` is the full comment in the shape [List comments](https://www.tryadeli.com/docs/api/comments/list-comments) returns; Instagram and Facebook return the new comment's id. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `409 page_not_selected`, `422 connection_expired`, `429 rate_limited`, `429 quota_exhausted`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/comments" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "postId": "7300000000000000000", "parentId": "7301000000000000001", "message": "Thank you!" }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/comments", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "tiktok", accountId: "00000000-0000-0000-0000-000000000000", postId: "7300000000000000000", parentId: "7301000000000000001", message: "Thank you!", }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/comments", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "postId": "7300000000000000000", "parentId": "7301000000000000001", "message": "Thank you!", }, ) print(response.json()) ``` ## Responses ### 201 ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "comment": { "id": "7301000000000000002", "mediaId": "7300000000000000000", "parentId": "7301000000000000001", "authorName": "Creator", "authorFullName": "Creator", "authorHandle": "creator", "avatarUrl": null, "profileUrl": "https://www.tiktok.com/@creator", "body": "Thank you!", "likeCount": 0, "isCreator": true, "isHidden": false, "createdAt": "2026-09-30T13:05:00.000Z", "liked": false, "deletable": true } } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "Instagram supports replies only", "details": [ { "code": "custom", "path": ["parentId"], "message": "Instagram supports replies only" } ] } } ``` ## Hide or like comment URL: https://www.tryadeli.com/docs/api/comments/update-comment Markdown: https://www.tryadeli.com/docs/api/comments/update-comment.md Hide, unhide, like, or unlike a comment. # Hide or like comment > Hide, unhide, like, or unlike a comment. Canonical: `PATCH https://app.tryadeli.com/api/v1/comments/{commentId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) **Path parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `commentId` | `string` | Yes | The provider's comment id: a numeric string everywhere but YouTube, whose ids are URL-safe base64. One that does not match the platform's format returns 400 invalid\_request. | **Body** | Name | Type | Required | Description | | ----------- | ---------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook" \| "tiktok" \| "youtube"` | Yes | | | `accountId` | `uuid` | Yes | | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `hidden` | `boolean` | | Hide (true) or unhide (false). | | `liked` | `boolean` | | TikTok only. Like (true) or unlike (false). | | `postId` | `string` | | TikTok only, required with hidden: the post the comment is on. | On YouTube, `hidden: true` holds the comment for review, where only the channel sees it, and `hidden: false` publishes it again. `liked` returns `422 not_supported` on every platform but TikTok. Send at least one of `hidden` and `liked`. Returns `200` with the new state: `{ platform, accountId, profileId, comment: { id, hidden?, liked? } }`. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `422 connection_expired`, `422 not_supported`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X PATCH "https://app.tryadeli.com/api/v1/comments/7301000000000000001" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "postId": "7300000000000000000", "hidden": true }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/comments/7301000000000000001", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "tiktok", accountId: "00000000-0000-0000-0000-000000000000", postId: "7300000000000000000", hidden: true, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.patch( "https://app.tryadeli.com/api/v1/comments/7301000000000000001", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "postId": "7300000000000000000", "hidden": True, }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "comment": { "id": "7301000000000000001", "hidden": true } } ``` ### 422 ```json { "error": { "code": "not_supported", "message": "Instagram comments cannot be liked through this API" } } ``` ## Delete comment URL: https://www.tryadeli.com/docs/api/comments/delete-comment Markdown: https://www.tryadeli.com/docs/api/comments/delete-comment.md Delete a comment. # Delete comment > Delete a comment. Canonical: `DELETE https://app.tryadeli.com/api/v1/comments/{commentId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) **Path parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `commentId` | `string` | Yes | The provider's comment id: a numeric string everywhere but YouTube, whose ids are URL-safe base64. One that does not match the platform's format returns 400 invalid\_request. | **Query parameters** | Name | Type | Required | Description | | ----------- | ---------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook" \| "tiktok" \| "youtube"` | Yes | | | `accountId` | `uuid` | Yes | | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `banAuthor` | `"true" \| "false"` | | YouTube only: also hide the author's future comments on the channel, when the comment is someone else's. | Returns `204`. Deleting cannot be undone. > **Note: TikTok deletes only your own comments** > > On TikTok the account can delete only comments it wrote; anything else is > `422 comment_not_owned`. Hide other people's comments instead — a hidden > comment is visible only to its author. > **Note: YouTube rejects other people's comments** > > On YouTube the channel's own comments are deleted. Anyone else's is rejected, > which removes it for good — it cannot be published again — and, with > `banAuthor=true`, also bans its author from commenting on the channel. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `422 comment_not_owned`, `422 connection_expired`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X DELETE "https://app.tryadeli.com/api/v1/comments/7301000000000000002?platform=tiktok&accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/comments/7301000000000000002?platform=tiktok&accountId=00000000-0000-0000-0000-000000000000", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(response.status); // 204: no body ``` **Python** ```python import os import requests response = requests.delete( "https://app.tryadeli.com/api/v1/comments/7301000000000000002", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "platform": "tiktok", "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.status_code) # 204: no body ``` ## Responses ### 204 No Content ```text (empty body) ``` ### 422 ```json { "error": { "code": "comment_not_owned", "message": "TikTok can delete only comments this account wrote; hide other comments instead" } } ``` --- # Messages ## Messages URL: https://www.tryadeli.com/docs/api/messages Markdown: https://www.tryadeli.com/docs/api/messages.md Read Instagram and Facebook message history, and send text replies to both. # Messages > Read Instagram and Facebook message history, and send text replies to both. Canonical: Instagram direct messages and Facebook Page messages, normalized into one shape. Adeli refreshes history from the provider before returning it, so a read is live rather than a cache. > **Warning: Meta providers only** > > TikTok messaging is not supported, and YouTube has no messaging API at all — > its direct messages were retired in 2019. WhatsApp sends approved templates rather than > free text through the API — see [WhatsApp](https://www.tryadeli.com/docs/api/messages#whatsapp) below. > **Note: Both providers reply inside a 24-hour window** > > Meta only permits a reply within 24 hours of the person's last message, on > Instagram and on Facebook alike. When Meta refuses a send because the window > has closed, Adeli returns `409 window_closed`; you cannot open one by sending > first. ## Endpoints - `GET /api/v1/messages` — [List messages](https://www.tryadeli.com/docs/api/messages/list-messages.md) - `POST /api/v1/messages` — [Send message](https://www.tryadeli.com/docs/api/messages/send-message.md) ## WhatsApp Each profile can hold one WhatsApp number that the business connected itself, through Embedded Signup on the dashboard's **Accounts** page. It appears in `GET /api/v1/accounts` with `platform: "whatsapp"`, the phone number ID as `providerId`, and the display number as `displayIdentifier`, and its history is included in `GET /api/v1/messages`. WhatsApp history is what Adeli has received by webhook and sent, so a read makes no provider call. Through the API, WhatsApp sends one of the business's **approved templates** — the only message WhatsApp allows when starting a conversation or more than 24 hours after the customer last wrote. **WhatsApp body** | Name | Type | Required | Description | | ------------------------- | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | | `platform` | `"whatsapp"` | Yes | | | `accountId` | `uuid` | Yes | The WhatsApp account in your organization. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `recipient` | `string` | Yes | An E.164 phone number, such as `+15551234567`. | | `template.name` | `string` | Yes | An approved template on the business's WhatsApp Business Account: lowercase letters, digits, and underscores. | | `template.language` | `string` | Yes | The template's language, such as `en_US`. | | `template.bodyParameters` | `string[]` | | Values for the body's `{{1}}`, `{{2}}`, … placeholders, in order. Up to 20; omit for a template without placeholders. | ```json { "platform": "whatsapp", "accountId": "00000000-0000-0000-0000-000000000000", "recipient": "+15551234567", "template": { "name": "order_update", "language": "en_US", "bodyParameters": ["Jane", "A-42"] } } ``` Success returns `201` with `type: "template"`. A number whose setup has not finished returns `422 setup_incomplete`; one whose access the business revoked returns `422 reconnect_required`. An unknown or unapproved template fails at Meta and surfaces as `502 provider_error`. WhatsApp numbers cannot be connected through `POST /api/v1/profiles/{profileId}/connect` yet — that returns `422 provider_not_supported`. ## List messages URL: https://www.tryadeli.com/docs/api/messages/list-messages Markdown: https://www.tryadeli.com/docs/api/messages/list-messages.md Instagram, Facebook, and WhatsApp message history, oldest first. # List messages > Instagram, Facebook, and WhatsApp message history, oldest first. Canonical: `GET https://app.tryadeli.com/api/v1/messages` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns a flat array across the profile's Instagram, Facebook, and WhatsApp conversations, oldest first by `occurredAt`. Uses the [aggregate response contract](https://www.tryadeli.com/docs/concepts#partial-responses). **Query parameters** | Name | Type | Required | Description | | ----------- | ----------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook" \| "whatsapp"` | | Return one provider's messages only. `facebook` covers every connected Facebook Page's Messenger conversations. Anything else, including `tiktok` and `youtube`, returns `422 unsupported_platform`. | | `accountId` | `uuid` | | Narrow to one connected account. | | `profileId` | `uuid` | | Required unless you pass accountId, which names its profile. If you pass both, they must agree. | **Message fields** | Name | Type | Required | Description | | ------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------- | | `id` | `string` | | Adeli's identifier for this message. | | `providerId` | `string \| null` | | The provider's message id. | | `direction` | `"inbound" \| "outbound"` | | Relative to the connected account. | | `type` | `"text" \| "template" \| "unsupported"` | | `unsupported` covers media and other payloads Adeli does not normalize. | | `sender` | `string \| null` | | Set on inbound messages — the provider-scoped participant id (an IGSID on Instagram, a PSID on Facebook). | | `recipient` | `string \| null` | | Set on outbound messages. | | `status` | `string` | | Provider delivery state, such as `received` or `sent`. | | `occurredAt` | `ISO 8601` | | When the provider recorded the message. Sort on this. | If a provider sync fails, Adeli still returns the messages it has already persisted and reports the failure in `errors`, which makes the response `207`. **Errors** — `401 unauthorized`, `400 invalid_request`, `422 unsupported_platform`, `404 profile_not_found`, `404 account_not_found`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/messages?platform=instagram&accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/messages?platform=instagram&accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/messages", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "status": "complete", "messages": [ { "id": "instagram_message_aWdfbWlk", "providerId": "aWdfbWlk", "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "direction": "inbound", "type": "text", "recipient": null, "sender": "17841400000000001", "body": "Do you ship to Canada?", "status": "received", "occurredAt": "2026-04-01T12:00:00.000Z", "createdAt": "2026-04-01T12:00:01.000Z" } ], "errors": [] } ``` ### 422 ```json { "error": { "code": "unsupported_platform", "message": "Unsupported platform" } } ``` ## Send message URL: https://www.tryadeli.com/docs/api/messages/send-message Markdown: https://www.tryadeli.com/docs/api/messages/send-message.md Send a text reply on Instagram or Facebook, or a WhatsApp template. # Send message > Send a text reply on Instagram or Facebook, or a WhatsApp template. Canonical: `POST https://app.tryadeli.com/api/v1/messages` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) **Body** | Name | Type | Required | Description | | ----------- | --------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `platform` | `"instagram" \| "facebook"` | Yes | | | `accountId` | `uuid` | Yes | A connected Instagram account or Facebook Page in your organization. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `recipient` | `string` | Yes | The provider-scoped participant id, 1–200 characters. Take it from the `sender` field of an inbound message, not from a username. | | `text` | `string` | Yes | 1–1,000 characters. | A Facebook reply has the same shape. `accountId` is the connected Page, and `recipient` is the person's PSID, from the `sender` field of their inbound message: ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000002", "recipient": "6200000000000001", "text": "Yes, we deliver on Saturdays." } ``` WhatsApp sends an approved template instead of text; see [WhatsApp](https://www.tryadeli.com/docs/api/messages#whatsapp) for its body. Success returns `201` and a bare message object with `direction: "outbound"`, `type: "text"`, and `status: "sent"`. > **Note: Meta's messaging rules still apply** > > A business can message a person only inside the window Meta's policy opens, > which begins when that person messages the business. When Meta refuses a send > because that window has closed, the response is `409 window_closed`. Wait for > the person to message again before retrying. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 profile_not_found`, `404 account_not_found`, `409 window_closed`, `409 page_not_selected` (Facebook), `403 missing_permission` (Facebook), `422 connection_expired`, `422 provider_not_configured`, `422 provider_not_supported`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/messages" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "recipient": "17841400000000001", "text": "Yes, we ship to Canada." }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ platform: "instagram", accountId: "00000000-0000-0000-0000-000000000000", recipient: "17841400000000001", text: "Yes, we ship to Canada.", }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/messages", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, json={ "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "recipient": "17841400000000001", "text": "Yes, we ship to Canada.", }, ) print(response.json()) ``` ## Responses ### 201 ```json { "id": "instagram_message_aWdfbWlkMg", "providerId": "aWdfbWlkMg", "platform": "instagram", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000001", "direction": "outbound", "type": "text", "recipient": "17841400000000001", "sender": "17841400000000000", "body": "Yes, we ship to Canada.", "status": "sent", "occurredAt": "2026-04-01T12:05:00.000Z", "createdAt": "2026-04-01T12:05:00.000Z" } ``` ### 409 ```json { "error": { "code": "window_closed", "message": "Instagram's 24-hour reply window has closed for this recipient" } } ``` --- # Analytics ## Analytics URL: https://www.tryadeli.com/docs/api/analytics Markdown: https://www.tryadeli.com/docs/api/analytics.md Instagram account insights, Facebook Page metrics, TikTok profile statistics, account insights, and post insights, and YouTube channel and video insights. # Analytics > Instagram account insights, Facebook Page metrics, TikTok profile statistics, account insights, and post insights, and YouTube channel and video insights. Canonical: Platform-specific endpoints. Unlike accounts, posts, and messages, these are deliberately **not** normalized into a shared shape — the providers measure genuinely different things, and flattening them would invent numbers none of them reports. All of them are single-account reads, so none uses the aggregate envelope. ## Endpoints - `GET /api/v1/analytics/instagram/account-insights` — [Instagram account insights](https://www.tryadeli.com/docs/api/analytics/instagram-account-insights.md) - `GET /api/v1/analytics/tiktok/account-analytics` — [TikTok account analytics](https://www.tryadeli.com/docs/api/analytics/tiktok-account-analytics.md) - `GET /api/v1/analytics/tiktok/account-insights` — [TikTok account insights](https://www.tryadeli.com/docs/api/analytics/tiktok-account-insights.md) - `GET /api/v1/analytics/tiktok/video-insights` — [TikTok post insights](https://www.tryadeli.com/docs/api/analytics/tiktok-post-insights.md) - `GET /api/v1/analytics/facebook/page-metrics` — [Facebook Page metrics](https://www.tryadeli.com/docs/api/analytics/facebook-page-metrics.md) - `GET /api/v1/analytics/youtube/channel-insights` — [YouTube channel insights](https://www.tryadeli.com/docs/api/analytics/youtube-channel-insights.md) - `GET /api/v1/analytics/youtube/video-insights` — [YouTube video insights](https://www.tryadeli.com/docs/api/analytics/youtube-video-insights.md) ## Not available Instagram media-level insights and Facebook per-post insights are not part of v1. TikTok and YouTube per-post insights are: [TikTok post insights](https://www.tryadeli.com/docs/api/analytics/tiktok-post-insights) and [YouTube video insights](https://www.tryadeli.com/docs/api/analytics/youtube-video-insights). ## Instagram account insights URL: https://www.tryadeli.com/docs/api/analytics/instagram-account-insights Markdown: https://www.tryadeli.com/docs/api/analytics/instagram-account-insights.md Views, reach, interactions, and accounts engaged over 28 days. # Instagram account insights > Views, reach, interactions, and accounts engaged over 28 days. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/instagram/account-insights` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns Instagram's own insight records for a rolling 28-day window, covering `views`, `reach`, `total_interactions`, and `accounts_engaged`. **Query parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | A connected Instagram account. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | > **Note: An empty insights array is normal** > > Instagram omits metrics entirely for accounts with too few followers or too > little history, and its data lags by up to 48 hours. Render a missing metric as > unavailable rather than as zero — they mean different things. `insights` is Instagram's array, passed through unchanged. Adeli does not store history, so each call is a live read. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 profile_not_found`, `422 connection_expired`, `422 provider_not_configured`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/instagram/account-insights?accountId=00000000-0000-4000-8000-000000000003" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/instagram/account-insights?accountId=00000000-0000-4000-8000-000000000003", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/instagram/account-insights", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "instagram", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "insights": [{ "name": "reach", "values": [] }], "fetchedAt": "2026-04-01T12:00:00.000Z" } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "Instagram connection has expired" } } ``` ## TikTok account analytics URL: https://www.tryadeli.com/docs/api/analytics/tiktok-account-analytics Markdown: https://www.tryadeli.com/docs/api/analytics/tiktok-account-analytics.md A TikTok account's profile and lifetime counters. # TikTok account analytics > A TikTok account's profile and lifetime counters. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/tiktok/account-analytics` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns the TikTok account's profile and lifetime counters, read from TikTok API for Business. For daily figures and audience demographics use [TikTok account insights](https://www.tryadeli.com/docs/api/analytics/tiktok-account-insights). **Query parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | A connected TikTok account. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | `openId` is TikTok's app-scoped id for the account. `isBusinessAccount` tells a TikTok Business Account from a personal one; some insight fields in [TikTok account insights](https://www.tryadeli.com/docs/api/analytics/tiktok-account-insights) exist only for business accounts. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 profile_not_found`, `422 connection_expired`, `422 provider_not_configured`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/tiktok/account-analytics?accountId=00000000-0000-4000-8000-000000000003" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/tiktok/account-analytics?accountId=00000000-0000-4000-8000-000000000003", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/tiktok/account-analytics", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "profile": { "openId": "app-scoped-open-id", "username": "creator", "displayName": "Creator", "avatarUrl": "https://example.com/avatar.jpg", "profileDeepLink": "https://www.tiktok.com/@creator", "bioDescription": "Profile bio", "isVerified": false, "isBusinessAccount": true }, "analytics": { "followers": 1200, "following": 80, "likes": 5400, "videos": 42 }, "fetchedAt": "2026-04-01T12:00:00.000Z" } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "TikTok account not found" } } ``` ### 429 ```json { "error": { "code": "rate_limited", "message": "TikTok rate limit reached; retry later" } } ``` ## TikTok account insights URL: https://www.tryadeli.com/docs/api/analytics/tiktok-account-insights Markdown: https://www.tryadeli.com/docs/api/analytics/tiktok-account-insights.md Daily metrics and follower demographics for a TikTok account. # TikTok account insights > Daily metrics and follower demographics for a TikTok account. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/tiktok/account-insights` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns the account's daily metrics and follower demographics for a window of complete UTC days. Needs the `user.insights` permission, which every connection made since Adeli moved to TikTok API for Business has granted. **Query parameters** | Name | Type | Required | Description | | ----------- | ------------ | -------- | ---------------------------------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | A connected TikTok account. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `startDate` | `YYYY-MM-DD` | | First day, UTC. At most 60 days ago. Give both dates or neither; the default is the last 28 complete days. | | `endDate` | `YYYY-MM-DD` | | Last day, UTC, before today and on or after startDate. | > **Note: null means TikTok did not report it** > > TikTok's daily figures lag by 24 to 48 hours and exist only while Analytics is > turned on in the TikTok app. Unique views, follower gains and losses, engaged > audience, and `activity` are reported for Business Accounts only; the click > metrics need a verified or registered business; demographics and `activity` > need at least 100 followers. Anything TikTok withholds is `null` or an empty > list, never `0`. `activity` is when followers were active, by hour. `percentage` values are fractions of 1. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 profile_not_found`, `422 connection_expired` (also returned when the connection lacks `user.insights`; reconnecting fixes it), `422 provider_not_configured`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/tiktok/account-insights?accountId=00000000-0000-4000-8000-000000000003&startDate=2026-09-01&endDate=2026-09-28" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/tiktok/account-insights?accountId=00000000-0000-4000-8000-000000000003&startDate=2026-09-01&endDate=2026-09-28", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/tiktok/account-insights", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", "startDate": "2026-09-01", "endDate": "2026-09-28", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "isBusinessAccount": true, "startDate": "2026-09-01", "endDate": "2026-09-28", "daily": [ { "date": "2026-09-01", "videoViews": 1126, "uniqueVideoViews": 900, "profileViews": 40, "likes": 26, "comments": 4, "shares": 1, "followers": 1204, "newFollowers": 6, "lostFollowers": 2, "engagedAudience": 120, "bioLinkClicks": 3, "emailClicks": 0, "phoneNumberClicks": 0, "addressClicks": 0, "appDownloadClicks": 0, "leadSubmissions": 0, "activity": [{ "hour": "18", "count": 52 }] } ], "audience": { "ages": [{ "key": "18-24", "percentage": 0.42 }], "genders": [{ "key": "Female", "percentage": 0.6 }], "countries": [{ "key": "US", "percentage": 0.75 }], "cities": [{ "key": "Austin", "percentage": 0.08 }] }, "fetchedAt": "2026-09-29T12:00:00.000Z" } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "endDate must be before today (UTC); TikTok reports complete days only", "details": [ { "code": "custom", "message": "endDate must be before today (UTC); TikTok reports complete days only", "path": ["endDate"] } ] } } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "TikTok connection requires reconnecting" } } ``` ## TikTok post insights URL: https://www.tryadeli.com/docs/api/analytics/tiktok-post-insights Markdown: https://www.tryadeli.com/docs/api/analytics/tiktok-post-insights.md Counters and watch metrics for TikTok posts. # TikTok post insights > Counters and watch metrics for TikTok posts. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/tiktok/video-insights` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns counters and watch metrics for specific TikTok posts, or for the most recent page of posts when `videoIds` is omitted. Needs the `video.insights` permission. **Query parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------ | | `accountId` | `uuid` | Yes | A connected TikTok account. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `videoIds` | `string` | | Comma-separated TikTok post ids (providerId from GET /api/v1/posts, or postIds from a publish status), at most 20. | A post that has barely been watched may have no `reach` or watch metrics yet; those come back `null` or empty. `coverImageUrl` is a signed TikTok URL that expires, so fetch it again rather than storing it. TikTok stops updating a post's figures 365 days after it is published. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 profile_not_found`, `422 connection_expired` (also returned when the connection lacks `video.insights`), `422 provider_not_configured`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/tiktok/video-insights?accountId=00000000-0000-4000-8000-000000000003&videoIds=6990565363377392901" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/tiktok/video-insights?accountId=00000000-0000-4000-8000-000000000003&videoIds=6990565363377392901", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/tiktok/video-insights", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", "videoIds": "6990565363377392901", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "tiktok", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "videos": [ { "id": "6990565363377392901", "mediaType": "VIDEO", "isAd": false, "caption": "little coco", "coverImageUrl": "https://p16-sign-va.tiktokcdn.com/...", "shareUrl": "https://www.tiktok.com/@creator/video/6990565363377392901", "embedUrl": "https://www.tiktok.com/player/v1/6990565363377392901", "durationSeconds": 20, "likeCount": 10, "commentCount": 2, "shareCount": 0, "favoriteCount": 1, "viewCount": 1231, "reach": 154, "createdAt": "2021-07-30T04:03:55.000Z", "insights": { "totalTimeWatchedSeconds": 695.9, "averageTimeWatchedSeconds": 4.1, "fullVideoWatchedRate": 0.0237, "newFollowers": 2, "profileViews": 9, "retention": [{ "second": "1", "percentage": 0.82 }], "impressionSources": [{ "key": "For You", "percentage": 0.8983 }], "audienceGenders": [], "audienceCountries": [{ "key": "US", "percentage": 0.7574 }], "audienceCities": [], "audienceTypes": [] } } ], "fetchedAt": "2026-09-29T12:00:00.000Z" } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "TikTok connection requires reconnecting" } } ``` ## Facebook Page metrics URL: https://www.tryadeli.com/docs/api/analytics/facebook-page-metrics Markdown: https://www.tryadeli.com/docs/api/analytics/facebook-page-metrics.md A Page's audience, engagement on recent posts, and 28-day insights. # Facebook Page metrics > A Page's audience, engagement on recent posts, and 28-day insights. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/facebook/page-metrics` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns the Page's own details and audience size, engagement totalled across its recent posts, and Page Insights for the last 28 days. **Query parameters** | Name | Type | Required | Description | | ----------- | ------ | -------- | ----------------------------------------------------------------------------------------------- | | `accountId` | `uuid` | | A connected Facebook Page. Defaults to the profile's Page. | | `profileId` | `uuid` | | Required unless you pass accountId, which names its profile. If you pass both, they must agree. | **insights** | Name | Type | Required | Description | | ------------- | ---------------- | -------- | ---------------------------------------------------------------------------------------------- | | `views` | `number \| null` | | Times the Page's content was on screen over the window (Meta's page\_media\_view), summed. | | `engagements` | `number \| null` | | Reactions, comments, shares, and clicks on the Page's posts (page\_post\_engagements), summed. | | `pageViews` | `number \| null` | | Visits to the Page itself (page\_views\_total), summed. | | `followers` | `number \| null` | | Followers on the latest day Meta reported (page\_follows). | | `daily` | `array` | | Daily views, oldest first, as { date, value }. | > **Note: When insights is null** > > Page Insights need the `read_insights` permission. A Facebook connection made > before Adeli requested it, or one where it was declined, returns > `"insights": null` with `"insightsUnavailable": "permission_missing"`; > reconnecting Facebook fixes it. `"analyze_task_missing"` means Meta refused > even with the permission, because the person who connected the Page cannot > view its insights. `"provider_error"` means Meta failed the request. The rest > of the response is returned either way. A figure Meta did not report is > `null`, not `0`; small or new Pages often have none yet. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `409 page_not_selected`, `422 connection_expired`, `422 provider_not_configured`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/facebook/page-metrics?accountId=00000000-0000-4000-8000-000000000003" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/facebook/page-metrics?accountId=00000000-0000-4000-8000-000000000003", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/facebook/page-metrics", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "page": { "id": "1234567890", "name": "Jasper’s Market", "username": "jaspers", "category": "Grocery Store", "link": "https://www.facebook.com/jaspers" }, "metrics": { "followers": 980, "fans": 960, "recentPosts": 10, "recentPostLikes": 42, "recentPostComments": 7, "recentPostShares": 3 }, "insights": { "periodDays": 28, "views": 4200, "engagements": 310, "pageViews": 75, "followers": 990, "daily": [{ "date": "2026-09-20T07:00:00+0000", "value": 150 }] }, "insightsUnavailable": null, "fetchedAt": "2026-09-21T12:00:00.000Z" } ``` ### 409 ```json { "error": { "code": "page_not_selected", "message": "Choose a Facebook Page first" } } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "Facebook account not found" } } ``` ## YouTube channel insights URL: https://www.tryadeli.com/docs/api/analytics/youtube-channel-insights Markdown: https://www.tryadeli.com/docs/api/analytics/youtube-channel-insights.md Daily metrics, top videos, and demographics for a YouTube channel. # YouTube channel insights > Daily metrics, top videos, and demographics for a YouTube channel. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/youtube/channel-insights` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns the channel's stored details and, from YouTube Analytics, its daily metrics, top ten videos, and viewer demographics over a window. YouTube's figures lag by two to three days, so the default window is the 28 days ending three days ago. Needs the `yt-analytics.readonly` grant; a connection without it returns `422 connection_expired` with "Reconnect YouTube to enable analytics". **Query parameters** | Name | Type | Required | Description | | ----------- | ------------ | -------- | --------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | A connected YouTube channel. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `startDate` | `YYYY-MM-DD` | | First day, UTC. Give both dates or neither. A window covers at most 365 days. | | `endDate` | `YYYY-MM-DD` | | Last day, UTC, before today and on or after startDate. | `channel.subscribers` is `null` when the channel hides its subscriber count. YouTube withholds demographics for channels with too few viewers, so `audience` can be empty. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 profile_not_found`, `422 connection_expired`, `422 provider_not_configured`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/youtube/channel-insights?accountId=00000000-0000-4000-8000-000000000003&startDate=2026-09-05&endDate=2026-10-02" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/youtube/channel-insights?accountId=00000000-0000-4000-8000-000000000003&startDate=2026-09-05&endDate=2026-10-02", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/youtube/channel-insights", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", "startDate": "2026-09-05", "endDate": "2026-10-02", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "youtube", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "channel": { "channelId": "UCxxxxxxxxxxxxxxxxxxxxxx", "title": "Bird Channel", "handle": "@birds", "avatarUrl": "https://yt3.ggpht.com/...", "subscribers": 1500, "views": 90000, "videos": 12 }, "insights": { "startDate": "2026-09-05", "endDate": "2026-10-02", "daily": [ { "date": "2026-10-02", "views": 600, "minutesWatched": 1100, "averageViewDurationSeconds": 70, "subscribersGained": 5, "subscribersLost": 1, "likes": 20, "comments": 2, "shares": 0 } ], "topVideos": [{ "videoId": "dQw4w9WgXcQ", "views": 400, "minutesWatched": 800, "averageViewDurationSeconds": 65, "likes": 15, "comments": 2 }], "audience": { "ageGender": [{ "ageGroup": "age25-34", "gender": "female", "percentage": 31.5 }], "countries": [{ "country": "US", "views": 750 }] } }, "fetchedAt": "2026-10-05T12:00:00.000Z" } ``` ### 422 ```json { "error": { "code": "connection_expired", "message": "Reconnect YouTube to enable analytics" } } ``` ## YouTube video insights URL: https://www.tryadeli.com/docs/api/analytics/youtube-video-insights Markdown: https://www.tryadeli.com/docs/api/analytics/youtube-video-insights.md Watch metrics for specific YouTube videos. # YouTube video insights > Watch metrics for specific YouTube videos. Canonical: `GET https://app.tryadeli.com/api/v1/analytics/youtube/video-insights` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Returns watch metrics for specific videos, or for the channel's 20 newest, over the same kind of window as [channel insights](https://www.tryadeli.com/docs/api/analytics/youtube-channel-insights). Needs `yt-analytics.readonly`, like channel insights. **Query parameters** | Name | Type | Required | Description | | ----------- | ------------ | -------- | ---------------------------------------------------------------------------------------------------------- | | `accountId` | `uuid` | Yes | A connected YouTube channel. | | `profileId` | `uuid` | | Optional, because accountId names its profile. If you pass both, they must agree. | | `videoIds` | `string` | | Comma-separated YouTube video ids (providerId from GET /api/v1/posts), at most 50. Omit for the 20 newest. | | `startDate` | `YYYY-MM-DD` | | Give both dates or neither; the default is the 28 days ending three days ago. | | `endDate` | `YYYY-MM-DD` | | | A video YouTube has no figures for in the window comes back with every metric `null`. **Errors** — `401 unauthorized`, `400 invalid_request`, `404 account_not_found`, `404 profile_not_found`, `422 connection_expired`, `422 provider_not_configured`, `429 rate_limited`, `502 provider_error`. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/analytics/youtube/video-insights?accountId=00000000-0000-4000-8000-000000000003&videoIds=dQw4w9WgXcQ" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/analytics/youtube/video-insights?accountId=00000000-0000-4000-8000-000000000003&videoIds=dQw4w9WgXcQ", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/analytics/youtube/video-insights", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-4000-8000-000000000003", "videoIds": "dQw4w9WgXcQ", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "youtube", "accountId": "00000000-0000-4000-8000-000000000003", "profileId": "00000000-0000-4000-8000-000000000001", "insights": { "startDate": "2026-09-05", "endDate": "2026-10-02", "videos": [ { "videoId": "dQw4w9WgXcQ", "views": 400, "minutesWatched": 800, "averageViewDurationSeconds": 65, "averageViewPercentage": 54.2, "likes": 15, "comments": 2, "shares": 1, "subscribersGained": 3 } ] }, "fetchedAt": "2026-10-05T12:00:00.000Z" } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "YouTube account not found" } } ``` --- # Ads ## Ads URL: https://www.tryadeli.com/docs/api/ads Markdown: https://www.tryadeli.com/docs/api/ads.md Facebook ad accounts, campaign performance, creating campaigns from Page posts, and lead form submissions. # Ads > Facebook ad accounts, campaign performance, creating campaigns from Page posts, and lead form submissions. Canonical: Facebook advertising, on the same connection as Pages. These are platform-specific routes rather than a normalized surface: campaign structure has no equivalent on the other providers Adeli covers. Every endpoint needs a Facebook connection whose authorization includes the ads permissions. A connection made before those were requested keeps working for Pages and answers `403 ads_permission_required` here, with the missing scopes in `error.details.missingScopes`; the fix is another authorization, not a repair. > **Note:** > > Campaign and metric reads are live. Adeli stores no campaign structure and no > metrics, so every figure is as fresh as that response. The one exception is the > list of ad accounts, which is reused for up to 15 minutes. When Meta throttles a request, the response is `429 rate_limited` with a `Retry-After` header, `error.details.retryAfterSeconds` (Meta's own estimate when it gives one, otherwise a minute) and `error.details.limit`: `app` when the app's hourly call budget for this connection is spent, `ad_account` when that ad account's own budget is. When Meta refuses a request and explains why, the response is `422 provider_rejected` with Meta's explanation in `error.message` — for example that a Business portfolio has reached its limit of ad accounts. ## Endpoints - `GET /api/v1/ads/accounts` — [List ad accounts](https://www.tryadeli.com/docs/api/ads/list-ad-accounts.md) - `POST /api/v1/ads/accounts` — [Create ad account](https://www.tryadeli.com/docs/api/ads/create-ad-account.md) - `GET /api/v1/ads/businesses` — [List businesses](https://www.tryadeli.com/docs/api/ads/list-businesses.md) - `GET /api/v1/ads/accounts/{adAccountId}` — [Get ad account](https://www.tryadeli.com/docs/api/ads/get-ad-account.md) - `PATCH /api/v1/ads/accounts/{adAccountId}` — [Update ad account](https://www.tryadeli.com/docs/api/ads/update-ad-account.md) - `GET /api/v1/ads/tree` — [Campaign tree](https://www.tryadeli.com/docs/api/ads/get-campaign-tree.md) - `GET /api/v1/ads/insights` — [Daily insights](https://www.tryadeli.com/docs/api/ads/get-insights.md) - `POST /api/v1/ads/campaigns` — [Create campaign](https://www.tryadeli.com/docs/api/ads/create-campaign.md) - `DELETE /api/v1/ads/campaigns/{campaignId}` — [Delete campaign](https://www.tryadeli.com/docs/api/ads/delete-campaign.md) - `PUT /api/v1/ads/status` — [Update ad status](https://www.tryadeli.com/docs/api/ads/update-status.md) - `GET /api/v1/ads/leads` — [Lead forms and leads](https://www.tryadeli.com/docs/api/ads/list-leads.md) ## List ad accounts URL: https://www.tryadeli.com/docs/api/ads/list-ad-accounts Markdown: https://www.tryadeli.com/docs/api/ads/list-ad-accounts.md Every ad account the Facebook connection can reach. # List ad accounts > Every ad account the Facebook connection can reach. Canonical: `GET https://app.tryadeli.com/api/v1/ads/accounts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Every ad account the connection can reach, both directly assigned and owned by a Business portfolio the authorizing person administers. `businessId` is `null` for a directly-assigned account. The list is reused for up to 15 minutes; `fetchedAt` is when the oldest part of it was read from Meta. Pass `refresh=true` after granting access to a new ad account to see it immediately. Other endpoints re-list on their own when given an ad account id the stored list does not contain. **Query parameters** | Name | Type | Required | Description | | ----------- | --------- | -------- | ----------------------------------------------------------------------------------------------- | | `accountId` | `uuid` | | A connected Facebook account. Defaults to the profile's. | | `profileId` | `uuid` | | Required unless you pass accountId, which names its profile. If you pass both, they must agree. | | `refresh` | `boolean` | | true to ask Meta again instead of using the stored list. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/ads/accounts?accountId=00000000-0000-0000-0000-000000000000&refresh=true" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/accounts?accountId=00000000-0000-0000-0000-000000000000&refresh=true", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/ads/accounts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", "refresh": True, }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "adAccounts": [ { "id": "act_1234567890", "accountId": "1234567890", "name": "Client A — Ads", "currency": "USD", "timezoneName": "America/New_York", "accountStatus": 1, "minDailyBudget": 100, "businessId": "1122334455", "businessName": "Client A" }, { "id": "act_9876543210", "accountId": "9876543210", "name": "Personal ad account", "currency": "USD", "timezoneName": "America/Los_Angeles", "accountStatus": 1, "minDailyBudget": 100, "businessId": null, "businessName": null } ], "fetchedAt": "2026-10-09T16:00:00.000Z" } ``` ### 403 ```json { "error": { "code": "ads_permission_required", "message": "This connection has not granted the Facebook ads permissions", "details": { "missingScopes": ["ads_read", "ads_management"] } } } ``` ## Create ad account URL: https://www.tryadeli.com/docs/api/ads/create-ad-account Markdown: https://www.tryadeli.com/docs/api/ads/create-ad-account.md Create an ad account in a Business portfolio. # Create ad account > Create an ad account in a Business portfolio. Canonical: `POST https://app.tryadeli.com/api/v1/ads/accounts` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Creates an ad account in a Business portfolio the authorizing person administers. Meta has no API for personal ad accounts. The currency and time zone cannot be changed afterwards, and a portfolio may only hold a limited number of ad accounts — commonly one until it has spend history — which Meta reports as `422 provider_rejected`. An `Idempotency-Key` header is required, since a retry would be a second ad account: the same key replays `200` with the same account, `409 ad_account_create_failed` if it failed, or `409 ad_account_create_in_progress`. A new account is `201`, and the account list is refreshed to include it. **Body** | Name | Type | Required | Description | | ------------ | -------- | -------- | ------------------------------------------------------------------------------------------- | | `businessId` | `string` | Yes | A portfolio from GET `/api/v1/ads/businesses`. | | `name` | `string` | Yes | Up to 100 characters. | | `timezone` | `string` | Yes | An IANA zone Adeli maps to Meta's time zone id, such as America/New\_York or Europe/London. | | `currency` | `string` | | ISO 4217. Defaults to USD. | ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/ads/accounts?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: client-a-ad-account" \ -d '{ "businessId": "1122334455", "name": "Client A — Ads", "timezone": "America/New_York", "currency": "USD" }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/accounts?accountId=00000000-0000-0000-0000-000000000000", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "client-a-ad-account", }, body: JSON.stringify({ businessId: "1122334455", name: "Client A — Ads", timezone: "America/New_York", currency: "USD", }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/ads/accounts", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}", "Idempotency-Key": "client-a-ad-account"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, json={ "businessId": "1122334455", "name": "Client A — Ads", "timezone": "America/New_York", "currency": "USD", }, ) print(response.json()) ``` ## Responses ### 201 ```json { "adAccount": { "id": "act_1234567890", "accountId": "1234567890", "name": "Client A — Ads", "currency": "USD", "timezoneName": "America/New_York", "accountStatus": 1, "minDailyBudget": 100, "businessId": "1122334455", "businessName": "Client A" }, "replayed": false } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "An Idempotency-Key header is required" } } ``` ### 409 ```json { "error": { "code": "ad_account_create_in_progress", "message": "This ad account is still being created", "details": { "replayed": true } } } ``` ## List businesses URL: https://www.tryadeli.com/docs/api/ads/list-businesses Markdown: https://www.tryadeli.com/docs/api/ads/list-businesses.md The Business portfolios an ad account can be created in. # List businesses > The Business portfolios an ad account can be created in. Canonical: `GET https://app.tryadeli.com/api/v1/ads/businesses` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) The portfolios the authorizing person administers — the ones an ad account can be created in. ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/ads/businesses?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/businesses?accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/ads/businesses", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "businesses": [ { "id": "1122334455", "name": "Client A" } ] } ``` ### 403 ```json { "error": { "code": "ads_permission_required", "message": "This connection has not granted the Facebook ads permissions", "details": { "missingScopes": ["business_management"] } } } ``` ## Get ad account URL: https://www.tryadeli.com/docs/api/ads/get-ad-account Markdown: https://www.tryadeli.com/docs/api/ads/get-ad-account.md One ad account with its status, spend, and spending limit. # Get ad account > One ad account with its status, spend, and spending limit. Canonical: `GET https://app.tryadeli.com/api/v1/ads/accounts/{adAccountId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) The account as listed, plus `details`: `accountStatus`, `disableReason`, `amountSpent` and `spendCap` in whole currency units (`spendCap` is `null` when there is none), and `hasPaymentMethod` — `null` when Meta withholds it, which it does unless the person has the MANAGE task on the account. Always read live. **Path parameters** | Name | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------- | | `adAccountId` | `string` | Yes | An ad account id, in Meta's act\_ form. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/ads/accounts/act_1234567890?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/accounts/act_1234567890?accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/ads/accounts/act_1234567890", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "adAccount": { "id": "act_1234567890", "accountId": "1234567890", "name": "Client A — Ads", "currency": "USD", "timezoneName": "America/New_York", "accountStatus": 1, "minDailyBudget": 100, "businessId": "1122334455", "businessName": "Client A" }, "details": { "id": "act_1234567890", "currency": "USD", "accountStatus": 1, "disableReason": 0, "spendCap": 500, "amountSpent": 182.4, "hasPaymentMethod": true } } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "Ad account is not available to this connection" } } ``` ## Update ad account URL: https://www.tryadeli.com/docs/api/ads/update-ad-account Markdown: https://www.tryadeli.com/docs/api/ads/update-ad-account.md Rename an ad account or set its spending limit. # Update ad account > Rename an ad account or set its spending limit. Canonical: `PATCH https://app.tryadeli.com/api/v1/ads/accounts/{adAccountId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Renames an account or sets its spending limit — Meta's hard stop, counted from the moment it is set: once the account has spent that much, all its ads stop delivering. Returns the same shape as the [details endpoint](https://www.tryadeli.com/docs/api/ads/get-ad-account). **Path parameters** | Name | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------- | | `adAccountId` | `string` | Yes | An ad account id, in Meta's act\_ form. | **Body** | Name | Type | Required | Description | | ---------- | ---------------- | -------- | --------------------------------------------- | | `name` | `string` | | Up to 100 characters. | | `spendCap` | `number \| null` | | Whole currency units; null removes the limit. | Payment methods and closing an account have no API; do those in Meta. ## Request examples **cURL** ```bash curl --fail-with-body -X PATCH "https://app.tryadeli.com/api/v1/ads/accounts/act_1234567890?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "spendCap": 500 }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/accounts/act_1234567890?accountId=00000000-0000-0000-0000-000000000000", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ spendCap: 500, }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.patch( "https://app.tryadeli.com/api/v1/ads/accounts/act_1234567890", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, json={ "spendCap": 500, }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "adAccount": { "id": "act_1234567890", "accountId": "1234567890", "name": "Client A — Ads", "currency": "USD", "timezoneName": "America/New_York", "accountStatus": 1, "minDailyBudget": 100, "businessId": "1122334455", "businessName": "Client A" }, "details": { "id": "act_1234567890", "currency": "USD", "accountStatus": 1, "disableReason": 0, "spendCap": 500, "amountSpent": 182.4, "hasPaymentMethod": true } } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "Invalid ad account update", "details": [ { "code": "custom", "path": [], "message": "Provide a name or a spendCap" } ] } } ``` ## Campaign tree URL: https://www.tryadeli.com/docs/api/ads/get-campaign-tree Markdown: https://www.tryadeli.com/docs/api/ads/get-campaign-tree.md Campaigns, ad sets, and ads with performance rolled up at every level. # Campaign tree > Campaigns, ad sets, and ads with performance rolled up at every level. Canonical: `GET https://app.tryadeli.com/api/v1/ads/tree` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) The Campaign → Ad set → Ad hierarchy for one ad account, with `spend`, `impressions`, `reach`, `clicks`, `ctr`, `cpc` and `cpm` rolled up at every level. An object that did not deliver in the range reports zeroes rather than being omitted. **Query parameters** | Name | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- | | `adAccountId` | `string` | Yes | An ad account id, in Meta's act\_ form. | | `since` | `date` | | YYYY-MM-DD. Defaults to 28 days ago. | | `until` | `date` | | YYYY-MM-DD. Defaults to today. Ranges over 730 days are rejected. | | `accountId` | `uuid` | | A connected Facebook account. | | `profileId` | `uuid` | | Required unless you pass accountId, which names its profile. If you pass both, they must agree. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/ads/tree?adAccountId=act_1234567890&since=2026-09-12&until=2026-10-09&accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/tree?adAccountId=act_1234567890&since=2026-09-12&until=2026-10-09&accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/ads/tree", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "adAccountId": "act_1234567890", "since": "2026-09-12", "until": "2026-10-09", "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "adAccount": { "id": "act_1234567890", "accountId": "1234567890", "name": "Client A — Ads", "currency": "USD", "timezoneName": "America/New_York", "accountStatus": 1, "minDailyBudget": 100, "businessId": "1122334455", "businessName": "Client A" }, "campaigns": [ { "id": "120210000000000001", "name": "Fall launch", "status": "ACTIVE", "effectiveStatus": "ACTIVE", "metrics": { "spend": 182.4, "impressions": 41250, "reach": 30110, "clicks": 912, "ctr": 2.210909, "cpc": 0.2, "cpm": 4.421818, "conversions": 0 }, "objective": "OUTCOME_ENGAGEMENT", "adSets": [ { "id": "120210000000000002", "name": "Fall launch — ad set", "status": "ACTIVE", "effectiveStatus": "ACTIVE", "metrics": { "spend": 182.4, "impressions": 41250, "reach": 30110, "clicks": 912, "ctr": 2.210909, "cpc": 0.2, "cpm": 4.421818, "conversions": 0 }, "ads": [ { "id": "120210000000000004", "name": "Fall launch — ad", "status": "ACTIVE", "effectiveStatus": "ACTIVE", "metrics": { "spend": 182.4, "impressions": 41250, "reach": 30110, "clicks": 912, "ctr": 2.210909, "cpc": 0.2, "cpm": 4.421818, "conversions": 0 } } ] } ] } ], "range": { "since": "2026-09-12", "until": "2026-10-09" }, "fetchedAt": "2026-10-09T16:00:00.000Z" } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "since and until must be YYYY-MM-DD, with since on or before until" } } ``` ### 429 ```json { "error": { "code": "rate_limited", "message": "Facebook is rate limiting this ad account; try again in a minute", "details": { "retryAfterSeconds": 60, "limit": "ad_account" } } } ``` ## Daily insights URL: https://www.tryadeli.com/docs/api/ads/get-insights Markdown: https://www.tryadeli.com/docs/api/ads/get-insights.md One point per day for an ad account, campaign, ad set, or ad. # Daily insights > One point per day for an ad account, campaign, ad set, or ad. Canonical: `GET https://app.tryadeli.com/api/v1/ads/insights` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) One point per day for an ad account, or for a single campaign, ad set or ad when `objectId` is given. **Query parameters** | Name | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------------------- | | `adAccountId` | `string` | Yes | An ad account id. | | `objectId` | `string` | | A campaign, ad set or ad id. Defaults to the whole account. | | `since` | `date` | | YYYY-MM-DD. Defaults to 28 days ago. | | `until` | `date` | | YYYY-MM-DD. Defaults to today. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/ads/insights?adAccountId=act_1234567890&objectId=120210000000000001&since=2026-10-07&until=2026-10-08&accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/insights?adAccountId=act_1234567890&objectId=120210000000000001&since=2026-10-07&until=2026-10-08&accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/ads/insights", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "adAccountId": "act_1234567890", "objectId": "120210000000000001", "since": "2026-10-07", "until": "2026-10-08", "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "adAccountId": "act_1234567890", "objectId": "120210000000000001", "points": [ { "date": "2026-10-07", "spend": 24.81, "impressions": 5630, "clicks": 121, "conversions": 0 }, { "date": "2026-10-08", "spend": 25.02, "impressions": 5874, "clicks": 133, "conversions": 0 } ], "range": { "since": "2026-10-07", "until": "2026-10-08" }, "fetchedAt": "2026-10-09T16:00:00.000Z" } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "adAccountId is required" } } ``` ## Create campaign URL: https://www.tryadeli.com/docs/api/ads/create-campaign Markdown: https://www.tryadeli.com/docs/api/ads/create-campaign.md Promote a published Page post as a paused campaign. # Create campaign > Promote a published Page post as a paused campaign. Canonical: `POST https://app.tryadeli.com/api/v1/ads/campaigns` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Promotes a post the connected Page already published, creating a campaign, ad set, creative and ad. **Everything is created paused.** Nothing delivers and nothing spends until you activate it, which is [a separate call](https://www.tryadeli.com/docs/api/ads/update-status). An `Idempotency-Key` header is required. This is the only endpoint in the API where a retry has a cost, so the same key replays the original result rather than creating a second campaign: `200` with the same object ids when the campaign was built, `409 campaign_create_failed` with the original reason when it failed, and `409 campaign_create_in_progress` while it is still being created. To retry a failed create, send a new key. **Body** | Name | Type | Required | Description | | -------------- | -------- | -------- | ---------------------------------------------------------------------- | | `adAccountId` | `string` | Yes | The ad account to create in. | | `pagePostId` | `string` | Yes | The post id alone; the Page half of Meta's story id is server-derived. | | `dailyBudget` | `number` | Yes | Whole currency units, as Meta expects: 25 means 25.00. | | `startDate` | `date` | Yes | YYYY-MM-DD. | | `endDate` | `date` | Yes | YYYY-MM-DD, on or after startDate. | | `campaignName` | `string` | Yes | Shown in Ads Manager. | Responds `201` with the created object ids, Adeli's `campaign` record (its `id` is the `campaignRecordId` used by [Activate, pause or resume](https://www.tryadeli.com/docs/api/ads/update-status)), and a `preview` when Meta returns one: Meta's `body` markup, plus the `url`, `width` and `height` of the facebook.com preview page it wraps. Embed `url` in an iframe that allows scripts; the page renders blank without them. If a step fails, Adeli deletes what it already created and responds `502 campaign_create_failed` with the failing `step` and any `orphanedObjectIds` it could not remove — those are paused, so they cost nothing, but they are named so you can find them in Ads Manager. ## Request examples **cURL** ```bash curl --fail-with-body -X POST "https://app.tryadeli.com/api/v1/ads/campaigns?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: fall-launch-boost" \ -d '{ "adAccountId": "act_1234567890", "pagePostId": "987654321098765", "dailyBudget": 25, "startDate": "2026-11-01", "endDate": "2026-11-07", "campaignName": "Fall launch" }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/campaigns?accountId=00000000-0000-0000-0000-000000000000", { method: "POST", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "fall-launch-boost", }, body: JSON.stringify({ adAccountId: "act_1234567890", pagePostId: "987654321098765", dailyBudget: 25, startDate: "2026-11-01", endDate: "2026-11-07", campaignName: "Fall launch", }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.post( "https://app.tryadeli.com/api/v1/ads/campaigns", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}", "Idempotency-Key": "fall-launch-boost"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, json={ "adAccountId": "act_1234567890", "pagePostId": "987654321098765", "dailyBudget": 25, "startDate": "2026-11-01", "endDate": "2026-11-07", "campaignName": "Fall launch", }, ) print(response.json()) ``` ## Responses ### 201 ```json { "campaign": { "id": "00000000-0000-4000-8000-000000000020", "profileId": "00000000-0000-4000-8000-000000000002", "facebookConnectionId": "00000000-0000-0000-0000-000000000000", "adAccountId": "act_1234567890", "pageId": "123456789012345", "pagePostId": "987654321098765", "idempotencyKey": "fall-launch-boost", "campaignId": "120210000000000001", "adSetId": "120210000000000002", "creativeId": "120210000000000003", "adId": "120210000000000004", "status": "paused", "failureReason": null, "createdAt": "2026-10-09T16:00:00.000Z", "updatedAt": "2026-10-09T16:00:04.000Z" }, "objects": { "campaignId": "120210000000000001", "adSetId": "120210000000000002", "creativeId": "120210000000000003", "adId": "120210000000000004" }, "preview": { "format": "MOBILE_FEED_STANDARD", "body": "", "url": "https://www.facebook.com/ads/api/preview_iframe.php?d=AQ...&t=AQ...", "width": 320, "height": 570 }, "replayed": false } ``` ### 400 ```json { "error": { "code": "invalid_request", "message": "Invalid campaign request", "details": [ { "code": "custom", "path": ["startDate"], "message": "startDate must be tomorrow (UTC) or later" } ] } } ``` ### 409 ```json { "error": { "code": "campaign_create_in_progress", "message": "This campaign is still being created", "details": { "campaignRecordId": "00000000-0000-4000-8000-000000000020", "replayed": true } } } ``` ## Delete campaign URL: https://www.tryadeli.com/docs/api/ads/delete-campaign Markdown: https://www.tryadeli.com/docs/api/ads/delete-campaign.md Delete a campaign with its ad sets and ads. # Delete campaign > Delete a campaign with its ad sets and ads. Canonical: `DELETE https://app.tryadeli.com/api/v1/ads/campaigns/{campaignId}` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Deletes a campaign together with its ad sets and ads, which stops any delivery immediately. It cannot be undone. The campaign must belong to `adAccountId`; otherwise the response is `404 campaign_not_found` and nothing is deleted. Responds `204`. Adeli's record of a campaign it created is marked `deleted`. **Path parameters** | Name | Type | Required | Description | | ------------ | -------- | -------- | ------------------- | | `campaignId` | `string` | Yes | A Meta campaign id. | **Query parameters** | Name | Type | Required | Description | | ------------- | -------- | -------- | -------------------------------------------------------- | | `adAccountId` | `string` | Yes | The ad account the campaign belongs to. | | `accountId` | `uuid` | | A connected Facebook account. Defaults to the profile's. | ## Request examples **cURL** ```bash curl --fail-with-body -X DELETE "https://app.tryadeli.com/api/v1/ads/campaigns/120210000000000001?adAccountId=act_1234567890&accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/campaigns/120210000000000001?adAccountId=act_1234567890&accountId=00000000-0000-0000-0000-000000000000", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(response.status); // 204: no body ``` **Python** ```python import os import requests response = requests.delete( "https://app.tryadeli.com/api/v1/ads/campaigns/120210000000000001", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "adAccountId": "act_1234567890", "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.status_code) # 204: no body ``` ## Responses ### 204 No Content ```text (empty body) ``` ### 404 ```json { "error": { "code": "campaign_not_found", "message": "That campaign does not belong to this ad account" } } ``` ## Update ad status URL: https://www.tryadeli.com/docs/api/ads/update-status Markdown: https://www.tryadeli.com/docs/api/ads/update-status.md Activate, pause, or resume an ad or ad set. # Update ad status > Activate, pause, or resume an ad or ad set. Canonical: `PUT https://app.tryadeli.com/api/v1/ads/status` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Sets an ad's or ad set's delivery status. Activating an ad is what starts spending. **Body** | Name | Type | Required | Description | | ------------------ | -------- | -------- | --------------------------------------------------------------------------------------------- | | `adAccountId` | `string` | Yes | The ad account the object belongs to. | | `objectId` | `string` | Yes | An ad or ad set id. | | `status` | `string` | Yes | ACTIVE or PAUSED. | | `campaignRecordId` | `uuid` | | Keeps Adeli's campaign record in step when the object came from POST `/api/v1/ads/campaigns`. | ## Request examples **cURL** ```bash curl --fail-with-body -X PUT "https://app.tryadeli.com/api/v1/ads/status?accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "adAccountId": "act_1234567890", "objectId": "120210000000000004", "status": "PAUSED", "campaignRecordId": "00000000-0000-4000-8000-000000000020" }' ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/status?accountId=00000000-0000-0000-0000-000000000000", { method: "PUT", headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ adAccountId: "act_1234567890", objectId: "120210000000000004", status: "PAUSED", campaignRecordId: "00000000-0000-4000-8000-000000000020", }), }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.put( "https://app.tryadeli.com/api/v1/ads/status", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "accountId": "00000000-0000-0000-0000-000000000000", }, json={ "adAccountId": "act_1234567890", "objectId": "120210000000000004", "status": "PAUSED", "campaignRecordId": "00000000-0000-4000-8000-000000000020", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "objectId": "120210000000000004", "status": "PAUSED" } ``` ### 404 ```json { "error": { "code": "account_not_found", "message": "Ad account is not available to this connection" } } ``` ## Lead forms and leads URL: https://www.tryadeli.com/docs/api/ads/list-leads Markdown: https://www.tryadeli.com/docs/api/ads/list-leads.md A Page's lead forms, or one form's submissions. # Lead forms and leads > A Page's lead forms, or one form's submissions. Canonical: `GET https://app.tryadeli.com/api/v1/ads/leads` Authorization: `Bearer ` — see [Authentication](https://www.tryadeli.com/docs/authentication) Without `formId`, the lead generation forms belonging to the connected Page. With it, that form's submissions, including the field data and the campaign, ad set and ad each lead came from. > **Note:** > > Adeli does not store lead data. Submissions are read from Facebook when you ask > for them and are not retained after the response. **Query parameters** | Name | Type | Required | Description | | ----------- | -------- | -------- | ----------------------------------------------------------------------------------------------- | | `formId` | `string` | | A lead form id. Omit to list the Page's forms. | | `accountId` | `uuid` | | A connected Facebook account. | | `profileId` | `uuid` | | Required unless you pass accountId, which names its profile. If you pass both, they must agree. | ## Request examples **cURL** ```bash curl --fail-with-body "https://app.tryadeli.com/api/v1/ads/leads?formId=555666777888999&accountId=00000000-0000-0000-0000-000000000000" \ -H "Authorization: Bearer $ADELI_API_KEY" ``` **JavaScript** ```javascript const response = await fetch("https://app.tryadeli.com/api/v1/ads/leads?formId=555666777888999&accountId=00000000-0000-0000-0000-000000000000", { headers: { Authorization: `Bearer ${process.env.ADELI_API_KEY}`, }, }); console.log(await response.json()); ``` **Python** ```python import os import requests response = requests.get( "https://app.tryadeli.com/api/v1/ads/leads", headers={"Authorization": f"Bearer {os.environ['ADELI_API_KEY']}"}, params={ "formId": "555666777888999", "accountId": "00000000-0000-0000-0000-000000000000", }, ) print(response.json()) ``` ## Responses ### 200 ```json { "platform": "facebook", "accountId": "00000000-0000-0000-0000-000000000000", "profileId": "00000000-0000-4000-8000-000000000002", "leads": [ { "id": "1029384756473829", "createdAt": "2026-10-08T18:22:41+0000", "fields": [ { "name": "full_name", "values": ["Jordan Lee"] }, { "name": "email", "values": ["jordan@example.com"] } ], "campaignName": "Fall launch", "adSetName": "Fall launch — ad set", "adName": "Fall launch — ad" } ], "fetchedAt": "2026-10-09T16:00:00.000Z" } ``` ### 403 ```json { "error": { "code": "ads_permission_required", "message": "This connection has not granted the Facebook ads permissions", "details": { "missingScopes": ["leads_retrieval"] } } } ```