# CuteDyno documentation Complete documentation as one file. Source: https://cutedyno.com/docs # Quickstart Publish your first post through the CuteDyno API in about five minutes. CuteDyno publishes to TikTok, Instagram, Facebook, LinkedIn, and YouTube through one API. You send content once; CuteDyno handles each platform's upload rules, token refresh, retries, and delivery reporting. This page takes you from nothing to a published post. Every request below is real. ## 1. Get an API key Create one in the dashboard under [Developers → API](/dashboard/api). Keys look like `cdyn_live_...` and are shown once, so store it immediately. ```bash export CUTEDYNO_API_KEY="cdyn_live_..." curl https://api.cutedyno.com/v1/profile \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` That call returns the profile your key belongs to, which is the cheapest way to confirm the key works. Install an SDK if you would rather not hand-roll requests: ```bash npm install @cutedyno/node ``` The Node SDK reads `CUTEDYNO_API_KEY` from the environment, so `new CuteDyno()` needs no arguments. ## 2. Connect a social account CuteDyno never sees your user's platform password. You create a short-lived connect session, send the user to the returned URL, and they come back with an account attached to your profile. ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const { authUrl } = await cutedyno.accounts.connect({ platform: 'instagram', redirectUrl: 'https://yourapp.com/settings/social?connected=1', }); // Send the user here. They land back on redirectUrl when done. console.log(authUrl); ``` Once they finish, the account appears in the account list: ```typescript const { accounts } = await cutedyno.accounts.list(); // [{ id: '…', platformAccountId: '178414…', platform: 'instagram', name: 'Acme', username: 'acmecoffee', … }] ``` Use `id` — the CuteDyno UUID — when creating posts. `platformAccountId` is the platform's own identifier, useful for reconciling with data you already hold. [Connecting accounts](/docs/guides/connecting-accounts) covers the flow in full. ## 3. Create a post ```typescript const { post } = await cutedyno.posts.create( { content: 'Shipping today.', accountIds: [accounts[0].id], scheduledAt: '2026-08-01T15:00:00.000Z', }, { idempotencyKey: crypto.randomUUID() } ); console.log(post.id, post.status); // '…', 'scheduled' ``` Three modes, chosen by what you send rather than by a mode flag: | You send | Result | | --- | --- | | Neither `scheduledAt` nor `publishNow` | A draft. Nothing goes out. | | `scheduledAt` | Queued for that time. | | `publishNow: true` | Queued immediately. | Sending both `scheduledAt` and `publishNow` is rejected, because the intent is ambiguous. Attach media by uploading first — see [Media uploads](/docs/guides/media-uploads). Pass an `Idempotency-Key` on this call: a network timeout is not proof the post was not created, and [idempotency](/docs/guides/idempotency) is what stops a retry becoming a duplicate post. Want to check content against a platform's rules without writing anything? [`POST /v1/posts/validate`](/docs/api/validate-post) runs the same checks and returns the same errors. ## 4. Confirm it published Two ways. Poll the post: ```typescript const { post } = await cutedyno.posts.retrieve(postId); // status: scheduled → queued → processing → published | partially_published | failed ``` `partially_published` means some accounts succeeded and others did not — normal when posting to several platforms at once, since each has its own rules. `post.results` breaks it down per account, and [`GET /v1/posts/:id/history`](/docs/api/get-post-history) shows every transition with a reason. Or, better, subscribe to webhooks once and stop polling: ```typescript await cutedyno.webhooks.create({ url: 'https://yourapp.com/webhooks/cutedyno', events: ['post.published', 'post.failed'], }); ``` The `post.published` payload carries the platform post ID and permalink. [Webhooks](/docs/webhooks) covers signature verification and the retry schedule. ## What to read next - [Build a platform](/docs/build-a-platform) — serve many customers from one key with profiles. - [Publishing](/docs/publishing) — drafts, scheduling, per-platform options, and retries. - [Errors](/docs/errors) — the error envelope and which codes are worth retrying. - [Platform requirements](/docs/platforms) — media specs and options per network. - [MCP server](/docs/mcp) — let an agent do all of the above in natural language. --- # Authentication API keys, scopes, profile access, and how to keep keys safe. Every request to `https://api.cutedyno.com` carries an API key as a bearer token: ```bash curl https://api.cutedyno.com/v1/profiles \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` There is no OAuth dance for your own access and no session cookie. A key is the whole story. Keys start with `cdyn_live_`, and CuteDyno stores only a hash — the full value is shown exactly once, when the key is created. ## Scopes A key's **scope** decides what it can do. Three scopes exist, and they are deliberately coarse: fine-grained matrices tend to produce keys nobody can reason about. | Scope | Posts | Accounts | Media | Comments | Analytics | Webhooks | | --- | --- | --- | --- | --- | --- | --- | | `full` | read, write | read, write | read, write | read | read | read, write | | `agent` | read, write | read, write | read, write | read | read | read | | `readonly` | read | read | read | read | read | read | `agent` exists for exactly the case its name suggests. An agent that can publish posts should not also be able to repoint your webhook endpoint at somewhere else, so `agent` keys can see webhook configuration but not change it. Pick a scope when creating the key: ```typescript const { key, secret } = await cutedyno.apiKeys.create({ name: 'Publishing worker', scope: 'agent', }); // `secret` is the only time you will see the full key. console.log(secret); ``` A request that needs a permission the scope lacks fails with `403` and code `insufficient_permission`. The response names the permission it wanted, for example `posts:write`. ## Profile access Every key has a **home profile** — the profile it was created in. What varies is how far beyond that it can reach. - **Omit `profileIds`** and the key can act on every profile in the account. This is the key a platform integration wants. - **Set `profileIds`** and the key is limited to that list. This is the key you hand to a customer, a contractor, or an agent. Requests choose their profile with a `profileId` query parameter or body field. Leave it out and the request lands on the key's home profile, which is why single-tenant integrations never have to think about profiles at all. ```typescript // Account-wide key acting on one specific customer. await cutedyno.posts.list({ profileId: acme.id }); // Same call from a key whose home profile is Acme. await cutedyno.posts.list(); ``` Asking for a profile the key cannot reach fails with `403` and code `profile_not_accessible`. That is the same answer whether the profile belongs to somebody else or does not exist, so the API cannot be used to probe for valid IDs. Note one deliberate restriction: a key with `profileIds` set cannot mint further keys. Only account-wide keys can call [`POST /v1/api-keys`](/docs/api/create-api-key), so a leaked customer key cannot widen its own access. ## Extra restrictions Three optional limits narrow a key further, each independent of scope: ```typescript await cutedyno.apiKeys.create({ name: 'Instagram-only agent', scope: 'agent', profileIds: [acme.id], allowedAccountIds: [instagramAccount.id], maxPostsPerDay: 10, requireApproval: true, expiresIn: 30, }); ``` | Field | Effect when set | | --- | --- | | `allowedAccountIds` | Posts may only target these connected accounts. Anything else returns `account_not_allowed`. | | `maxPostsPerDay` | Caps posts created by this key, resetting at midnight UTC. Over the cap returns `daily_post_limit`. | | `requireApproval` | Posts created by this key land in `pending_approval` instead of publishing. See [agent governance](/docs/guides/agent-governance). | | `expiresIn` | Days until the key stops working. After that, requests return `api_key_expired`. | ## Rotation To rotate without downtime: create the new key, deploy it, confirm traffic has moved by watching `lastUsedAt` on the old key in [`GET /v1/api-keys`](/docs/api/list-api-keys), then revoke the old one with [`DELETE /v1/api-keys/:id`](/docs/api/revoke-api-key). Both keys work during the overlap. Revocation takes effect on the next request. ## Keeping keys safe - Never ship a key to a browser, mobile app, or desktop app. All three are readable by users. Proxy through your own backend. - Use a separate key per environment and per service, so revoking one thing does not take down everything. - Prefer `readonly` or `agent` over `full`. Most integrations never need to manage webhooks. - Audit usage in [`GET /v1/logs`](/docs/api/list-api-logs): every authenticated request is recorded with the key that made it, its route, status, and duration. ## Authentication errors | Code | Status | Meaning | | --- | --- | --- | | `authentication_required` | 401 | No `Authorization` header, or it was not a bearer token. | | `invalid_api_key` | 401 | The key does not exist, was revoked, or is malformed. | | `api_key_expired` | 401 | The key passed its `expiresAt`. | | `insufficient_permission` | 403 | Valid key, wrong scope for this endpoint. | | `profile_not_accessible` | 403 | The key is not scoped to the requested profile. | | `invalid_profile_id` | 400 | `profileId` was sent but was not a string. | None of these are worth retrying — nothing about the outcome will change on a second attempt. Full list in the [error reference](/docs/errors). --- # Build a platform Serve many customers from one integration using profiles, scoped keys, and account-level webhooks. If you are adding social publishing to your own product — an agency dashboard, a CRM, a scheduling tool, a vertical SaaS — you need one integration that serves many customers without their data ever meeting. That is what **profiles** are for. A profile is one tenant. It owns its connected social accounts, its posts, its media, and its policy. Nothing crosses a profile boundary, ever, and there is no request that can span two of them. ```text Your CuteDyno account one bill, one account-wide API key ├── Profile: Acme Corp @acme on Instagram, @acme on TikTok ├── Profile: Globex Globex LinkedIn page └── Profile: Initech Initech YouTube channel ``` ## The shape of the integration Four decisions, and the rest follows: 1. **One profile per customer**, created when they sign up in your product. 2. **One account-wide API key**, held by your backend. Not one key per customer. 3. **Scope every call with `profileId`**, taken from your own customer record. 4. **One webhook endpoint**, routed by the `profileId` in the payload. That is the whole model. Below is each piece. ## 1. Create a profile per customer Do this in the same transaction that creates your own customer record, and store the returned ID next to it. ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); // account-wide key from the environment export async function onCustomerSignup(customer: Customer) { const { profile } = await cutedyno.profiles.create( { name: customer.companyName, description: `Tenant ${customer.id}`, }, { idempotencyKey: `profile-${customer.id}` } ); await db.customers.update(customer.id, { cutedynoProfileId: profile.id }); } ``` The idempotency key matters here. Signup flows get retried, and without one a double-submit leaves you with two profiles and a customer who can only see half their accounts. The SDK generates a key per call automatically, which covers a retry inside one call, but deriving the key from your own customer ID also covers a retry from a different process or a later job run. See [idempotency](/docs/guides/idempotency). ## 2. Use one key, not one per customer It is tempting to mint a key per customer. Resist it: you gain nothing, and you inherit the job of storing, rotating, and revoking thousands of secrets. Instead, hold one **account-wide** key — one created without `profileIds` — in your backend's secret manager, and name the profile on each call. ```typescript const { accounts } = await cutedyno.accounts.list({ profileId: customer.cutedynoProfileId, }); ``` Every profile-scoped endpoint takes `profileId`, as a query parameter on reads and a body field on writes. The SDK will also carry a default for you: ```typescript const forCustomer = new CuteDyno({ profileId: customer.cutedynoProfileId }); const { accounts } = await forCustomer.accounts.list(); // implicitly scoped ``` There is one case where per-customer keys are right: when the customer's own code, or an agent acting for them, calls CuteDyno directly. Then create a key with `profileIds: [theirProfile]` so it cannot see anything else, and note that such keys cannot mint further keys. [Authentication](/docs/authentication) covers the scoping rules. ## 3. Let customers connect their own accounts Never ask a customer for their social password, and never proxy their OAuth through your own app credentials. Create a connect session and redirect. ```typescript app.post('/settings/social/connect', async (req, res) => { const { authUrl } = await cutedyno.accounts.connect({ platform: req.body.platform, profileId: req.user.cutedynoProfileId, redirectUrl: `https://yourapp.com/settings/social?connected=${req.body.platform}`, }); res.redirect(authUrl); }); ``` The user authorises on the platform, CuteDyno stores and refreshes the tokens, and they land back on your `redirectUrl`. The account is then attached to that profile and nothing else. [Connecting accounts](/docs/guides/connecting-accounts) covers failure cases and re-authorisation. ## 4. One webhook endpoint for everyone Register a single **account-scoped** subscription and route by profile. Do not register one webhook per profile — you would then have thousands of endpoints delivering to the same URL. ```typescript await cutedyno.webhooks.create({ url: 'https://yourapp.com/webhooks/cutedyno', scope: 'account', events: ['post.published', 'post.failed', 'account.disconnected'], }); ``` Every payload carries `profileId`, so the receiver is a lookup away from your own customer: ```typescript import express from 'express'; import { verifyWebhook } from '@cutedyno/node'; app.post( '/webhooks/cutedyno', express.raw({ type: 'application/json' }), async (req, res) => { const event = verifyWebhook<{ profileId: string }>({ payload: req.body, signature: req.header('CuteDyno-Signature'), secret: process.env.CUTEDYNO_WEBHOOK_SECRET!, }); const customer = await db.customers.findByProfileId(event.data.profileId); // A profile we no longer track. Acknowledge so it is not retried for a day. if (!customer) return res.sendStatus(200); res.sendStatus(200); await enqueue(customer, event); } ); ``` Verify the signature before you trust `profileId` — it is the field that decides which customer's data you are about to write. [Webhooks](/docs/webhooks) has the full verification recipe. ## Guardrails per customer Each profile has its own policy, so one customer can require approval while another publishes freely: ```typescript await cutedyno.policy.update({ profileId: customer.cutedynoProfileId, requireApproval: true, allowedPlatforms: ['instagram', 'tiktok'], maxPostsPerDay: 5, }); ``` Posts that hit a policy land in `pending_approval` and fire `approval.requested` rather than failing. [Agent governance](/docs/guides/agent-governance) covers approvals, and matters most when the thing creating posts is an LLM. ## Offboarding When a customer leaves, disconnect their social accounts first, then delete the profile: ```typescript await cutedyno.profiles.del(customer.cutedynoProfileId); ``` Deleting a profile that still has connected accounts fails with `profile_has_connected_accounts`. That is deliberate: revoking a customer's platform tokens should be an explicit act, not a side effect of a cleanup job. ## A checklist before you ship - Profile IDs stored against your own customer records, not derived at request time. - One account-wide key in a secret manager, with `lastUsedAt` monitored. - An idempotency key on every profile creation and post creation. - One account-scoped webhook, signature verified, `profileId` used for routing. - `429` handled by honouring `Retry-After`. See [rate limits](/docs/guides/rate-limits). - `partially_published` handled — some accounts succeed while others fail, and this is normal. ## Recipes - [Publishing](/docs/publishing) — scheduling, per-platform variants, and retries. - [Reporting](/docs/guides/reporting) — reading delivery results back into your own reporting. - [Agent governance](/docs/guides/agent-governance) — letting an LLM post without letting it post anything. --- # SDKs Official clients, and what to do when there isn't one for your language. ## Node.js ```bash npm install @cutedyno/node ``` Types are generated from the same OpenAPI document the API validates against, so request bodies, filters, and responses are checked at compile time. A typo in a field name is a build error, not a runtime `invalid_request`. ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); // reads CUTEDYNO_API_KEY ``` What the client handles so you don't: - **Retries** on `429` and `5xx`, with backoff that honours `Retry-After`. - **Idempotency keys** on every write, so an automatic retry cannot publish twice. - **Rate limit visibility** through an `onRateLimit` callback that receives the current headers. - **Media uploads** as a single call — presign, transfer, and the URL to post with. - **Webhook verification** with constant-time comparison and a timestamp check. - **Pagination** as an async iterator, so you never write a page loop. ```typescript const cutedyno = new CuteDyno({ apiKey: process.env.CUTEDYNO_API_KEY, baseUrl: 'https://api.cutedyno.com', timeout: 30_000, maxRetries: 2, profileId: 'prof_customer_42', // default profile for every call onRateLimit: ({ remaining, reset }) => { metrics.gauge('cutedyno.rate_limit.remaining', remaining); }, }); ``` Setting `profileId` on the client is the shortcut for a per-customer worker: construct one client per customer and drop `profileId` from every call site. Errors throw `CuteDynoError` with a stable `code`, plus `isRetryable`, `isRateLimit`, and `isAuthError` for the common branches. See [Errors](/docs/errors). The full method table lives in the [package README](https://www.npmjs.com/package/@cutedyno/node). ## Python Not yet published. Until it is, the REST API is straightforward from `httpx` or `requests` — see the curl examples throughout these docs, and generate a client from the spec if you want types: ```bash pip install openapi-python-client openapi-python-client generate --url https://cutedyno.com/docs/api/openapi.json ``` ## Any other language Start from [`/docs/api/openapi.json`](/docs/api/openapi.json). It is a committed artifact generated from the server's own schemas, so a generated client matches the running API rather than a hand-maintained description of it. Whatever you generate or write by hand, four things are worth implementing rather than skipping: 1. **Send `Idempotency-Key` on writes.** Without it, a timeout leaves you unable to retry safely. See [Idempotency](/docs/guides/idempotency). 2. **Retry `429` and `5xx` only**, and wait for `Retry-After`. Retrying a `400` just fails again. See [Rate limits](/docs/guides/rate-limits). 3. **Branch on `error.code`, never on `error.message`.** Codes are contractual; messages are prose. 4. **Log `requestId` from failures.** It is the fastest route to an answer when you need support. ## MCP For agents rather than code, the [MCP server](/docs/mcp) exposes the same API as tools: ```bash npx -y cutedyno-mcp ``` --- # Publishing Drafts, schedules, per-platform options, and what to do when one account fails. One post can target several accounts on several platforms. CuteDyno accepts it as a single object, then fans it out — each account is published independently, with that platform's rules applied. That independence is the thing to design around. A post is not one operation that succeeds or fails; it is several, and they can disagree. ## The three modes There is no `mode` parameter. What you send decides: ```typescript // Draft — saved, nothing goes out await cutedyno.posts.create({ content: 'Rough idea', accountIds }); // Scheduled await cutedyno.posts.create({ content: 'Autumn range is live.', accountIds, scheduledAt: '2026-09-01T09:00:00Z', }); // Now await cutedyno.posts.create({ content: 'We are live.', accountIds, publishNow: true }); ``` Sending both `scheduledAt` and `publishNow` is rejected rather than guessed at. A `scheduledAt` in the past publishes immediately; more than 180 days out is refused. A draft may omit `accountIds` entirely — useful when a human picks the accounts later. Scheduling or publishing without them is an error, since there is nowhere to send it. ## Status, and what each one means ```text draft ──────────┐ ├─→ scheduled ─→ queued ─→ processing ─→ published pending_approval ┘ ├─→ partially_published └─→ failed ``` `queued` means a worker has it. `processing` means bytes are moving. The gap between `scheduled` and `published` depends on the platform and the size of the media, so treat publishing as asynchronous even when you asked for it now. The full table is in the [API overview](/docs/api#status-values). ## Per-platform options The same text rarely suits every network, and some platforms require fields the others do not have. `platforms` carries options that apply only to accounts on that platform: ```typescript await cutedyno.posts.create({ content: 'Behind the scenes of the shoot.', media: [{ type: 'video', url: videoUrl }], accountIds: [tiktokId, youtubeId, linkedinId], platforms: { tiktok: { title: 'Behind the scenes', privacy_level: 'PUBLIC_TO_EVERYONE', disable_duet: true, }, youtube: { title: 'Behind the scenes of the autumn shoot', privacyStatus: 'public', tags: ['fashion', 'bts'], }, linkedin: { visibility: 'PUBLIC', }, }, }); ``` Options for a platform you are not posting to are ignored, so a single template can carry all of them. YouTube is the one platform with a required option: it will not accept a video without `platforms.youtube.title`. See the [platform guides](/docs/platforms) for the full option list and media specs per network. ## Validate first [`POST /v1/posts/validate`](/docs/api/validate-post) runs the same checks as create and writes nothing: ```typescript const result = await cutedyno.posts.validate({ content: 'Autumn range is live.', accountIds, scheduledAt: '2026-09-01T09:00:00Z', }); result.valid; // false if it would be rejected result.errors; // same codes and messages create would return result.warnings; // e.g. "This profile requires approval…" ``` Use it to drive a form in your own UI, and read `warnings` — that is where you learn that a post will go to review instead of publishing, before your user wonders why nothing happened. ## Editing and cancelling Drafts, scheduled, queued, and failed posts can be edited. Published, processing, and partially published posts cannot — the content is already on a platform, and CuteDyno will not pretend otherwise. ```typescript await cutedyno.posts.update(postId, { scheduledAt: '2026-09-02T09:00:00Z' }); await cutedyno.posts.cancel(postId); // scheduled or queued only ``` Editing the content of a post that required approval sends it back for review. Approval attaches to what was approved, not to the post ID. ## When one account fails ```typescript const { post } = await cutedyno.posts.retrieve(postId); if (post.status === 'partially_published') { for (const result of post.results) { if (result.status === 'failed') { console.error(result.platform, result.pageName, result.error); } } } ``` Then requeue only what failed: ```typescript await cutedyno.posts.retry(postId); ``` Retry skips accounts that already succeeded, so it cannot double-post. It accepts `failed` and `partially_published` posts only. Not every failure is worth retrying. A revoked token needs the user to [reconnect](/docs/guides/connecting-accounts); a video too long for TikTok needs a different video. Read `result.error` before retrying in a loop — the same request will fail the same way. ## Don't poll Polling every post until it settles works, and wastes most of your [rate limit](/docs/guides/rate-limits) discovering that nothing has changed. Subscribe once instead: ```typescript await cutedyno.webhooks.create({ url: 'https://yourapp.com/webhooks/cutedyno', events: [ 'post.published', 'post.partially_published', 'post.failed', ], }); ``` The payload carries the per-account `results`, so you usually do not need to fetch the post at all. See [Webhooks](/docs/webhooks). ## Listing history ```typescript const { posts, total, hasMore } = await cutedyno.posts.list({ status: 'failed', platform: 'tiktok', page: 1, limit: 50, }); ``` Or let the SDK walk the pages: ```typescript for await (const post of cutedyno.posts.iterate({ status: 'failed' })) { const { history } = await cutedyno.posts.history(post.id); console.log(post.id, history.at(-1)?.reason); } ``` `history` is the per-post audit trail: every transition, who caused it, and the reason recorded at the time. It is the first place to look when someone asks why a post did not go out. ## Next - [Media uploads](/docs/guides/media-uploads) — images and video - [Platform guides](/docs/platforms) — per-network rules - [Idempotency](/docs/guides/idempotency) — safe retries - [Agent governance](/docs/guides/agent-governance) — approvals and scoped keys --- # Webhooks Receive delivery results instead of polling, with signature verification and a documented retry schedule. Publishing is asynchronous. A post moves through `queued`, `processing`, and then to `published`, `failed`, or `partially_published`, and how long that takes depends on the platform and the size of the video. Webhooks tell you when it lands, so you never have to poll. ## Subscribe ```typescript const { subscription, secret } = await cutedyno.webhooks.create({ url: 'https://yourapp.com/webhooks/cutedyno', events: ['post.published', 'post.failed', 'post.partially_published'], }); // Store this now. It is the only time the secret is returned. console.log(secret); ``` `scope` decides how much a subscription hears: | Scope | Receives | | --- | --- | | `profile` (default) | Events from the one profile the subscription was created in. | | `account` | Events from every profile in the account, each tagged with `profileId`. | Multi-tenant platforms want `account`: one endpoint, one secret, routed by `profileId`. See [Build a platform](/docs/build-a-platform). Subscribe to `["*"]` to receive everything, including events added later. That is convenient in development and a liability in production, where a new event type silently entering your handler is how outages start. ## The payload Every delivery is a `POST` with this envelope: ```json { "id": "3f9c1a80-0000-4000-8000-000000000000", "type": "post.partially_published", "createdAt": "2026-08-01T15:00:04.212Z", "data": { "profileId": "a1b2c3d4-0000-4000-8000-000000000000", "postId": "77f0e1a2-0000-4000-8000-000000000000", "status": "partially_published", "completedTargets": 1, "failedTargets": 1, "results": [ { "targetId": "…", "platform": "instagram", "pageName": "acme", "status": "success", "result": { "id": "17912345678901234" } }, { "targetId": "…", "platform": "tiktok", "pageName": "acmehq", "status": "failed", "error": "Video must be at least 3 seconds long." } ] } } ``` `id` is the delivery ID. It is stable across retries, which makes it the right key to dedupe on. `data` always carries `profileId`; the rest depends on `type`. `data.status` mirrors the post's status. A `state` field carrying the same value is still sent for older receivers, but it will be removed — read `status`. Four headers come with it: | Header | Purpose | | --- | --- | | `CuteDyno-Signature` | `t=,v1=`. Verify this before parsing anything. | | `CuteDyno-Event` | The event type, so you can route without parsing the body. | | `CuteDyno-Event-Id` | Same as `id`. Use it to dedupe. | | `CuteDyno-Delivery-Attempt` | `1` on the first try, incrementing on retries. | ## Verify the signature Anyone can POST to your endpoint. The signature is what makes the payload trustworthy, and every field you act on — `profileId` most of all — depends on it. ```typescript import express from 'express'; import { verifyWebhook, WebhookVerificationError } from '@cutedyno/node'; app.post( '/webhooks/cutedyno', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = verifyWebhook({ payload: req.body, signature: req.header('CuteDyno-Signature'), secret: process.env.CUTEDYNO_WEBHOOK_SECRET!, }); } catch (error) { if (error instanceof WebhookVerificationError) return res.sendStatus(400); throw error; } // Acknowledge first. Ten seconds is the whole budget. res.sendStatus(200); await queue.add('cutedyno-event', event); } ); ``` Note `express.raw`. The signature covers the exact bytes CuteDyno sent, and `express.json()` reparses and reserialises them, which changes the bytes and breaks verification. This is the single most common webhook problem. Without the SDK it is a five-line HMAC: ```python import hashlib import hmac import time def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(part.split("=", 1) for part in header.split(",")) timestamp, received = parts["t"], parts["v1"] if abs(time.time() - int(timestamp)) > tolerance: return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, received) ``` Use a constant-time comparison, as above. A plain `==` leaks the signature one byte at a time to anyone patient enough to measure. The timestamp check is what stops replays: without it, a captured payload stays valid forever. Five minutes of tolerance covers ordinary clock skew. ## Retries A delivery counts as successful on any `2xx`. Anything else — including a timeout after 10 seconds — is retried on this schedule: | Attempt | Delay after previous | | --- | --- | | 2 | 30 seconds | | 3 | 2 minutes | | 4 | 10 minutes | | 5 | 1 hour | | 6 | 6 hours | | 7 | 24 hours | Seven attempts over roughly 31 hours, then the delivery is abandoned. Inspect what happened with [`GET /v1/webhooks/:id/deliveries`](/docs/api/list-webhook-deliveries), which records status, attempt count, response code, and the first 2000 bytes of your response body. Two consequences worth designing for: - **Deliveries can arrive out of order.** A retried `post.scheduled` can land after `post.published`. Treat events as facts about a moment, and re-read the post if you need current state. - **Deliveries can arrive twice.** A `2xx` that we never see because the connection dropped becomes a retry. Dedupe on `id`. ## Acknowledge fast, process later Return `200` as soon as the signature checks out, then do the work on a queue. If you publish to five platforms and your handler makes five database writes and an email send before responding, you will eventually exceed 10 seconds, and CuteDyno will retry an event you already handled. ## Test before you ship [`POST /v1/webhooks/:id/test`](/docs/api/test-webhook) delivers a synthetic event with the same shape, signature, and headers as the real thing: ```typescript await cutedyno.webhooks.test(subscription.id, { eventType: 'post.published' }); ``` The payload carries `"test": true` so a receiver can distinguish it. Check the result in the delivery log, or in the dashboard under [Webhooks](/dashboard/webhooks). ## Changing an endpoint [`PATCH /v1/webhooks/:id`](/docs/api/update-webhook) changes the URL or the event list, and pauses delivery: ```typescript await cutedyno.webhooks.update(subscription.id, { isActive: false }); ``` A paused subscription keeps its secret and its history, and events that occur while it is paused are not queued for later — they are simply not delivered. Pause during a deploy you expect to break; delete when you are done with the endpoint for good. ## Rotating the secret [`POST /v1/webhooks/:id/rotate-secret`](/docs/api/rotate-webhook-secret) issues a new signing secret and returns it once: ```typescript const { secret } = await cutedyno.webhooks.rotateSecret(subscription.id); ``` The old secret stops verifying immediately, so there is no overlap window. Deploy the new value to your receiver before rotating, or pause the subscription, rotate, deploy, and resume. ## Event reference Grouped by resource. Each fires once per state change. ### Accounts | Event | When | | --- | --- | | `account.connected` | A social account finished connecting to a profile. | | `account.disconnected` | An account was removed, or the platform revoked its token. | | `connection.completed` | A connect session succeeded. `data.accounts` lists what was added. | | `connection.failed` | A connect session failed or was abandoned. | `account.disconnected` deserves a real handler. Platform tokens get revoked when a user changes their password or removes your app, and until they reconnect, every post to that account fails. ### Posts | Event | When | | --- | --- | | `post.created` | A post was created, in any starting state. | | `post.scheduled` | A post was scheduled for a future time. | | `post.published` | Published successfully to every target account. | | `post.partially_published` | Published to some accounts and failed on others. | | `post.failed` | Failed on every target account. | | `post.cancelled` | Cancelled before it published. | `post.partially_published` is not an edge case. It is what happens when Instagram accepts a video and TikTok rejects it for duration, in the same post. `data.results` breaks it down per account, and [`POST /v1/posts/:id/retry`](/docs/api/retry-post) requeues only what failed. ### Approvals | Event | When | | --- | --- | | `approval.requested` | A post entered approval and approvers were notified. | | `approval.approved` | An approver approved it. Publishing continues. | | `approval.rejected` | An approver rejected it. It stays unpublished. | These matter when an agent is creating the posts. See [agent governance](/docs/guides/agent-governance). ### Comments | Event | When | | --- | --- | | `comment.received` | A new comment arrived on published content. | Facebook and Instagram only, and only for accounts where comment sync is enabled. --- # Connecting accounts Run the OAuth flow that links a social account to a CuteDyno profile. Before anything can be published, someone has to grant CuteDyno permission to post on their behalf. You start a connect session, send the user to the platform, and CuteDyno handles the callback, the token exchange, and the refresh cycle from there. Five platforms connect this way: `facebook`, `instagram`, `tiktok`, `linkedin`, and `youtube`. ## The flow ```text 1. POST /v1/connect/sessions -> authUrl + session.id 2. Send the user to authUrl -> they approve on the platform 3. Platform redirects back -> CuteDyno exchanges the code 4. connection.completed webhook-> the account is usable ``` Steps 3 and 4 happen without you. You need to build steps 1 and 2, and listen in step 4. ## Start a session ```typescript const { authUrl, session } = await cutedyno.accounts.connect({ platform: 'instagram', profileId: 'prof_customer_42', redirectUrl: 'https://yourapp.com/settings/social?connected=instagram', }); // Redirect the user, or open authUrl in a popup. ``` `redirectUrl` must be HTTPS, and it is where the user lands when the flow finishes — success or failure. Send them back to the screen they started from, not to a bare confirmation page; people connect accounts in the middle of doing something else. Sessions expire. Treat `authUrl` as single-use and short-lived: generate one when the user clicks Connect, not when the settings page renders. `profileId` decides which customer gets the account. Omit it and the account lands in the API key's home profile, which is what you want for a single-tenant setup and almost never what you want on a platform. See [Build a platform](/docs/build-a-platform). ## Know when it finished Subscribe to `connection.completed`. It is the only reliable signal, because the user might approve on their phone while your tab sits idle, or abandon the flow entirely. ```typescript if (event.type === 'connection.completed') { const { profileId, account } = event.data; await db.socialAccounts.insert({ customerId: profileId, accountId: account.id, platform: account.platform, handle: account.username, }); } ``` Also handle `connection.failed`, which carries an `errorMessage` explaining what the platform rejected. Surface that text; it is usually actionable ("this Instagram account is not a professional account"). If you need to check state directly — for a status page, or because a user is watching a spinner — poll the session: ```typescript const { session, accounts } = await cutedyno.accounts.getConnectSession(session.id); // session.status: 'pending' | 'completed' | 'failed' | 'expired' ``` Poll at most every few seconds, and stop once the status leaves `pending`. Do not use polling as your source of truth; use it to make the UI feel alive while the webhook does the real work. ## Two IDs per account Every connected account has both: ```json { "id": "3f2a…", "platformAccountId": "17841400000000000", "platform": "instagram", "name": "Acme Coffee", "username": "acmecoffee" } ``` `id` is CuteDyno's. `platformAccountId` is the platform's, useful for reconciling with data you already hold from a previous integration. Endpoints that take account IDs — `accountIds` on a post, `accountId` on comments, `allowedAccountIds` on a key — accept either, so you do not have to migrate stored IDs to start using CuteDyno. `accountId` is a deprecated alias of `id`, kept so older clients keep working. Use `id`. ## Platform requirements Most failed connections are not bugs; they are accounts that were never eligible. | Platform | Requirement | | --- | --- | | Instagram | A Professional (Business or Creator) account, linked to a Facebook Page. Personal accounts cannot be posted to via any API. | | Facebook | A Page, and the connecting user must have a Page admin or content role. Personal profiles are not supported. | | TikTok | Any account, but unaudited apps can only post privately. See [TikTok](/docs/platforms/tiktok). | | LinkedIn | A personal profile or a Company Page you administer. | | YouTube | A channel on the Google account being connected. | Publish the relevant requirement next to your Connect button. It saves a support ticket per user. ## Reconnecting Tokens get revoked: a user changes their password, removes your app, loses admin rights on a Page, or the platform expires a long-lived token. When that happens, publishing starts failing with `account_disconnected`. The fix is the same flow again. Start a new session for the same platform and profile, and CuteDyno replaces the credentials on the existing account rather than creating a duplicate. Watch for `account.disconnected` webhooks and prompt the user before they discover it from a failed post. ## Listing what is connected ```typescript const { accounts } = await cutedyno.accounts.list({ profileId: 'prof_customer_42' }); ``` Show this list in your own UI, keyed by `platform`, and offer Connect for platforms that are missing. Read it fresh rather than trusting your mirror of it — an account can disappear from CuteDyno's side when a user revokes access on the platform. ## Next - [Media uploads](/docs/guides/media-uploads) — get images and video into a post - [Build a platform](/docs/build-a-platform) — connect accounts on behalf of your customers - [Webhooks](/docs/webhooks) — the full event list --- # Media uploads Get images and video into CuteDyno, and what each platform accepts. CuteDyno does not accept file bytes through the API. You ask for a presigned URL, upload the file straight to storage, and then reference the resulting public URL in a post. That keeps large video uploads off the API path, where they would be slow and prone to timeouts. ## The short version ```typescript import { readFile } from 'node:fs/promises'; const { publicUrl } = await cutedyno.media.upload({ fileName: 'launch.mp4', fileType: 'video/mp4', data: await readFile('./launch.mp4'), }); await cutedyno.posts.create({ content: 'Six months of work, live today.', media: [{ type: 'video', url: publicUrl }], accountIds: ['3f2a…'], publishNow: true, }); ``` `media.upload` does the presign and the transfer in one call. Use it unless you need to manage the transfer yourself — a browser upload with a progress bar, or a resumable upload for a large file on a bad connection. ## Doing it manually Two steps. First, presign: ```bash curl https://api.cutedyno.com/v1/media/upload \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fileName": "launch.mp4", "fileType": "video/mp4" }' ``` ```json { "signedUrl": "https://storage.cutedyno.com/…?X-Amz-Signature=…", "publicUrl": "https://storage.cutedyno.com/workspace-…/videos/1754049600000-….mp4", "key": "workspace-…/videos/1754049600000-….mp4", "expiresIn": 3600, "maxBytes": 104857600 } ``` Then `PUT` the bytes, with the same `Content-Type` you presigned with: ```bash curl -X PUT "$SIGNED_URL" \ -H "Content-Type: video/mp4" \ --data-binary @launch.mp4 ``` A mismatched `Content-Type` fails the signature check, which shows up as a `403` from storage rather than an error from CuteDyno. If a `PUT` is rejected, check that header first. `publicUrl` works as soon as the `PUT` returns. You do not need to wait or poll. ### Uploading from a browser Presign on your server — never ship an API key to a browser — and hand the client just `signedUrl` and `publicUrl`: ```typescript await fetch(signedUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file, }); ``` The signed URL grants exactly one upload to one key, for one hour, so it is safe to give to a browser. Presign at the moment the user picks a file, not when the page loads, or the URL may expire while they are still filling in a caption. ## Accepted files | Kind | Types | | --- | --- | | Images | JPEG, PNG, WebP, GIF | | Video | MP4, QuickTime (`.mov`), WebM, AVI | Maximum 100 MB per file. Anything else is rejected with `unsupported_media_type` before you waste bandwidth on it. MP4 with H.264 video and AAC audio is the safest choice by a wide margin. Every platform accepts it; the others get transcoded, re-encoded, or occasionally refused depending on the codec inside the container. ## Attaching media to a post The `media` array holds items of `{ type, url }`: ```typescript await cutedyno.posts.create({ content: 'Three shots from the shoot.', media: [ { type: 'image', url: firstUrl }, { type: 'image', url: secondUrl }, { type: 'image', url: thirdUrl }, ], accountIds, }); ``` The post's content type is inferred, not declared. A video in the array makes it a video post; otherwise images make it an image post; otherwise it is text. Do not mix a video and images in one post — the images are ignored, and the result is not what you meant. Send two posts. Media must be reachable by the platforms, which fetch the URL themselves. URLs returned by `/v1/media/upload` always are. Your own URLs work too, as long as they are public HTTPS with no signed-URL expiry and no hotlink protection; a URL that 403s for a platform's fetcher produces a publish failure that looks mysterious from your side. ## Platform limits worth knowing These are enforced by the platforms, at publish time, not by CuteDyno at upload time. A file that uploads fine can still fail to publish. | Platform | Limits | | --- | --- | | Instagram | Up to 10 images in a carousel. Video becomes a Reel. Aspect ratio between 4:5 and 1.91:1 for feed images. | | Facebook | Multiple images supported. Video up to 240 minutes in theory, far less in practice. | | TikTok | Video only, plus photo posts. Maximum duration depends on the creator's account, not on your app — CuteDyno reads it per account and rejects longer videos before uploading. | | LinkedIn | Multiple images supported. Video between 3 seconds and 30 minutes. | | YouTube | Video only, and `platforms.youtube.title` is required. Unverified channels are capped at 15 minutes. | Call [`POST /v1/posts/validate`](/docs/api/validate-post) before publishing to a multi-platform set. It catches structural problems — a video sent to a text-only target, a missing YouTube title — without spending an upload. For the full per-platform picture, see the [platform guides](/docs/platforms). ## Reusing an upload `publicUrl` is stable and permanent. Upload a brand asset once, store the URL, and reference it across as many posts as you like. There is no need to re-upload the same file for each post or each account. Files are stored per profile. Uploading with `profileId` set puts the file in that customer's namespace, which is what you want on a multi-tenant platform so one customer's assets never appear in another's media library. ## Next - [Platform guides](/docs/platforms) — media specs and options per platform - [Connecting accounts](/docs/guides/connecting-accounts) — get accounts to post to - [Errors](/docs/errors) — every code, including `unsupported_media_type` --- # Idempotency Make retries safe so a network timeout can never publish the same post twice. A request that times out tells you nothing about whether it was applied. The post may have been created and the response lost, or the request may never have arrived. Retrying is the only reasonable response, and without idempotency, retrying is how you publish the same thing twice. Send an `Idempotency-Key` header on writes: ```bash curl -X POST https://api.cutedyno.com/v1/posts \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: 8f14e45f-ea0c-4d7a-9f1a-2b3c4d5e6f70" \ -H "Content-Type: application/json" \ -d '{"content":"Shipping today.","accountIds":["…"],"publishNow":true}' ``` If that exact request already succeeded, CuteDyno returns the stored response instead of doing the work again, with an `Idempotency-Replayed: true` header so you can tell. ## What the key covers Keys are scoped to the API key that used them and stored for **24 hours**. Within that window: | Situation | What happens | | --- | --- | | First request with this key | Executed normally, and the response is stored. | | Same key, same body | The stored response is replayed. Nothing runs twice. | | Same key, different body | `409` with code `idempotency_key_reused`. | | Same key, but the first attempt failed | Executed again. Failures are not stored. | | Key older than 24 hours | Treated as new. | That third row is a guardrail, not an inconvenience. If the same key arrives with a different body, one of the two requests is not what you meant, and replaying an unrelated response would be worse than an error. The fourth row is what makes retry loops work: a `500` or a `429` leaves no stored response, so retrying with the same key genuinely retries. ## Which endpoints accept it Every `POST`, `PUT`, `PATCH`, and `DELETE`. It matters most on the ones that cost something: - [`POST /v1/posts`](/docs/api/create-post) — a duplicate is a duplicate post on a real account. - [`POST /v1/posts/:id/publish`](/docs/api/publish-post) and [`/retry`](/docs/api/retry-post) — same. - [`POST /v1/profiles`](/docs/api/create-profile) — a duplicate splits one customer across two tenants. - [`POST /v1/api-keys`](/docs/api/create-api-key) — a duplicate leaves an unaccounted secret in existence. - [`POST /v1/webhooks`](/docs/api/create-webhook) — a duplicate doubles every delivery. `GET` requests ignore the header. They are already idempotent. ## Choosing a key Any string works, but the choice decides what the key protects you from. **A random UUID per attempt** protects against nothing. Each retry looks like a new request. **A random UUID per logical operation**, generated once and reused across retries, protects against retries within that operation. This is what the Node SDK does automatically: one key per call, reused across its internal retries. ```typescript // Generated once, reused across the SDK's own retries. await cutedyno.posts.create({ content: 'Shipping today.', accountIds }); ``` **A key derived from your own data** protects against everything, including a crash between the request and recording its result, and a job that runs twice: ```typescript await cutedyno.posts.create( { content: post.body, accountIds: post.accountIds, scheduledAt: post.scheduledAt }, { idempotencyKey: `post-${post.id}` } ); ``` Now the operation is safe no matter how many times your worker runs it. If your scheduler fires twice, or a deploy restarts a process mid-flight, the second attempt replays rather than publishes. This is the pattern to use for anything driven by your own database rows. The one thing to watch: the body must be identical, or you get `idempotency_key_reused`. If the row can change between attempts, include a version in the key — `post-${post.id}-v${post.revision}`. ## A retry loop worth copying ```typescript import { CuteDynoError } from '@cutedyno/node'; async function createPostWithRetry(input: PostInput, key: string) { for (let attempt = 0; attempt < 4; attempt += 1) { try { return await cutedyno.posts.create(input, { idempotencyKey: key }); } catch (error) { const retryable = error instanceof CuteDynoError && error.isRetryable; if (!retryable || attempt === 3) throw error; await sleep(error.retryAfter ? error.retryAfter * 1000 : 2 ** attempt * 500); } } } ``` Three things make it correct: the key is created outside the loop, only retryable errors are retried, and `Retry-After` is honoured when present. The SDK already does all three; this is what to write if you are not using it. ## Common mistakes **Regenerating the key inside the retry.** The most common one. Each attempt then looks new, and idempotency does nothing. **Reusing one key for a batch.** Ten posts under one key means one post and nine replays of its response. One key per post. **Assuming a replay means failure.** `Idempotency-Replayed: true` on a `201` means the post exists. Treat it as success. **Deriving keys from a timestamp.** `post-${Date.now()}` is a random key with extra steps. --- # Rate limits How much you can send, what the headers tell you, and how to back off correctly. Requests are counted per API key in a rolling one-minute window. The default allowance is **1000 requests per hour**, which the limiter spends as **16 per minute** so one burst cannot exhaust an hour's budget. Every response tells you where you stand, so you never have to guess: | Header | Meaning | | --- | --- | | `RateLimit-Limit` | Requests allowed in the current window. | | `RateLimit-Remaining` | Requests left in it. | | `RateLimit-Reset` | Seconds until the window resets. | | `RateLimit-Policy` | The policy in force, for example `16;w=60`. | | `Retry-After` | On a `429` only. Seconds to wait. | ## Handling a 429 A `429` carries code `rate_limit_exceeded` and a `Retry-After` header. Wait that long, then retry — do not retry immediately, and do not invent your own delay when the API has told you the right one. ```typescript import { CuteDynoError } from '@cutedyno/node'; try { await cutedyno.posts.create(input); } catch (error) { if (error instanceof CuteDynoError && error.isRateLimit) { await sleep((error.retryAfter ?? 60) * 1000); // …then retry, with the same idempotency key. } } ``` The Node SDK does this for you: `429` and `5xx` are retried twice by default, honouring `Retry-After`, with exponential backoff and jitter when the header is absent. Raise or lower it with `maxRetries`. Use the same idempotency key across those retries, or a retry can create a second post. See [idempotency](/docs/guides/idempotency). ## Staying under the limit **Use webhooks instead of polling.** This is the big one. Polling ten posts every five seconds is 120 requests a minute against a 16 per minute limit; a `post.published` webhook is zero. [Webhooks](/docs/webhooks) is the fix for almost every rate limit problem we see. **Batch accounts into one post.** One [`POST /v1/posts`](/docs/api/create-post) with five `accountIds` is one request, publishes to five accounts, and gives you one status to track. Five separate posts are five requests and five things to reconcile. **Watch the headers rather than counting.** Track `RateLimit-Remaining` and slow down as it approaches zero. The SDK will hand it to you on every response: ```typescript const cutedyno = new CuteDyno({ onRateLimit: ({ remaining, reset }) => { if (remaining < 3) metrics.gauge('cutedyno.throttle_risk', reset); }, }); ``` **Serialise your workers.** Ten workers each retrying in lockstep will hammer the same key. One queue with limited concurrency uses the same budget and finishes sooner. ## Platform limits are separate CuteDyno's limit governs calls to CuteDyno. The platforms have their own, and those apply to publishing, not to your API calls: - TikTok caps direct posts per user per day. - Instagram caps content publishing per account per rolling 24 hours. - YouTube spends daily quota units per upload. When a platform rejects a post for its own quota, the post fails with a platform error rather than a CuteDyno `429`, and the message names the platform. Retrying immediately will not help; the platform quota resets on its own schedule. The per-platform pages under [Platforms](/docs/platforms) list the current caps. ## Asking for more If your workload genuinely needs a higher ceiling — a large migration, or a platform onboarding thousands of profiles at once — [get in touch](/contact) with your key prefix and the shape of the traffic. Limits are per key, so a bulk-import key can be raised without touching your production one. --- # Agent governance Give an AI agent posting access without giving it the keys to your brand. An agent that can publish to your company's social accounts is one bad prompt away from an incident, and unlike a human it will not hesitate before hitting send. CuteDyno's answer is not to trust the model. It is to bound what any key can do, in the API, on the server, where the agent cannot argue with it. Six controls, which compose: | Control | Set on | Effect | | --- | --- | --- | | Scope | Key | Which operations the key may call at all | | `requireApproval` | Key or profile | Posts wait for a human before reaching a platform | | `allowedAccountIds` | Key | Only these connected accounts may be posted to | | `maxPostsPerDay` | Key or profile | Hard cap per UTC day | | `allowedPlatforms` | Profile | Only these platforms may be posted to | | `expiresIn` | Key | The key stops working on its own | ## Start with scope The cheapest control is the one that removes the capability entirely. ```typescript const { secret } = await cutedyno.apiKeys.create({ name: 'Reporting agent', scope: 'readonly', }); ``` A `readonly` key can answer "how did last week's posts do?" and cannot publish, connect an account, or change a webhook. If that is all your agent needs, stop here — no other control matters, because there is nothing to govern. `agent` sits between `readonly` and `full`: it can draft, schedule, and publish, but it cannot manage keys, profiles, or webhooks. Use it for anything that writes. Reserve `full` for your own backend. See [Authentication](/docs/authentication) for the complete scope table. ## Require approval The control that matters most. With `requireApproval`, a post created by the agent does not publish — it goes to `pending_approval` and an email goes to a human with an approve/reject link. ```typescript const { secret } = await cutedyno.apiKeys.create({ name: 'Content agent', scope: 'agent', requireApproval: true, maxPostsPerDay: 10, }); ``` Now every publish from that key stops for review: ```typescript const { post } = await cutedyno.posts.create({ content: 'Introducing our autumn range.', accountIds, publishNow: true, }); post.status; // 'pending_approval' post.approvers; // ['you@yourcompany.com'] ``` Note what `publishNow: true` did: nothing. The flag is a request, and the policy overrides it. This is the whole point — the agent cannot opt out of review by choosing different parameters, and it does not need to know that review exists. Approval routes to whoever you nominate in `approvers`, and to the human who created the key when you nominate nobody. An `agent`-scope key cannot nominate its own approver at all; review always goes to the key's owner. Otherwise an agent could name an address it controls and approve its own work, which is not review. Set the same thing on a profile with [`PUT /v1/policy`](/docs/api/update-policy) when the requirement belongs to the customer rather than to one integration: ```typescript await cutedyno.policy.update({ profileId: 'prof_customer_42', requireApproval: true, }); ``` Policy and key are ORed. Either one demanding review is enough, so a customer can tighten their own profile without your cooperation, and you can tighten one agent without touching the customer. ### What the human sees An email with the post text, the target accounts, and two links. Approving releases the post to its original schedule; rejecting leaves it unpublished with the reviewer's comment attached to the post. Everyone nominated must approve before it publishes. Editing a post that is already approved sends it back for review. Approval attaches to the content, not to the post ID. Watch the `approval.requested`, `approval.approved`, and `approval.rejected` [webhooks](/docs/webhooks) to reflect the state in your own UI. ## Restrict the accounts An agent that manages one product's Instagram has no business posting to the corporate LinkedIn. ```typescript await cutedyno.apiKeys.create({ name: 'Product IG agent', scope: 'agent', allowedAccountIds: ['3f2a…'], }); ``` Any post naming an account outside the list is rejected with `account_not_allowed` before anything is queued. Either an account's `id` or its `platformAccountId` satisfies the list, so you can write it with whichever ID you already store. Profile-level `allowedPlatforms` does the same thing one level up — useful when a customer is only paying for LinkedIn, and you would rather the answer to "can I post this to TikTok?" be `platform_not_allowed` than a support conversation. ## Cap the volume ```typescript await cutedyno.apiKeys.create({ name: 'Content agent', scope: 'agent', maxPostsPerDay: 5, }); ``` A loop that decides to post every 30 seconds hits `daily_post_limit` on the sixth attempt rather than filling a feed with 200 posts. The count is posts created by that key, resetting at midnight UTC. This is a blast-radius control, not a rate limit. Set it near the real ceiling of what the agent should ever do in a day — if a well-behaved agent posts three times a day, five is a reasonable cap and fifty is not. ## Expire the key ```typescript await cutedyno.apiKeys.create({ name: 'Campaign agent (Q3)', scope: 'agent', requireApproval: true, expiresIn: 90, }); ``` Keys that outlive the project they were made for are how credentials leak. An expiring key means a forgotten integration fails closed with `api_key_expired` instead of quietly retaining access for years. ## Watch what happened Governance without an audit trail is a policy nobody can check. Two logs, both filterable by key: ```typescript const { events } = await cutedyno.events.list({ limit: 50 }); // actorType: 'api_key' | 'user' | 'system' // action: 'post.created', 'post.publish_requested', 'account.connected', … const { logs } = await cutedyno.logs.list({ limit: 50 }); // method, path, statusCode, requestId, apiKeyId ``` `events` is the semantic trail — who did what to which resource. `logs` is the raw HTTP record, useful when you need to know exactly what an agent sent. Both are visible in the dashboard under [Activity](/dashboard/activity). Read these after a surprise. An agent that produced 40 rejected posts in an hour is visible here long before anyone complains about the content. ## A worked setup A content agent in Claude, allowed to draft and schedule for two brand accounts, with everything reviewed and a hard ceiling: ```typescript const { secret } = await cutedyno.apiKeys.create({ name: 'Claude content agent', scope: 'agent', requireApproval: true, allowedAccountIds: [instagramId, linkedinId], maxPostsPerDay: 8, expiresIn: 180, }); ``` Give `secret` to the [MCP server](/docs/mcp) as `CUTEDYNO_API_KEY`. The agent can now research, draft, schedule, and report. It cannot publish unreviewed, cannot reach any other account, cannot exceed eight posts a day, cannot change its own permissions, and stops working in six months. The agent does not need to be told any of this, and cannot talk its way out of it. That is the difference between a policy and a prompt. ## Next - [Authentication](/docs/authentication) — scopes and key management in full - [MCP server](/docs/mcp) — connect the agent - [Webhooks](/docs/webhooks) — approval events - [Errors](/docs/errors) — `account_not_allowed`, `daily_post_limit`, `platform_not_allowed`, `approval_required` --- # Reporting Read delivery results, state history, audit events, and request logs back into your own reporting. Once a post leaves your system, four sources tell you what happened to it. Each answers a different question, and reaching for the wrong one is why "why didn't this publish?" takes longer than it should. | Source | Answers | | --- | --- | | [`GET /v1/posts/:id`](/docs/api/get-post) | What did each account do with this post? | | [`GET /v1/posts/:id/history`](/docs/api/get-post-history) | How did it get to its current status? | | [`GET /v1/events`](/docs/api/list-events) | Who or what caused a change? | | [`GET /v1/logs`](/docs/api/list-api-logs) | What did my integration actually send? | ## Per-account results A post is one object that fans out to several accounts, so success is not a single boolean. `results` carries one entry per target, written as each account completes: ```json { "post": { "id": "9f1c…", "status": "partially_published", "results": [ { "targetId": "3f2a…", "platform": "instagram", "pageName": "Acme Coffee", "status": "success", "result": { "id": "17912345678901234" } }, { "targetId": "8b41…", "platform": "tiktok", "pageName": "acme", "status": "failed", "error": "spam_risk_too_many_posts" } ] } } ``` `results` is `null` until publishing starts. `result` holds the platform's own response, which is where you find the native post id to link to — Instagram returns a media id, YouTube a video id, and LinkedIn a URN. The shape differs per platform because it is passed through untouched rather than flattened into a lowest common denominator. Store `targetId` and the native id together. That pairing is what lets you show "live on Instagram" next to a link, and it is the only durable join between your records and the platform's. ## Why a post ended up where it is `history` is the transition log, oldest first: ```json { "history": [ { "fromState": null, "toState": "scheduled", "reason": null, "createdAt": "2026-08-01T09:00:00Z" }, { "fromState": "scheduled", "toState": "processing", "reason": null, "createdAt": "2026-08-01T12:00:02Z" }, { "fromState": "processing", "toState": "partially_published", "reason": "1 of 2 accounts failed", "createdAt": "2026-08-01T12:00:19Z" } ] } ``` `reason` is populated on the transitions where something was decided — a cancellation, a rejection, a partial publish — and null on the routine ones. When a customer asks why a post went out three minutes late, this is the answer, and it is cheaper than reading your own logs. ## Who did it Audit events name the actor behind each change, which matters as soon as more than one thing can act on a profile: ```typescript const { events } = await cutedyno.events.list({ profileId: customer.cutedynoProfileId, limit: 50, }); for (const event of events) { // actorType: 'api_key' | 'user' | 'system' console.log(event.createdAt, event.actorType, event.action, event.resourceId); } ``` `apiKeyId` distinguishes one integration from another, so an agent key and your own backend key are separable in a report. `system` covers work CuteDyno did on its own — a scheduled post firing, a token refreshed. `metadata` carries whatever was relevant to that action and is deliberately untyped; treat it as display material rather than something to branch on. ## What your integration sent Request logs are your own traffic, replayed back with status codes and timings: ```typescript const { logs } = await cutedyno.logs.list({ limit: 100 }); const failures = logs.filter((entry) => entry.statusCode >= 400); ``` Every entry carries the `requestId` that was returned in the failing response, so a support conversation can start from an identifier both sides can see. `durationMs` is measured server-side, which makes it the honest number to compare against your own client-side timing when you suspect the network rather than the API. This is the endpoint to check first when a call "did nothing" — an absent log entry means the request never arrived, which is a different problem from a rejected one. ## Building a reporting loop Polling all four endpoints on a timer is the wrong shape. Subscribe to webhooks and treat the endpoints as detail lookups: ```typescript app.post('/webhooks/cutedyno', async (req, res) => { const event = cutedyno.webhooks.verify(req.rawBody, req.headers, SECRET); res.sendStatus(200); if (event.type === 'post.published' || event.type === 'post.failed') { const { post } = await cutedyno.posts.get({ id: event.data.postId }); await recordDelivery(post); } }); ``` The webhook says something changed; the fetch tells you what it became. Doing it this way keeps you inside the rate limit no matter how many profiles you run, because traffic scales with activity rather than with the number of customers. [Webhooks](/docs/webhooks) covers verification and the retry schedule. ## Platform metrics Views, reach, saves, and watch time are collected per published post, but they are not on the v1 API yet — they are visible in the [dashboard](/dashboard/analytics). What each platform exposes differs enough that a single flattened shape would misrepresent most of them; [Platforms](/docs/platforms) lists what is available per network. Until that lands, the native post id in `results[].result` is the hook you need: it is what you pass to a platform's own insights API if you already hold that access, and what you store so a later backfill has something to join on. --- # Platforms What each network accepts, what it requires, and where they differ. CuteDyno takes one post and publishes it five different ways. The API hides most of that, but not all of it — a platform that requires a video title, or refuses a video over a certain length, cannot be abstracted away. This section covers what is specific to each network. Everything common to all of them is in [Publishing](/docs/publishing). ## At a glance | | Text | Images | Video | Notes | | --- | --- | --- | --- | --- | | [Facebook](/docs/platforms/facebook) | Yes | 1 or many | Reels | Pages only | | [Instagram](/docs/platforms/instagram) | No | Up to 10 | Reels | Professional accounts only | | [TikTok](/docs/platforms/tiktok) | No | Yes | Yes | App audit affects visibility | | [LinkedIn](/docs/platforms/linkedin) | Yes | 1 or many | Yes | Links become article shares | | [YouTube](/docs/platforms/youtube) | No | No | Yes | Title required | Instagram, TikTok, and YouTube reject text-only posts. If a post targets a mix and one of them is text-only, that account fails while the rest succeed — the post ends up `partially_published`. Check with [`POST /v1/posts/validate`](/docs/api/validate-post) first. ## Content type is inferred You never declare a content type. CuteDyno reads the `media` array: a video makes it a video post, otherwise images make it an image post, otherwise it is text. Platform-specific shapes follow from there — a video to Instagram is published as a Reel, several images to Instagram become a carousel — without you choosing a format name. ## Per-platform options The `platforms` object carries settings that only exist on one network: ```typescript await cutedyno.posts.create({ content: 'Same idea, three houses.', media: [{ type: 'video', url }], accountIds: [tiktokId, youtubeId, facebookId], platforms: { tiktok: { privacy_level: 'PUBLIC_TO_EVERYONE', disable_duet: true }, youtube: { title: 'Same idea, three houses', privacyStatus: 'public' }, facebook: { privacy: { value: 'PUBLIC' } }, }, }); ``` Options are applied only to accounts on that platform, so one payload can carry all of them. Each platform page below lists its own options in full. ## What CuteDyno enforces, and what the platform does CuteDyno checks a small number of things before spending an upload: - File type and size at upload (100 MB, common image and video formats). - TikTok video duration against the creator's own limit, which TikTok reports per account. - TikTok photo titles, trimmed to 90 characters. - Instagram carousels, capped at 10 images. - YouTube posts, which need a title. Everything else — caption lengths, aspect ratios, codec support, community guidelines — is decided by the platform when it receives the post. Those arrive as a per-account failure in `results[].error` with the platform's own wording, which is usually more accurate than anything we could predict in advance. ## Text per platform One caption rarely fits five networks. LinkedIn readers expect a paragraph; TikTok expects a line. Where a platform needs its own text, its `platforms` entry accepts it — `platforms.youtube.title`, `platforms.tiktok.title`, `platforms.linkedin.title` — and the shared `content` fills in everywhere else. ## Analytics Published posts are tracked per platform, but the depth varies with what each API exposes: | Platform | Available | | --- | --- | | Instagram | Views, reach, saves, shares, likes, comments; average watch time on Reels | | Facebook | Views, likes, comments, shares, reach; follower demographics by country and city | | TikTok | Views, likes, comments, shares per video; follower and video counts | | YouTube | Views, likes, comments, shares, watch time, subscribers gained | | LinkedIn | Publishing history only, unless your app has Community Management access | ## Comments Comment sync and moderation work on Facebook and Instagram. TikTok, LinkedIn, and YouTube do not expose the webhooks or moderation endpoints needed, so [`GET /v1/comments`](/docs/api/list-comments) returns Facebook and Instagram comments only. --- # TikTok Video and photo posts, privacy levels, commercial disclosure, and the app audit that decides who can see them. TikTok accepts video and photo posts. It rejects text-only posts, and it is stricter than the other platforms about who is allowed to publish publicly. ## The audit problem Read this before anything else. TikTok's Content Posting API has two tiers: - **Unaudited app** — every post is forced to `SELF_ONLY`. Visible to the creator, nobody else. The API accepts the post and returns success. - **Audited app** — posts can be public. If your posts publish successfully and no one can see them, this is why, and no amount of debugging your integration will change it. Apply for audit in the TikTok Developer Portal. A related failure is domain verification: TikTok fetches video from the URL you supply, and refuses URLs on unverified domains with `url_ownership_unverified`. URLs from [`/v1/media/upload`](/docs/api/create-media-upload) are on a verified domain, so use them unless you have verified your own. ## Video ```typescript await cutedyno.posts.create({ content: 'Three ways to use the new grinder.', media: [{ type: 'video', url: videoUrl }], accountIds: [tiktokAccountId], platforms: { tiktok: { title: 'Three ways to use the new grinder', privacy_level: 'PUBLIC_TO_EVERYONE', disable_duet: true, video_cover_timestamp_ms: 2000, }, }, }); ``` Publishing is asynchronous on TikTok's side as well as ours: CuteDyno hands over the URL, TikTok downloads and processes the video, and we poll for up to ten minutes before giving up. A post can sit in `processing` for several minutes for a large video, which is normal. `content` becomes the caption. `platforms.tiktok.title` is the video's title, which TikTok treats separately. ## Photos ```typescript await cutedyno.posts.create({ content: 'Roastery, 6am.', media: [ { type: 'image', url: firstUrl }, { type: 'image', url: secondUrl }, ], accountIds: [tiktokAccountId], platforms: { tiktok: { title: 'Roastery, 6am' } }, }); ``` Photo titles are capped at 90 characters and trimmed for you, taking the first line or sentence of your caption when you do not supply a title. The caption goes through in full as the description. Images must be JPEG or WebP. Anything else — PNG, GIF — is converted to JPEG before upload, so you do not have to think about it. ## Duration TikTok's maximum video length depends on the creator's account, not on your app. CuteDyno reads that limit per account and rejects a longer video before uploading, with the account's actual maximum in the message. There is no fixed number to code against. ## Options | Option | Effect | | --- | --- | | `title` | Video title, or photo title. Distinct from the caption. | | `privacy_level` | `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. Defaults to the widest the creator allows. | | `disable_comment` | Turn off comments. | | `disable_duet` | Turn off duets. | | `disable_stitch` | Turn off stitches. | | `video_cover_timestamp_ms` | Frame to use as the cover. Defaults to 1 second in. | | `brand_content_toggle` | Discloses a paid partnership. | | `brand_organic_toggle` | Discloses the creator promoting their own brand. | | `is_aigc` | Marks the video as AI-generated. | | `auto_add_music` | Let TikTok suggest music for photo posts. | | `upload_as_draft` | Send to the creator's TikTok drafts instead of publishing. | `privacy_level` is validated against what the creator's account actually permits, so a private account cannot be made to post publicly by passing a wider value. ## Commercial disclosure If `brand_content_toggle` is set, TikTok requires the disclosure to be coherent: branded content cannot also be `SELF_ONLY`, and you must indicate whether the content promotes the creator, a third party, or both. Incoherent combinations are rejected before upload with a message naming the problem. ## Drafts `upload_as_draft: true` sends the video to the creator's TikTok inbox instead of publishing. They finish and post it from the app. This is the honest option for an unaudited app: rather than publishing something only the creator can see, put it in their drafts where they expect to find it. TikTok allows five pending inbox drafts per 24 hours. ## Failures worth handling | Message | Meaning | | --- | --- | | `unaudited_client_can_only_post_to_private_accounts` | Your app is not audited. See above. | | `url_ownership_unverified` | The media URL's domain is not verified with TikTok. | | `spam_risk_too_many_pending_share` | Too many pending posts. Slow down. | | This TikTok account cannot post more right now | The creator hit TikTok's own rate limit. Retry later. | | Video exceeds TikTok max duration | Shorter video needed; the message carries the account's limit. | These arrive in `results[].error` for the TikTok account. Other accounts in the same post are unaffected. ## Analytics Per-video views, likes, comments, and shares, plus follower and video counts on the account. TikTok does not expose audience demographics to the API, so those are unavailable rather than empty. ## Comments Not supported. TikTok has no comment webhook or moderation API for this integration, so TikTok comments do not appear in [`GET /v1/comments`](/docs/api/list-comments). --- # Instagram Reels, carousels, and the account type requirement that blocks most first attempts. Instagram accepts images and video. It does not accept text-only posts — there is nowhere for them to go. ## Account requirements Instagram's API only works with **Professional** accounts: Business or Creator. A personal account cannot be published to by any tool, including this one, and the connect flow will fail rather than silently connect something unusable. Two shapes work: - **Instagram Business, linked to a Facebook Page** — connected through Facebook, using the Page's token. - **Instagram Professional, standalone** — connected directly through Instagram login. Both publish identically. Put the requirement next to your Connect button; it is the most common reason a connection fails. ## Video becomes a Reel ```typescript await cutedyno.posts.create({ content: 'Behind the counter this morning. ☕️', media: [{ type: 'video', url: videoUrl }], accountIds: [instagramAccountId], platforms: { instagram: { thumbnail_url: coverUrl, collaborators: ['acmeroasters'], }, }, }); ``` There is no separate reel content type: Instagram publishes API video as Reels, so every video post is one. Instagram processes video on its own side before it can be published. CuteDyno creates a media container, waits for Instagram to finish — up to five minutes for video, two for images — and then publishes. A minute or two in `processing` for a large video is normal. ## Carousels Two to ten images in the `media` array become a carousel: ```typescript await cutedyno.posts.create({ content: 'Five shots from the roastery.', media: images.map((url) => ({ type: 'image', url })), accountIds: [instagramAccountId], }); ``` Above ten, Instagram accepts only the first ten. Split into several posts rather than relying on that. ## Options | Option | Effect | | --- | --- | | `location_id` | Instagram location ID to tag. | | `thumbnail_url` | Cover image for a Reel. Must be publicly reachable. | | `collaborators` | Up to three usernames invited as collaborators. | Collaborators are invitations, not facts: the post publishes immediately and the collaborator appears once they accept. ## Media rules Instagram is the fussiest platform about media, and enforces all of it on their side: - Feed images between 4:5 and 1.91:1. Squarer or wider is cropped or rejected. - Reels are vertical, 9:16. Other ratios get letterboxed. - Minimum 3 seconds of video. - JPEG and PNG for images; MP4 with H.264 and AAC for video. A rejection arrives as `results[].error` with Instagram's own wording, which is more specific than a generic pre-check would be. ## Failures worth handling | Message | Meaning | | --- | --- | | media container failed processing | Instagram rejected the file. Usually codec, aspect ratio, or duration. | | media container expired before publishing | Processing took longer than Instagram's own container lifetime. Retry. | | media container processing timed out | Instagram never finished. Retry; if it repeats, the file is the problem. | | Authentication failed. Please reconnect your account. | The token was revoked. Run the [connect flow](/docs/guides/connecting-accounts) again. | ## Analytics Views, reach, saves, shares, likes, and comments per post, plus average watch time on Reels. Audience demographics by age, gender, and country are available at the account level. ## Comments Fully supported. Instagram comments arrive as `comment.received` webhooks, appear in [`GET /v1/comments`](/docs/api/list-comments), and can be replied to, hidden, or deleted. --- # Facebook Page posts, multi-photo feeds, Reels, and the options only Facebook has. Facebook is the most permissive of the five: text, links, one image, several images, and video all work. ## Pages only CuteDyno publishes to Facebook **Pages**, not personal profiles. Meta does not allow API posting to profiles, so there is nothing to configure. The person connecting must have a content or admin role on the Page. If a Page is missing from the list after connecting, that is almost always the reason — Meta only returns Pages the user can act on. ## Text and links ```typescript await cutedyno.posts.create({ content: 'We wrote up how we source our beans.', accountIds: [facebookPageId], }); ``` Facebook is the only platform that publishes a text-only post. A link in the text is unfurled by Facebook into a preview card. ## Images One image, or several in one feed post: ```typescript await cutedyno.posts.create({ content: 'Roastery day.', media: images.map((url) => ({ type: 'image', url })), accountIds: [facebookPageId], platforms: { facebook: { alt_text_custom: 'Coffee beans cooling in the roaster drum', place: '109637905730051', }, }, }); ``` Multi-photo posts upload each image separately and then attach them to a single feed post, so a post with eight images takes noticeably longer than one with one. ## Video becomes a Reel ```typescript await cutedyno.posts.create({ content: 'Sixty seconds inside the roastery.', media: [{ type: 'video', url: videoUrl }], accountIds: [facebookPageId], }); ``` Video is published as a Facebook Reel. Facebook downloads the file, processes it, and CuteDyno polls for about two minutes before reporting a timeout — long videos occasionally exceed that and land as a failure even though Facebook eventually publishes them. Check the Page before retrying. ## Options | Option | Effect | | --- | --- | | `privacy` | `{ "value": "PUBLIC" \| "FRIENDS" \| "SELF" }`. Audience for the post. | | `place` | Facebook Place ID to tag. | | `alt_text_custom` | Alt text for the image. | | `no_story` | Publish without also generating a story. | `alt_text_custom` is worth setting. Facebook generates alt text automatically and it is usually poor; a caption written by you is better for the people relying on it. ## Failures worth handling | Message | Meaning | | --- | --- | | Video processing failed | Facebook rejected the file, usually codec or length. | | Video publishing timeout | Processing outlasted the polling window. Check the Page before retrying. | | Access denied. Please check your permissions. | The connected user lost their Page role. | | Authentication failed. Please reconnect your account. | Token revoked. Reconnect. | ## Analytics Views, likes, comments, shares, and reach per post, for both feed posts and Reels, plus follower counts and audience demographics by country and city. ## Comments Fully supported. Comments on Page posts arrive as `comment.received` webhooks, appear in [`GET /v1/comments`](/docs/api/list-comments), and can be replied to, hidden, or deleted. --- # LinkedIn Text, article shares, multi-image posts, video, and what visibility does. LinkedIn accepts everything: text, text with a link, one image, several images, and video. ## Personal profiles and Company Pages Both work. A personal profile posts as the connected person; a Company Page posts as the organisation, and the connecting user must administer it. Company Page **analytics** need LinkedIn's Community Management API access, which is a separate application to LinkedIn. Publishing to a Page does not — only reading its metrics does. Until that access is granted, page analytics return an error rather than pretending to have data. ## Text and articles ```typescript await cutedyno.posts.create({ content: 'We wrote up what we learned migrating 40 customers in a week.', accountIds: [linkedinAccountId], platforms: { linkedin: { visibility: 'PUBLIC' } }, }); ``` A URL in the text turns the post into an article share, with LinkedIn rendering its own preview card. There is no separate parameter for it. ## Images ```typescript await cutedyno.posts.create({ content: 'The team at the conference.', media: images.map((url) => ({ type: 'image', url })), accountIds: [linkedinAccountId], platforms: { linkedin: { title: 'Conference recap' } }, }); ``` `platforms.linkedin.title` is used for a single image or document share, where LinkedIn shows a headline above the media. CuteDyno downloads each file and uploads the bytes to LinkedIn directly, rather than giving LinkedIn a URL to fetch. That means media on a slow origin makes publishing slower, but a private origin is not a problem as long as CuteDyno can reach it. ## Video ```typescript await cutedyno.posts.create({ content: 'A two minute tour of the new office.', media: [{ type: 'video', url: videoUrl }], accountIds: [linkedinAccountId], }); ``` LinkedIn accepts video between 3 seconds and 30 minutes. Video posts publish as `PUBLIC`; the `visibility` option applies to text, article, and image posts. ## Options | Option | Effect | | --- | --- | | `visibility` | `PUBLIC`, `CONNECTIONS`, or `LOGGED_IN`. Defaults to `PUBLIC`. | | `title` | Headline for a single image or document share. | `CONNECTIONS` restricts a personal post to first-degree connections. `LOGGED_IN` requires a LinkedIn account to view — useful for internal announcements that should not appear in search results. ## Writing for LinkedIn The one platform where a cross-posted caption reads worst. LinkedIn's audience expects a few sentences with a point, and the first two lines are all that shows before "see more", so put the substance there. Hashtag stacks that work on Instagram look like spam here. If you are fanning one post out to five networks, this is the account to give its own text. ## Failures worth handling | Message | Meaning | | --- | --- | | LinkedIn author URN is required | The account is missing its author identity. Reconnect it. | | Access denied. Please check your permissions. | The user lost their admin role on the Page. | | LinkedIn company page analytics require Community Management API access | Publishing works; metrics need the extra access. | ## Analytics Publishing history is always available. Impressions, reactions, comments, reshares, and members reached require Community Management API access from LinkedIn. ## Comments Not supported. LinkedIn does not expose the comment webhooks or moderation endpoints this integration would need, so LinkedIn comments do not appear in [`GET /v1/comments`](/docs/api/list-comments). --- # YouTube Video uploads, the required title, privacy status, and Shorts. YouTube takes video and nothing else. No text posts, no images. ## Title is required The one platform with a mandatory option. A post targeting YouTube without `platforms.youtube.title` is rejected before upload: ```typescript await cutedyno.posts.create({ content: 'How we roast our house blend, start to finish.', media: [{ type: 'video', url: videoUrl }], accountIds: [youtubeAccountId], platforms: { youtube: { title: 'How we roast our house blend', description: 'A walkthrough of our roasting process, from green bean to bag.', privacyStatus: 'public', tags: ['coffee', 'roasting', 'specialty coffee'], }, }, }); ``` `platforms.youtube.description` overrides the shared `content` for this account. Titles are what YouTube search runs on, so a title written for YouTube rather than reused from a caption is worth the extra field. ## Options | Option | Effect | | --- | --- | | `title` | Video title. Required. | | `description` | Video description. Falls back to the post's `content`. | | `privacyStatus` | `public`, `private`, or `unlisted`. Defaults to `public`. | | `tags` | Search tags. | `unlisted` is the useful one for review workflows: the video is uploaded and playable by anyone with the link, but does not appear on the channel or in search. Someone can watch it before you flip it public in YouTube Studio. ## Shorts There is no Shorts flag. YouTube decides: a vertical video under 60 seconds is treated as a Short automatically. Upload it the same way as anything else. Analytics are Shorts-oriented, since that is what most API publishing produces. Long-form uploads publish fine, but the metrics shown lean towards Shorts reporting. ## Channel requirements The connected Google account must have a channel. Accounts without one connect and then fail at publish with "No YouTube channel found" — create the channel in YouTube first. Unverified channels are limited to 15-minute uploads. Verifying the channel with a phone number lifts that, and is done in YouTube Studio, not here. ## Uploading CuteDyno fetches the video and streams it to YouTube in 256 KB chunks over a resumable upload. Large files take a while, and the post sits in `processing` throughout. Unlike TikTok and Instagram, there is no processing wait after the upload: once YouTube accepts the bytes, the video ID exists and the post is `published`. YouTube's own transcoding continues afterwards, so a video can be published but not yet playable in every resolution. ## Failures worth handling | Message | Meaning | | --- | --- | | Video URL and title are required for YouTube posts | Missing `platforms.youtube.title`. | | No YouTube channel found for this account | The Google account has no channel. | | YouTube video upload failed | Rejected mid-upload, usually quota or file size. | | Authentication failed. Please reconnect your account. | The Google token was revoked. Reconnect. | YouTube's daily upload quota is per project and generous, but a bulk backfill can exhaust it. Spread large migrations over several days. ## Analytics Views, likes, comments, shares, watch time, and subscribers gained, over a rolling 28-day window and per video. Audience demographics come from YouTube Analytics. ## Comments Not supported. YouTube comments are not synced, so they do not appear in [`GET /v1/comments`](/docs/api/list-comments). --- # MCP server Let Claude, Cursor, Codex, and other agents schedule and publish posts through CuteDyno. The CuteDyno MCP server exposes the API as [Model Context Protocol](https://modelcontextprotocol.io) tools, so an agent can connect an account, upload media, draft a post, and check whether it published — without you writing any integration code. It is generated from the same registry that powers the REST API, so every tool maps to an endpoint with the same validation, the same permissions, and the same errors. There is no separate agent API to fall behind. ## Install ```bash npx -y cutedyno-mcp ``` The server needs one environment variable, `CUTEDYNO_API_KEY`. Create a key in the dashboard under [Developers → API](/dashboard/api). Consider giving an agent a narrower key than your own. A `readonly` key lets it answer questions about your posting history without being able to publish; an `agent` key lets it publish but not repoint your webhooks. See [Authentication](/docs/authentication). ### Claude Desktop Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "cutedyno": { "command": "npx", "args": ["-y", "cutedyno-mcp"], "env": { "CUTEDYNO_API_KEY": "cdyn_live_..." } } } } ``` Restart Claude Desktop. The tools appear under the connector menu. ### Claude Code ```bash claude mcp add cutedyno \ --env CUTEDYNO_API_KEY=cdyn_live_... \ -- npx -y cutedyno-mcp ``` ### Cursor Create `.cursor/mcp.json` in your project, or edit the global config: ```json { "mcpServers": { "cutedyno": { "command": "npx", "args": ["-y", "cutedyno-mcp"], "env": { "CUTEDYNO_API_KEY": "cdyn_live_..." } } } } ``` ### VS Code Create `.vscode/mcp.json`. Note that VS Code uses `servers` rather than `mcpServers`: ```json { "servers": { "cutedyno": { "command": "npx", "args": ["-y", "cutedyno-mcp"], "env": { "CUTEDYNO_API_KEY": "cdyn_live_..." } } } } ``` ### Windsurf Edit `~/.codeium/windsurf/mcp_config.json` with the same shape as the Claude Desktop config above. ### Hosted HTTP transport If you would rather not run a local process, point a client at the hosted endpoint: ```json { "mcpServers": { "cutedyno": { "url": "https://api.cutedyno.com/mcp", "headers": { "Authorization": "Bearer cdyn_live_..." } } } } ``` The HTTP transport runs the same middleware as `/v1`: request logging, idempotency, profile resolution, and rate limits. An agent is a client like any other, and it shows up in [`GET /v1/logs`](/docs/api/list-api-logs) the same way. ## Verify it works ```bash CUTEDYNO_API_KEY=cdyn_live_... npx -y cutedyno-mcp --tools ``` That prints the tool list without starting a server, which is the fastest way to tell a configuration problem from a connection problem. Then ask the agent something read-only, like "which social accounts are connected to my CuteDyno profile?" — if that returns accounts, the key and transport are both fine. ## Tools Twenty-seven tools, grouped by resource. Each one links to its parameters and an example call. {{mcp-tool-index}} Tools marked read-only cannot change anything, which makes them safe to auto-approve in clients that offer per-tool approval. Everything else writes, and `create_post` with `publishNow` writes to a real social account in front of a real audience. ## Resources The server also exposes documentation as MCP resources, so an agent can read how CuteDyno works instead of guessing: {{mcp-resources}} ## Prompts Prepared workflows, filled in with your arguments: {{mcp-prompts}} ## Keeping an agent on a leash An LLM publishing to your company's social accounts without review is a bad idea in the same way that giving a new hire the Twitter password on day one is a bad idea. Three mechanisms, from loosest to tightest: 1. **A `readonly` key.** The agent can read and report, never post. 2. **`requireApproval` on the key or the profile.** The agent's posts land in `pending_approval` and a human decides. Nothing reaches a platform unreviewed. 3. **`allowedAccountIds` and `maxPostsPerDay`.** The agent can only touch nominated accounts, and only so many times a day. [Agent governance](/docs/guides/agent-governance) covers how to combine these. ## Troubleshooting **No tools appear.** The client could not start the server. Run the `--tools` command above; if it works there, the problem is the client's config file — usually a `servers` versus `mcpServers` mixup, or JSON that does not parse. **Every tool fails with `invalid_api_key`.** The key is missing, revoked, or has stray whitespace from a copy-paste. Confirm it with `curl https://api.cutedyno.com/v1/profile -H "Authorization: Bearer $CUTEDYNO_API_KEY"`. **Tools fail with `insufficient_permission`.** The key's scope is too narrow for what the agent tried. A `readonly` key cannot create posts by design. **Posts end up as drafts unexpectedly.** The profile requires approval. Call `get_policy` to confirm, and check [`/dashboard/posts`](/dashboard/posts) for items waiting on review. **Requests are rejected against a local API.** Set `CUTEDYNO_API_URL` to point the server somewhere other than production, for example `http://localhost:3000`. --- # API reference Conventions shared by every endpoint, and the full endpoint index. A REST API over JSON. One base URL, bearer authentication, predictable resource names. ```text https://api.cutedyno.com ``` The machine-readable spec lives at [`/docs/api/openapi.json`](/docs/api/openapi.json). It is generated from the same schemas the API validates against, so it is never out of date — import it into Postman, Insomnia, or a codegen tool and the types will match what the server does. ## Conventions **Authentication.** `Authorization: Bearer cdyn_live_...` on every request. See [Authentication](/docs/authentication). **Content type.** Request bodies are JSON; send `Content-Type: application/json`. Responses are always JSON, including errors. **Naming.** Fields are `camelCase`. Timestamps are ISO 8601 in UTC, for example `2026-08-01T15:00:00.000Z`. IDs are UUIDs unless they come from a platform. **Profiles.** Endpoints that touch customer data accept `profileId` — a query parameter on reads, a body field on writes. Omit it and the request uses the key's home profile. See [Build a platform](/docs/build-a-platform). **Idempotency.** Every write accepts an `Idempotency-Key` header. See [Idempotency](/docs/guides/idempotency). **Errors.** One envelope, machine-readable `code`, and a `requestId` for support. See [Errors](/docs/errors). **Rate limits.** Every response carries `RateLimit-*` headers; a `429` carries `Retry-After`. See [Rate limits](/docs/guides/rate-limits). ## Pagination Two styles, depending on the resource: **Offset pagination** — posts, events, logs. Send `page` and `limit`, read `total` and `hasMore`: ```typescript const { posts, hasMore } = await cutedyno.posts.list({ page: 2, limit: 50 }); ``` Or let the SDK walk it for you: ```typescript for await (const post of cutedyno.posts.iterate({ status: 'failed' })) { console.log(post.id); } ``` **Cursor pagination** — comments, which change while you read them. Send `cursor` from the previous response's `nextCursor`, and stop when it is `null`. `limit` is capped at 100 everywhere. Requesting more is not an error; you get 100. ## Status values A post moves through these: | Status | Meaning | | --- | --- | | `draft` | Saved, no schedule. Nothing will happen until you schedule or publish it. | | `pending_approval` | Waiting on an approver. See [agent governance](/docs/guides/agent-governance). | | `approved` | Approved and about to continue. | | `rejected` | An approver declined it. It stays unpublished. | | `scheduled` | Queued for a future time. | | `queued` | Handed to a worker, about to be published. | | `processing` | Currently uploading to platforms. | | `published` | Succeeded on every target account. | | `partially_published` | Succeeded on some accounts, failed on others. | | `failed` | Failed on every target account. | | `cancelled` | Cancelled before publishing. | `partially_published` is the one people forget. Posting one video to five platforms means five independent uploads with five sets of rules, and a duration that TikTok accepts may exceed what another allows. Treat it as an expected outcome: read `results`, and [retry](/docs/api/retry-post) only what failed. ## Endpoints {{endpoint-index}} --- # List profiles `GET https://api.cutedyno.com/v1/profiles` Returns every profile this API key can act on. A profile is one customer of your platform: it owns its own connected accounts, posts, and policy. Required permission: `connections:read` Required permission: `connections:read` MCP tool: `list_profiles` ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/profiles" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.profiles.list(); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/profiles" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `profiles` (Profile[], required) - `total` (number, required) ```json { "profiles": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "description": "string", "createdAt": "string", "updatedAt": "string" } ], "total": 1 } ``` ## Errors - `authentication_required` (401) — No Authorization header was sent, or it was not a Bearer token. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `insufficient_permission` (403) — The key is missing the scope this endpoint needs, for example posts:write on a readonly key. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Create a profile `POST https://api.cutedyno.com/v1/profiles` Creates a profile for one of your customers. Requires an account-wide key; profile-scoped keys cannot create profiles. Required permission: `connections:write` Required permission: `connections:write` Accepts an `Idempotency-Key` header. MCP tool: `create_profile` ## Body - `name` (string, required) — Display name, usually your customer’s name. - `description` (string) — Free-form note about this customer. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/profiles" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "description": "string" }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.profiles.create({ name: "Acme Corp", description: "string" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/profiles" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "name": "Acme Corp", "description": "string" } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 201 - `profile` (Profile, required) ```json { "profile": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "description": "string", "createdAt": "string", "updatedAt": "string" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `insufficient_permission` (403) — The key is missing the scope this endpoint needs, for example posts:write on a readonly key. - `plan_limit` (403) — A plan quota was exhausted. The response carries entitlement, usage, and limit so you can surface the reason. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Get a profile `GET https://api.cutedyno.com/v1/profiles/{id}` Fetches one profile by id. Required permission: `connections:read` Required permission: `connections:read` ## Path parameters - `id` (string, required) — Profile id. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/profiles/PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.profiles.retrieve('PROFILE_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/profiles/PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `profile` (Profile, required) ```json { "profile": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "description": "string", "createdAt": "string", "updatedAt": "string" } } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Update a profile `PATCH https://api.cutedyno.com/v1/profiles/{id}` Renames a profile or changes its description. Required permission: `connections:write` Required permission: `connections:write` MCP tool: `update_profile` ## Path parameters - `id` (string, required) — Profile id. ## Body - `name` (string) - `description` (string) ## Request ```bash curl -X PATCH "https://api.cutedyno.com/v1/profiles/PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "description": "string" }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.profiles.update('PROFILE_ID', { name: "Acme Corp", description: "string" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/profiles/PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "name": "Acme Corp", "description": "string" } response = requests.patch(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `profile` (Profile, required) ```json { "profile": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "description": "string", "createdAt": "string", "updatedAt": "string" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Delete a profile `DELETE https://api.cutedyno.com/v1/profiles/{id}` Deletes a profile and everything inside it. Disconnect its social accounts first so their tokens are revoked deliberately. Required permission: `connections:write` Required permission: `connections:write` ## Path parameters - `id` (string, required) — Profile id. ## Request ```bash curl -X DELETE "https://api.cutedyno.com/v1/profiles/PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.profiles.del('PROFILE_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/profiles/PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.delete(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `deleted` (boolean, required) - `id` (string, required) ```json { "deleted": false, "id": "a1b2c3d4-0000-4000-8000-000000000000" } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `profile_has_connected_accounts` (409) — Disconnect the profile’s social accounts before deleting it, so tokens are revoked deliberately. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Get the current profile `GET https://api.cutedyno.com/v1/profile` Returns the profile this request resolved to, which is the key’s home profile unless you passed profileId. Required permission: `connections:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `connections:read` MCP tool: `get_profile` ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/profile" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.profiles.current(); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/profile" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `profile` (Profile, required) ```json { "profile": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "description": "string", "createdAt": "string", "updatedAt": "string" } } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List API keys `GET https://api.cutedyno.com/v1/api-keys` Lists the keys in this account. Secrets are never returned. Required permission: `connections:read` Required permission: `connections:read` ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/api-keys" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.apiKeys.list(); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/api-keys" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `keys` (ApiKey[], required) ```json { "keys": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "keyPrefix": "string", "scope": "full", "profileIds": [ "string" ], "expiresAt": "string", "createdAt": "string" } ] } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `insufficient_permission` (403) — The key is missing the scope this endpoint needs, for example posts:write on a readonly key. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Create an API key `POST https://api.cutedyno.com/v1/api-keys` Mints a key, optionally scoped to specific profiles. The secret is returned once and cannot be retrieved later. Required permission: `connections:write` Required permission: `connections:write` Accepts an `Idempotency-Key` header. ## Body - `name` (string, required) — Label shown in the dashboard. - `scope` (object) — Defaults to full. - `profileIds` (string[]) — Restrict the key to these profiles. Omit for access to every profile in the account. - `expiresIn` (integer) — Days until the key expires. Omit for no expiry. - `allowedAccountIds` (string[]) — Restrict the key to these connected accounts. - `maxPostsPerDay` (integer) — Cap posts created per UTC day with this key. - `requireApproval` (boolean) — Force posts from this key through approval. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/api-keys" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "profileIds": [ "string" ], "expiresIn": 0, "allowedAccountIds": [ "string" ], "maxPostsPerDay": 0, "requireApproval": false }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.apiKeys.create({ name: "Acme Corp", profileIds: [ "string" ], expiresIn: 0, allowedAccountIds: [ "string" ], maxPostsPerDay: 0, requireApproval: false }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/api-keys" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "name": "Acme Corp", "profileIds": [ "string" ], "expiresIn": 0, "allowedAccountIds": [ "string" ], "maxPostsPerDay": 0, "requireApproval": False } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 201 - `key` (ApiKey, required) - `secret` (string, required) — The only time the full key is returned. - `message` (string, required) ```json { "key": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "name": "Acme Corp", "keyPrefix": "string", "scope": "full", "profileIds": [ "string" ], "expiresAt": "string", "createdAt": "string" }, "secret": "whsec_2f8a...", "message": "Shipping today." } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `insufficient_permission` (403) — The key is missing the scope this endpoint needs, for example posts:write on a readonly key. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Revoke an API key `DELETE https://api.cutedyno.com/v1/api-keys/{id}` Deactivates a key immediately. A key cannot revoke itself. Required permission: `connections:write` Required permission: `connections:write` ## Path parameters - `id` (string, required) — API key id. ## Request ```bash curl -X DELETE "https://api.cutedyno.com/v1/api-keys/KEY_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.apiKeys.revoke('KEY_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/api-keys/KEY_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.delete(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `revoked` (boolean, required) - `id` (string, required) ```json { "revoked": false, "id": "a1b2c3d4-0000-4000-8000-000000000000" } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List connected accounts `GET https://api.cutedyno.com/v1/accounts` Lists the social accounts connected to a profile. Use `id` everywhere an account id is asked for; `platformAccountId` is the platform’s own id and is accepted too. Required permission: `connections:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `connections:read` MCP tool: `list_accounts` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/accounts?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.accounts.list({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/accounts?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `accounts` (Account[], required) - `profileId` (string, required) ```json { "accounts": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platformAccountId": "platformAccount_a1b2c3d4", "accountId": "account_a1b2c3d4", "platform": "facebook", "name": "Acme Corp", "username": "Acme Corp", "profilePictureUrl": "https://cdn.example.com/media/launch.jpg", "connectedAt": "string" } ], "profileId": "profile_a1b2c3d4" } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Start a connect flow `GET https://api.cutedyno.com/v1/connect/{platform}` Creates a connect session and returns the URL to send your customer to. Equivalent to POST /v1/connect/sessions. Required permission: `connections:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `connections:write` MCP tool: `get_connect_url` ## Path parameters - `platform` (string, required) — One of facebook, instagram, tiktok, linkedin, youtube. ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `redirectUrl` (string) — HTTPS URL to return the user to once they finish. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/connect/{platform}?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.accounts.connect({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/connect/{platform}?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `authUrl` (string, required) - `state` (string, required) - `session` (ConnectSession, required) ```json { "authUrl": "https://cdn.example.com/media/launch.jpg", "state": "string", "session": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platform": "facebook", "status": "pending", "authUrl": "https://cdn.example.com/media/launch.jpg", "url": "https://cdn.example.com/media/launch.jpg", "errorMessage": "Shipping today.", "createdAt": "string", "completedAt": "string", "expiresAt": "string" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Create a connect session `POST https://api.cutedyno.com/v1/connect/sessions` Starts an OAuth flow for one profile and returns the URL to redirect your customer to. Required permission: `connections:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `connections:write` Accepts an `Idempotency-Key` header. ## Body - `platform` (enum, required) One of: facebook, instagram, tiktok, linkedin, youtube. - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `redirectUrl` (string) — HTTPS URL to return the user to once they finish. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/connect/sessions" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "platform": "facebook", "redirectUrl": "https://cdn.example.com/media/launch.jpg" }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.accounts.connect({ profileId: "profile_a1b2c3d4", platform: "facebook", redirectUrl: "https://cdn.example.com/media/launch.jpg" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/connect/sessions" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "platform": "facebook", "redirectUrl": "https://cdn.example.com/media/launch.jpg" } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 201 - `authUrl` (string, required) - `state` (string, required) - `session` (ConnectSession, required) ```json { "authUrl": "https://cdn.example.com/media/launch.jpg", "state": "string", "session": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platform": "facebook", "status": "pending", "authUrl": "https://cdn.example.com/media/launch.jpg", "url": "https://cdn.example.com/media/launch.jpg", "errorMessage": "Shipping today.", "createdAt": "string", "completedAt": "string", "expiresAt": "string" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Get a connect session `GET https://api.cutedyno.com/v1/connect/sessions/{id}` Polls a connect session. Once status is completed, the accounts array lists what was connected. Required permission: `connections:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `connections:read` MCP tool: `get_connect_session` ## Path parameters - `id` (string, required) — Connect session id. ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/connect/sessions/SESSION_ID?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.accounts.getConnectSession('SESSION_ID', { profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/connect/sessions/SESSION_ID?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `session` (ConnectSession, required) - `accounts` (Account[], required) ```json { "session": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platform": "facebook", "status": "pending", "authUrl": "https://cdn.example.com/media/launch.jpg", "url": "https://cdn.example.com/media/launch.jpg", "errorMessage": "Shipping today.", "createdAt": "string", "completedAt": "string", "expiresAt": "string" }, "accounts": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platformAccountId": "platformAccount_a1b2c3d4", "accountId": "account_a1b2c3d4", "platform": "facebook", "name": "Acme Corp", "username": "Acme Corp", "profilePictureUrl": "https://cdn.example.com/media/launch.jpg", "connectedAt": "string" } ] } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List posts `GET https://api.cutedyno.com/v1/posts` Lists posts in a profile, newest first. Required permission: `posts:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:read` MCP tool: `list_posts` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `page` (integer) — Defaults to 1. - `limit` (integer) — Defaults to 20. - `status` (object) — Filter by status. - `contentType` (object) - `platform` (object) ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/posts?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.list({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `posts` (PostSummary[], required) - `page` (number, required) - `limit` (number, required) - `total` (number, required) - `hasMore` (boolean, required) ```json { "posts": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft", "content": "Shipping today.", "contentType": "text", "scheduledAt": "string", "createdAt": "string", "accounts": [ { "id": null, "platformAccountId": null, "platform": null, "name": null } ] } ], "page": 1, "limit": 20, "total": 1, "hasMore": false } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Create a post `POST https://api.cutedyno.com/v1/posts` Creates a draft, schedules a post, or publishes immediately, depending on scheduledAt, publishNow, and draft. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` Accepts an `Idempotency-Key` header. MCP tool: `create_post` ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `content` (string) — Post text. Used as the caption for image and video posts. Required unless the post is a draft with media. - `media` (MediaItem[]) — Images or a single video. Get URLs from POST /v1/media/upload. Content type is inferred: a video wins, otherwise images, otherwise text. - `accountIds` (string[]) — `id` values from GET /v1/accounts; `platformAccountId` also works. Required to schedule or publish, omit to save an unassigned draft. - `scheduledAt` (string) — ISO 8601 datetime, for example 2026-08-01T12:00:00Z. - `publishNow` (boolean) — Queue for immediate publishing. Cannot be combined with scheduledAt. - `platforms` (object) — Per-platform options, applied only to accounts on that platform. Everything here is optional; sensible defaults apply. - `approvers` (string[]) — Emails to send approval requests to, used only when the profile or key requires approval. Defaults to the key owner. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/posts" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "content": "Shipping today.", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "accountIds": [ "string" ], "scheduledAt": "string", "publishNow": false, "platforms": { "facebook": { "privacy": { "value": "PUBLIC" }, "place": "string", "alt_text_custom": "string", "no_story": false }, "instagram": { "location_id": "location_id_a1b2c3d4", "thumbnail_url": "https://cdn.example.com/media/launch.jpg", "collaborators": [ "string" ] }, "tiktok": { "title": "string", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": false, "disable_duet": false, "disable_stitch": false, "video_cover_timestamp_ms": 0, "brand_content_toggle": false, "brand_organic_toggle": false, "is_aigc": false, "auto_add_music": false, "upload_as_draft": false }, "linkedin": { "title": "string", "visibility": "PUBLIC" }, "youtube": { "title": "string", "description": "string", "privacyStatus": "public", "tags": [ "string" ] } }, "approvers": [ "string" ] }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.create({ profileId: "profile_a1b2c3d4", content: "Shipping today.", media: [ { type: "image", url: "https://cdn.example.com/media/launch.jpg" } ], accountIds: [ "string" ], scheduledAt: "string", publishNow: false, platforms: { facebook: { privacy: { value: "PUBLIC" }, place: "string", alt_text_custom: "string", no_story: false }, instagram: { location_id: "location_id_a1b2c3d4", thumbnail_url: "https://cdn.example.com/media/launch.jpg", collaborators: [ "string" ] }, tiktok: { title: "string", privacy_level: "PUBLIC_TO_EVERYONE", disable_comment: false, disable_duet: false, disable_stitch: false, video_cover_timestamp_ms: 0, brand_content_toggle: false, brand_organic_toggle: false, is_aigc: false, auto_add_music: false, upload_as_draft: false }, linkedin: { title: "string", visibility: "PUBLIC" }, youtube: { title: "string", description: "string", privacyStatus: "public", tags: [ "string" ] } }, approvers: [ "string" ] }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "content": "Shipping today.", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "accountIds": [ "string" ], "scheduledAt": "string", "publishNow": False, "platforms": { "facebook": { "privacy": { "value": "PUBLIC" }, "place": "string", "alt_text_custom": "string", "no_story": False }, "instagram": { "location_id": "location_id_a1b2c3d4", "thumbnail_url": "https://cdn.example.com/media/launch.jpg", "collaborators": [ "string" ] }, "tiktok": { "title": "string", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": False, "disable_duet": False, "disable_stitch": False, "video_cover_timestamp_ms": 0, "brand_content_toggle": False, "brand_organic_toggle": False, "is_aigc": False, "auto_add_music": False, "upload_as_draft": False }, "linkedin": { "title": "string", "visibility": "PUBLIC" }, "youtube": { "title": "string", "description": "string", "privacyStatus": "public", "tags": [ "string" ] } }, "approvers": [ "string" ] } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 201 - `post` (object, required) ```json { "post": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft", "profileId": "profile_a1b2c3d4", "scheduledAt": "string", "approvers": [ "string" ], "accounts": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platformAccountId": "platformAccount_a1b2c3d4", "platform": "string", "name": "Acme Corp" } ] } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_scheduled_at` (400) — scheduledAt could not be parsed as an ISO 8601 datetime, or it is in the past. - `key_not_linked_to_user` (400) — Publishing records a human actor. Recreate the key from the dashboard so it carries an owner. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `trial_expired` (402) — The account trial ended. Billing affects every profile at once, so alert an operator instead of retrying. - `account_not_allowed` (403) — The key is restricted to specific connected accounts and one of the supplied accountIds is not among them. - `platform_not_allowed` (403) — The profile policy restricts which platforms may be posted to, and one of the target accounts is on a platform outside that list. - `approval_required` (403) — This profile or key forces posts through review, and no approver could be determined. Supply approvers, or recreate the key so it carries an owner. - `daily_post_limit` (403) — The key has a maxPostsPerDay cap and it has been reached. The cap resets at midnight UTC. - `plan_limit` (403) — A plan quota was exhausted. The response carries entitlement, usage, and limit so you can surface the reason. - `no_accounts_connected` (404) — The profile has no connected social accounts yet. Run the connect flow first. - `accounts_not_found` (404) — None of the supplied accountIds matched a connected account in this profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Validate a post `POST https://api.cutedyno.com/v1/posts/validate` Dry-runs the same validation as create, without writing anything. Use it to show errors in your own UI before submitting. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` MCP tool: `validate_post` ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `content` (string) — Post text. Used as the caption for image and video posts. Required unless the post is a draft with media. - `media` (MediaItem[]) — Images or a single video. Get URLs from POST /v1/media/upload. Content type is inferred: a video wins, otherwise images, otherwise text. - `accountIds` (string[]) — `id` values from GET /v1/accounts; `platformAccountId` also works. Required to schedule or publish, omit to save an unassigned draft. - `scheduledAt` (string) — ISO 8601 datetime, for example 2026-08-01T12:00:00Z. - `publishNow` (boolean) — Queue for immediate publishing. Cannot be combined with scheduledAt. - `platforms` (object) — Per-platform options, applied only to accounts on that platform. Everything here is optional; sensible defaults apply. - `approvers` (string[]) — Emails to send approval requests to, used only when the profile or key requires approval. Defaults to the key owner. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/posts/validate" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "content": "Shipping today.", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "accountIds": [ "string" ], "scheduledAt": "string", "publishNow": false, "platforms": { "facebook": { "privacy": { "value": "PUBLIC" }, "place": "string", "alt_text_custom": "string", "no_story": false }, "instagram": { "location_id": "location_id_a1b2c3d4", "thumbnail_url": "https://cdn.example.com/media/launch.jpg", "collaborators": [ "string" ] }, "tiktok": { "title": "string", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": false, "disable_duet": false, "disable_stitch": false, "video_cover_timestamp_ms": 0, "brand_content_toggle": false, "brand_organic_toggle": false, "is_aigc": false, "auto_add_music": false, "upload_as_draft": false }, "linkedin": { "title": "string", "visibility": "PUBLIC" }, "youtube": { "title": "string", "description": "string", "privacyStatus": "public", "tags": [ "string" ] } }, "approvers": [ "string" ] }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.validate({ profileId: "profile_a1b2c3d4", content: "Shipping today.", media: [ { type: "image", url: "https://cdn.example.com/media/launch.jpg" } ], accountIds: [ "string" ], scheduledAt: "string", publishNow: false, platforms: { facebook: { privacy: { value: "PUBLIC" }, place: "string", alt_text_custom: "string", no_story: false }, instagram: { location_id: "location_id_a1b2c3d4", thumbnail_url: "https://cdn.example.com/media/launch.jpg", collaborators: [ "string" ] }, tiktok: { title: "string", privacy_level: "PUBLIC_TO_EVERYONE", disable_comment: false, disable_duet: false, disable_stitch: false, video_cover_timestamp_ms: 0, brand_content_toggle: false, brand_organic_toggle: false, is_aigc: false, auto_add_music: false, upload_as_draft: false }, linkedin: { title: "string", visibility: "PUBLIC" }, youtube: { title: "string", description: "string", privacyStatus: "public", tags: [ "string" ] } }, approvers: [ "string" ] }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/validate" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "content": "Shipping today.", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "accountIds": [ "string" ], "scheduledAt": "string", "publishNow": False, "platforms": { "facebook": { "privacy": { "value": "PUBLIC" }, "place": "string", "alt_text_custom": "string", "no_story": False }, "instagram": { "location_id": "location_id_a1b2c3d4", "thumbnail_url": "https://cdn.example.com/media/launch.jpg", "collaborators": [ "string" ] }, "tiktok": { "title": "string", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": False, "disable_duet": False, "disable_stitch": False, "video_cover_timestamp_ms": 0, "brand_content_toggle": False, "brand_organic_toggle": False, "is_aigc": False, "auto_add_music": False, "upload_as_draft": False }, "linkedin": { "title": "string", "visibility": "PUBLIC" }, "youtube": { "title": "string", "description": "string", "privacyStatus": "public", "tags": [ "string" ] } }, "approvers": [ "string" ] } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `valid` (boolean, required) - `dryRun` (boolean) - `warnings` (string[]) - `errors` (object[]) - `normalized` (object) ```json { "valid": false, "dryRun": false, "warnings": [ "string" ], "errors": [ { "code": "string", "message": "Shipping today." } ], "normalized": { "profileId": "profile_a1b2c3d4", "contentType": "text", "accountCount": 0, "scheduledAt": "string", "mode": "draft" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Get a post `GET https://api.cutedyno.com/v1/posts/{id}` Fetches one post, including per-account publish results. Required permission: `posts:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:read` MCP tool: `get_post` ## Path parameters - `id` (string, required) — Post id. ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/posts/POST_ID?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.retrieve('POST_ID', { profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/POST_ID?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `post` (Post, required) ```json { "post": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft", "content": "Shipping today.", "contentType": "text", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "platforms": {}, "scheduledAt": "string", "createdAt": "string", "updatedAt": "string", "completedAt": "string", "accounts": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platformAccountId": "platformAccount_a1b2c3d4", "platform": "string", "name": "Acme Corp" } ], "results": [ { "targetId": "target_a1b2c3d4", "platform": "facebook", "pageName": "Acme Corp", "status": "success", "result": null, "error": "string" } ], "completedTargets": 0, "failedTargets": 0, "totalTargets": 0 } } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Update a post `PUT https://api.cutedyno.com/v1/posts/{id}` Edits content, targets, or schedule. Published and in-flight posts cannot be edited. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` MCP tool: `update_post` ## Path parameters - `id` (string, required) — Post id. ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `content` (string) — Post text. Used as the caption for image and video posts. Required unless the post is a draft with media. - `media` (MediaItem[]) — Images or a single video. Get URLs from POST /v1/media/upload. Content type is inferred: a video wins, otherwise images, otherwise text. - `accountIds` (string[]) — `id` values from GET /v1/accounts; `platformAccountId` also works. Required to schedule or publish, omit to save an unassigned draft. - `scheduledAt` (string) — ISO 8601 datetime, for example 2026-08-01T12:00:00Z. - `publishNow` (boolean) — Queue for immediate publishing. Cannot be combined with scheduledAt. - `platforms` (object) — Per-platform options, applied only to accounts on that platform. Everything here is optional; sensible defaults apply. - `approvers` (string[]) — Emails to send approval requests to, used only when the profile or key requires approval. Defaults to the key owner. ## Request ```bash curl -X PUT "https://api.cutedyno.com/v1/posts/POST_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "content": "Shipping today.", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "accountIds": [ "string" ], "scheduledAt": "string", "publishNow": false, "platforms": { "facebook": { "privacy": { "value": "PUBLIC" }, "place": "string", "alt_text_custom": "string", "no_story": false }, "instagram": { "location_id": "location_id_a1b2c3d4", "thumbnail_url": "https://cdn.example.com/media/launch.jpg", "collaborators": [ "string" ] }, "tiktok": { "title": "string", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": false, "disable_duet": false, "disable_stitch": false, "video_cover_timestamp_ms": 0, "brand_content_toggle": false, "brand_organic_toggle": false, "is_aigc": false, "auto_add_music": false, "upload_as_draft": false }, "linkedin": { "title": "string", "visibility": "PUBLIC" }, "youtube": { "title": "string", "description": "string", "privacyStatus": "public", "tags": [ "string" ] } }, "approvers": [ "string" ] }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.update('POST_ID', { profileId: "profile_a1b2c3d4", content: "Shipping today.", media: [ { type: "image", url: "https://cdn.example.com/media/launch.jpg" } ], accountIds: [ "string" ], scheduledAt: "string", publishNow: false, platforms: { facebook: { privacy: { value: "PUBLIC" }, place: "string", alt_text_custom: "string", no_story: false }, instagram: { location_id: "location_id_a1b2c3d4", thumbnail_url: "https://cdn.example.com/media/launch.jpg", collaborators: [ "string" ] }, tiktok: { title: "string", privacy_level: "PUBLIC_TO_EVERYONE", disable_comment: false, disable_duet: false, disable_stitch: false, video_cover_timestamp_ms: 0, brand_content_toggle: false, brand_organic_toggle: false, is_aigc: false, auto_add_music: false, upload_as_draft: false }, linkedin: { title: "string", visibility: "PUBLIC" }, youtube: { title: "string", description: "string", privacyStatus: "public", tags: [ "string" ] } }, approvers: [ "string" ] }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/POST_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "content": "Shipping today.", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "accountIds": [ "string" ], "scheduledAt": "string", "publishNow": False, "platforms": { "facebook": { "privacy": { "value": "PUBLIC" }, "place": "string", "alt_text_custom": "string", "no_story": False }, "instagram": { "location_id": "location_id_a1b2c3d4", "thumbnail_url": "https://cdn.example.com/media/launch.jpg", "collaborators": [ "string" ] }, "tiktok": { "title": "string", "privacy_level": "PUBLIC_TO_EVERYONE", "disable_comment": False, "disable_duet": False, "disable_stitch": False, "video_cover_timestamp_ms": 0, "brand_content_toggle": False, "brand_organic_toggle": False, "is_aigc": False, "auto_add_music": False, "upload_as_draft": False }, "linkedin": { "title": "string", "visibility": "PUBLIC" }, "youtube": { "title": "string", "description": "string", "privacyStatus": "public", "tags": [ "string" ] } }, "approvers": [ "string" ] } response = requests.put(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `post` (Post, required) ```json { "post": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft", "content": "Shipping today.", "contentType": "text", "media": [ { "type": "image", "url": "https://cdn.example.com/media/launch.jpg" } ], "platforms": {}, "scheduledAt": "string", "createdAt": "string", "updatedAt": "string", "completedAt": "string", "accounts": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platformAccountId": "platformAccount_a1b2c3d4", "platform": "string", "name": "Acme Corp" } ], "results": [ { "targetId": "target_a1b2c3d4", "platform": "facebook", "pageName": "Acme Corp", "status": "success", "result": null, "error": "string" } ], "completedTargets": 0, "failedTargets": 0, "totalTargets": 0 } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_scheduled_at` (400) — scheduledAt could not be parsed as an ISO 8601 datetime, or it is in the past. - `invalid_state_transition` (400) — The post is in a state that does not allow this operation, for example editing a post that already published. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `account_not_allowed` (403) — The key is restricted to specific connected accounts and one of the supplied accountIds is not among them. - `platform_not_allowed` (403) — The profile policy restricts which platforms may be posted to, and one of the target accounts is on a platform outside that list. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Cancel a post `DELETE https://api.cutedyno.com/v1/posts/{id}` Cancels a scheduled or queued post. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` MCP tool: `cancel_post` ## Path parameters - `id` (string, required) — Post id. ## Request ```bash curl -X DELETE "https://api.cutedyno.com/v1/posts/POST_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.cancel('POST_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/POST_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.delete(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `post` (object, required) ```json { "post": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft" } } ``` ## Errors - `invalid_state_transition` (400) — The post is in a state that does not allow this operation, for example editing a post that already published. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Publish a post `POST https://api.cutedyno.com/v1/posts/{id}/publish` Moves a draft into the publishing queue. When the profile or key requires approval, the post waits for an approver instead. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` Accepts an `Idempotency-Key` header. MCP tool: `publish_post` ## Path parameters - `id` (string, required) — Post id. ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `scheduledAt` (string) — ISO 8601 datetime, for example 2026-08-01T12:00:00Z. - `approvers` (string[]) — Emails to request approval from. Defaults to the key owner when approval is required. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/posts/POST_ID/publish" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "scheduledAt": "string", "approvers": [ "string" ] }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.publish('POST_ID', { profileId: "profile_a1b2c3d4", scheduledAt: "string", approvers: [ "string" ] }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/POST_ID/publish" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "scheduledAt": "string", "approvers": [ "string" ] } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `post` (object, required) ```json { "post": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft", "profileId": "profile_a1b2c3d4" } } ``` ## Errors - `invalid_state_transition` (400) — The post is in a state that does not allow this operation, for example editing a post that already published. - `key_not_linked_to_user` (400) — Publishing records a human actor. Recreate the key from the dashboard so it carries an owner. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `approval_required` (403) — This profile or key forces posts through review, and no approver could be determined. Supply approvers, or recreate the key so it carries an owner. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Retry a failed post `POST https://api.cutedyno.com/v1/posts/{id}/retry` Requeues a failed or partially published post. Only the accounts that failed are retried. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` Accepts an `Idempotency-Key` header. MCP tool: `retry_post` ## Path parameters - `id` (string, required) — Post id. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/posts/POST_ID/retry" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.retry('POST_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/POST_ID/retry" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.post(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `post` (object, required) ```json { "post": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "status": "draft" } } ``` ## Errors - `invalid_state_transition` (400) — The post is in a state that does not allow this operation, for example editing a post that already published. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Get post state history `GET https://api.cutedyno.com/v1/posts/{id}/history` Returns every state transition for a post, which is the fastest way to explain a failure. Required permission: `posts:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:read` MCP tool: `get_post_history` ## Path parameters - `id` (string, required) — Post id. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/posts/POST_ID/history" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.posts.history('POST_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/posts/POST_ID/history" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `history` (object[], required) ```json { "history": [ { "fromState": "string", "toState": "string", "reason": "string", "createdAt": "string" } ] } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List comments `GET https://api.cutedyno.com/v1/comments` Lists comments on the profile’s published content. Facebook and Instagram only. Required permission: `comments:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `comments:read` MCP tool: `list_comments` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `platform` (enum) One of: facebook, instagram. - `accountId` (string) — Restrict to one account. Either `id` or `platformAccountId` from GET /v1/accounts. - `limit` (integer) - `cursor` (string) — Cursor from a previous response. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/comments?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.comments.list({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/comments?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `comments` (Comment[], required) - `nextCursor` (string, required) ```json { "comments": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "platform": "string", "message": "Shipping today.", "authorId": "author_a1b2c3d4", "authorName": "Acme Corp", "authorUsername": "Acme Corp", "isHidden": false, "hasResponse": false, "responseText": "string", "postId": "post_a1b2c3d4", "parentId": "parent_a1b2c3d4", "account": { "accountId": "account_a1b2c3d4", "name": "Acme Corp", "platform": "string", "profilePictureUrl": "https://cdn.example.com/media/launch.jpg" }, "createdAt": "string", "postedAt": "string" } ], "nextCursor": "string" } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Create a media upload URL `POST https://api.cutedyno.com/v1/media/upload` Returns a presigned PUT URL. Upload the bytes directly to it, then reference publicUrl in a post. Required permission: `media:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `media:write` MCP tool: `upload_media` ## Body - `fileName` (string, required) — Original file name, used to pick an extension. - `fileType` (enum, required) — MIME type of the file you are about to upload. One of: image/jpeg, image/jpg, image/png, image/webp, image/gif, video/mp4, video/quicktime, video/webm, video/x-msvideo. - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/media/upload" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "fileName": "Acme Corp", "fileType": "image/jpeg" }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.media.createUpload({ profileId: "profile_a1b2c3d4", fileName: "Acme Corp", fileType: "image/jpeg" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/media/upload" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "fileName": "Acme Corp", "fileType": "image/jpeg" } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `signedUrl` (string, required) — PUT the file bytes here. - `publicUrl` (string, required) — Use this URL in the post's media array. - `key` (string, required) - `expiresIn` (number, required) — Seconds until signedUrl expires. - `maxBytes` (number, required) — Largest file the upload will accept. ```json { "signedUrl": "https://cdn.example.com/media/launch.jpg", "publicUrl": "https://cdn.example.com/media/launch.jpg", "key": "string", "expiresIn": 0, "maxBytes": 0 } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `unsupported_media_type` (415) — The fileType passed to POST /v1/media/upload is not an accepted image or video type. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List webhook subscriptions `GET https://api.cutedyno.com/v1/webhooks` Lists the webhook endpoints registered for a profile. Required permission: `webhooks:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:read` MCP tool: `list_webhooks` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/webhooks?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.webhooks.list({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `subscriptions` (WebhookSubscription[], required) - `supportedEvents` (string[], required) ```json { "subscriptions": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "string" ], "scope": "profile", "isActive": false, "createdAt": "string", "updatedAt": "string" } ], "supportedEvents": [ "string" ] } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `insufficient_permission` (403) — The key is missing the scope this endpoint needs, for example posts:write on a readonly key. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Create a webhook subscription `POST https://api.cutedyno.com/v1/webhooks` Registers an endpoint. Set scope to account to receive events from every profile through one endpoint. The signing secret is returned once. Required permission: `webhooks:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:write` Accepts an `Idempotency-Key` header. MCP tool: `create_webhook` ## Body - `url` (string, required) — HTTPS endpoint that receives POSTs. - `events` (enum[], required) — Event types to subscribe to, or ["*"] for everything. - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `scope` (object) — Defaults to profile. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/webhooks" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "account.connected" ] }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.webhooks.create({ profileId: "profile_a1b2c3d4", url: "https://cdn.example.com/media/launch.jpg", events: [ "account.connected" ] }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "account.connected" ] } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 201 - `subscription` (WebhookSubscription, required) - `secret` (string, required) — Use this to verify the CuteDyno-Signature header. - `retrySchedule` (number[], required) — Retry delays in seconds. ```json { "subscription": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "string" ], "scope": "profile", "isActive": false, "createdAt": "string", "updatedAt": "string" }, "secret": "whsec_2f8a...", "retrySchedule": [ 0 ] } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `insufficient_permission` (403) — The key is missing the scope this endpoint needs, for example posts:write on a readonly key. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Send a test event `POST https://api.cutedyno.com/v1/webhooks/{id}/test` Delivers a synthetic event so you can verify your endpoint and signature check before waiting on real activity. Required permission: `webhooks:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:write` MCP tool: `test_webhook` ## Path parameters - `id` (string, required) — Webhook subscription id. ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `eventType` (object) — Event type to simulate. Defaults to post.published. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID/test" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4" }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.webhooks.test('WEBHOOK_ID', { profileId: "profile_a1b2c3d4" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID/test" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4" } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 202 - `delivery` (object, required) ```json { "delivery": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "eventType": "string", "status": "string", "createdAt": "string" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List webhook deliveries `GET https://api.cutedyno.com/v1/webhooks/{id}/deliveries` Returns the delivery log for one subscription, including response codes and the next scheduled retry. Required permission: `webhooks:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:read` MCP tool: `list_webhook_deliveries` ## Path parameters - `id` (string, required) — Webhook subscription id. ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `status` (enum) One of: pending, delivered, failed, all. - `limit` (integer) — Page size, 1-100. Defaults to 50. - `offset` (integer) — Rows to skip. Defaults to 0. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID/deliveries?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.webhooks.deliveries('WEBHOOK_ID', { profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID/deliveries?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `deliveries` (WebhookDelivery[], required) - `total` (number, required) - `maxAttempts` (number, required) - `retrySchedule` (number[], required) - `limit` (number, required) - `offset` (number, required) ```json { "deliveries": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "eventType": "string", "status": "pending", "attempts": 0, "responseStatus": 0, "responseBody": "string", "lastAttemptAt": "string", "nextAttemptAt": "string", "createdAt": "string" } ], "total": 1, "maxAttempts": 0, "retrySchedule": [ 0 ], "limit": 20, "offset": 0 } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Update a webhook subscription `PATCH https://api.cutedyno.com/v1/webhooks/{id}` Changes the URL or event list, or pauses delivery with isActive: false. A paused subscription keeps its secret and its delivery history. Required permission: `webhooks:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:write` ## Path parameters - `id` (string, required) — Webhook subscription id. ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `url` (string) — HTTPS endpoint that receives POSTs. - `events` (enum[]) — Replaces the current event list. - `isActive` (boolean) — Set false to stop delivering without deleting. ## Request ```bash curl -X PATCH "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "account.connected" ], "isActive": false }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.request('PATCH', '/v1/webhooks/{id}'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "account.connected" ], "isActive": False } response = requests.patch(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `subscription` (WebhookSubscription, required) ```json { "subscription": { "id": "a1b2c3d4-0000-4000-8000-000000000000", "url": "https://cdn.example.com/media/launch.jpg", "events": [ "string" ], "scope": "profile", "isActive": false, "createdAt": "string", "updatedAt": "string" } } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Delete a webhook subscription `DELETE https://api.cutedyno.com/v1/webhooks/{id}` Removes an endpoint. In-flight retries stop. Required permission: `webhooks:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:write` MCP tool: `delete_webhook` ## Path parameters - `id` (string, required) — Webhook subscription id. ## Request ```bash curl -X DELETE "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.webhooks.del('WEBHOOK_ID'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.delete(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `deleted` (boolean, required) - `id` (string, required) ```json { "deleted": false, "id": "a1b2c3d4-0000-4000-8000-000000000000" } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Rotate the signing secret `POST https://api.cutedyno.com/v1/webhooks/{id}/rotate-secret` Issues a new signing secret and returns it once. The previous secret stops verifying immediately, so deploy the new one before the next event. Required permission: `webhooks:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `webhooks:write` ## Path parameters - `id` (string, required) — Webhook subscription id. ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X POST "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID/rotate-secret" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4" }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.request('POST', '/v1/webhooks/{id}/rotate-secret'); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/webhooks/WEBHOOK_ID/rotate-secret" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4" } response = requests.post(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `id` (string, required) - `secret` (string, required) — Shown once. Use it to verify the CuteDyno-Signature header. ```json { "id": "a1b2c3d4-0000-4000-8000-000000000000", "secret": "whsec_2f8a..." } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `not_found` (404) — The resource does not exist inside the resolved profile. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Get profile policy `GET https://api.cutedyno.com/v1/policy` Returns the guardrails on a profile: approval requirement, allowed platforms, and daily post cap. Required permission: `posts:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:read` MCP tool: `get_policy` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/policy?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.policy.retrieve({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/policy?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `policy` (Policy, required) - `profileId` (string, required) ```json { "policy": { "requireApproval": false, "allowedPlatforms": [ "facebook" ], "maxPostsPerDay": 0 }, "profileId": "profile_a1b2c3d4" } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # Update profile policy `PUT https://api.cutedyno.com/v1/policy` Sets the guardrails on a profile. Only the fields you send change. Required permission: `posts:write` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:write` MCP tool: `update_policy` ## Body - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `requireApproval` (boolean) - `allowedPlatforms` (enum[]) - `maxPostsPerDay` (integer) — Cap on posts per UTC day for this profile. null removes the cap. ## Request ```bash curl -X PUT "https://api.cutedyno.com/v1/policy" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": "profile_a1b2c3d4", "requireApproval": false, "allowedPlatforms": [ "facebook" ], "maxPostsPerDay": 0 }' ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.policy.update({ profileId: "profile_a1b2c3d4", requireApproval: false, allowedPlatforms: [ "facebook" ], maxPostsPerDay: 0 }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/policy" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} payload = { "profileId": "profile_a1b2c3d4", "requireApproval": False, "allowedPlatforms": [ "facebook" ], "maxPostsPerDay": 0 } response = requests.put(url, json=payload, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `policy` (Policy, required) - `profileId` (string, required) ```json { "policy": { "requireApproval": false, "allowedPlatforms": [ "facebook" ], "maxPostsPerDay": 0 }, "profileId": "profile_a1b2c3d4" } ``` ## Errors - `invalid_request` (400) — The request body or query string failed validation. The message names the offending field. - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List audit events `GET https://api.cutedyno.com/v1/events` Returns the audit trail for a profile: who did what, whether it came from a key, an agent, or a person. Required permission: `posts:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:read` MCP tool: `list_events` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `limit` (integer) — Page size, 1-100. Defaults to 50. - `offset` (integer) — Rows to skip. Defaults to 0. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/events?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.events.list({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/events?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `events` (AuditEvent[], required) - `limit` (number, required) - `offset` (number, required) - `profileId` (string, required) ```json { "events": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "actorType": "api_key", "action": "string", "resourceType": "string", "resourceId": "resource_a1b2c3d4", "apiKeyId": "apiKey_a1b2c3d4", "userId": "user_a1b2c3d4", "metadata": null, "createdAt": "string" } ], "limit": 20, "offset": 0, "profileId": "profile_a1b2c3d4" } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List API request logs `GET https://api.cutedyno.com/v1/logs` Returns recent API calls made against a profile, with status codes, durations, and request ids. Required permission: `posts:read` Accepts `profileId` to target a specific profile. Omit it to use the key’s home profile. Required permission: `posts:read` MCP tool: `list_logs` ## Query parameters - `profileId` (string) — Profile to act on. Defaults to the profile the API key was created in. - `limit` (integer) — Page size, 1-100. Defaults to 50. - `offset` (integer) — Rows to skip. Defaults to 0. ## Request ```bash curl -X GET "https://api.cutedyno.com/v1/logs?profileId=PROFILE_ID" \ -H "Authorization: Bearer $CUTEDYNO_API_KEY" ``` ```typescript import { CuteDyno } from '@cutedyno/node'; const cutedyno = new CuteDyno(); const result = await cutedyno.logs.list({ profileId: "PROFILE_ID" }); console.log(result); ``` ```python import os import requests url = "https://api.cutedyno.com/v1/logs?profileId=PROFILE_ID" headers = {"Authorization": f"Bearer {os.environ['CUTEDYNO_API_KEY']}"} response = requests.get(url, headers=headers) response.raise_for_status() print(response.json()) ``` ## Response 200 - `logs` (ApiRequestLog[], required) - `limit` (number, required) - `offset` (number, required) - `profileId` (string, required) ```json { "logs": [ { "id": "a1b2c3d4-0000-4000-8000-000000000000", "method": "string", "path": "string", "statusCode": 0, "durationMs": 0, "requestId": "request_a1b2c3d4", "errorMessage": "Shipping today.", "createdAt": "string" } ], "limit": 20, "offset": 0, "profileId": "profile_a1b2c3d4" } ``` ## Errors - `invalid_api_key` (401) — The key does not exist, was revoked, or is malformed. Keys start with cdyn_live_. - `profile_not_accessible` (403) — The requested profile is outside this key’s scope, or belongs to another account. - `rate_limit_exceeded` (429) — Too many requests for this key. Honour the Retry-After header before retrying. - `internal_error` (500) — Something failed on our side. Retry with the same Idempotency-Key; report the requestId if it persists. --- # List profiles MCP tool `list_profiles`, calling `GET /v1/profiles`. Lists every profile this API key can act on. Start here when the key serves multiple customers, then pass profileId to the other tools. Read only: yes. ## Parameters This tool takes no parameters. --- # Create a profile MCP tool `create_profile`, calling `POST /v1/profiles`. Creates a profile for a new customer. Requires an account-wide API key. Read only: no. ## Parameters - `name` (string, required) — Display name for the profile. - `description` (string) — Free-form note. --- # Get a profile MCP tool `get_profile`, calling `GET /v1/profile`. Returns the profile this request resolves to, including its name and description. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Update a profile MCP tool `update_profile`, calling `PATCH /v1/profiles/{id}`. Renames a profile or changes its description. Read only: no. ## Parameters - `id` (string, required) — Profile id to update. - `name` (string) - `description` (string | null) --- # List connected accounts MCP tool `list_accounts`, calling `GET /v1/accounts`. Returns the social accounts connected to a profile. Use each `id` in accountIds when creating a post, and `accountId` when filtering comments. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Get a connect URL MCP tool `get_connect_url`, calling `GET /v1/connect/{platform}`. Starts a hosted OAuth flow and returns authUrl plus a session id. Send the user to authUrl; the account appears in list_accounts once they finish. Read only: no. ## Parameters - `platform` (enum, required) — Platform to connect. One of: facebook, instagram, tiktok, linkedin, youtube. - `redirectUrl` (string) — HTTPS URL to return the user to once they finish or cancel. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Get a connect session MCP tool `get_connect_session`, calling `GET /v1/connect/sessions/{id}`. Checks whether a connect flow finished. Prefer the connection.completed webhook over polling this. Read only: yes. ## Parameters - `id` (string, required) — Connect session id. get_connect_url returns it, and the OAuth redirect carries it as `state`. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # List posts MCP tool `list_posts`, calling `GET /v1/posts`. Returns posts for a profile, newest first, with filters for status, content type, and platform. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. - `page` (integer) — Defaults to 1. - `limit` (integer) — Defaults to 20, max 100. - `status` (enum) — Filter by lifecycle state. One of: draft, pending_approval, approved, rejected, scheduled, queued, processing, published, partially_published, failed, cancelled. - `contentType` (enum) One of: text, image, video. - `platform` (enum) One of: facebook, instagram, tiktok, linkedin, youtube. --- # Get a post MCP tool `get_post`, calling `GET /v1/posts/{id}`. Returns one post with its content, media, target accounts, and per-account publish results. Read only: yes. ## Parameters - `id` (string, required) — Post id. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Create a post MCP tool `create_post`, calling `POST /v1/posts`. Creates a post. Omit publishNow and scheduledAt to save a draft, set scheduledAt to schedule, or set publishNow to publish right away. Read only: no. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. - `content` (string) — Post text or caption. - `media` (object[]) — Images or a video. Upload with upload_media first. - `accountIds` (string[]) — Account `id` values from list_accounts. - `scheduledAt` (string) — ISO 8601 datetime to publish at, for example 2026-08-01T12:00:00Z. - `publishNow` (boolean) — Publish immediately. Omit together with scheduledAt to save a draft. - `platforms` (object) — Per-platform options keyed by platform name. --- # Update a post MCP tool `update_post`, calling `PUT /v1/posts/{id}`. Edits a draft or scheduled post. Published, processing, and partially published posts cannot be edited. Read only: no. ## Parameters - `id` (string, required) — Post id to update. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. - `content` (string) — Post text or caption. - `media` (object[]) — Images or a video. Upload with upload_media first. - `accountIds` (string[]) — Account `id` values from list_accounts. - `scheduledAt` (string) — ISO 8601 datetime to publish at, for example 2026-08-01T12:00:00Z. - `publishNow` (boolean) — Publish immediately. Omit together with scheduledAt to save a draft. - `platforms` (object) — Per-platform options keyed by platform name. --- # Cancel a post MCP tool `cancel_post`, calling `DELETE /v1/posts/{id}`. Cancels a scheduled or queued post before it publishes. Read only: no. ## Parameters - `id` (string, required) — Post id to cancel. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Validate a post MCP tool `validate_post`, calling `POST /v1/posts/validate`. Dry-runs the same checks as create_post without writing anything. Use it before creating a post you are unsure about. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. - `content` (string) — Post text or caption. - `media` (object[]) — Images or a video. Upload with upload_media first. - `accountIds` (string[]) — Account `id` values from list_accounts. - `scheduledAt` (string) — ISO 8601 datetime to publish at, for example 2026-08-01T12:00:00Z. - `publishNow` (boolean) — Publish immediately. Omit together with scheduledAt to save a draft. - `platforms` (object) — Per-platform options keyed by platform name. --- # Publish a post MCP tool `publish_post`, calling `POST /v1/posts/{id}/publish`. Moves a draft into the publishing queue. When the profile requires approval and you pass approvers, the post waits for them instead. Read only: no. ## Parameters - `id` (string, required) — Post id. - `scheduledAt` (string) — Publish at this time instead of immediately. - `approvers` (string[]) — Email addresses to request approval from. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Retry a post MCP tool `retry_post`, calling `POST /v1/posts/{id}/retry`. Requeues a failed or partially published post. Only the accounts that failed are retried. Read only: no. ## Parameters - `id` (string, required) — Post id. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Get post history MCP tool `get_post_history`, calling `GET /v1/posts/{id}/history`. Returns every state transition for a post. This is the fastest way to explain why something failed. Read only: yes. ## Parameters - `id` (string, required) — Post id. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # List comments MCP tool `list_comments`, calling `GET /v1/comments`. Returns comments on a profile’s published content. Facebook and Instagram only. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. - `platform` (enum) One of: facebook, instagram. - `accountId` (string) — The `accountId` field from list_accounts, not `id`. - `limit` (integer) - `cursor` (string) — Cursor from a previous response. --- # Upload media MCP tool `upload_media`, calling `POST /v1/media/upload`. Returns a presigned URL. PUT the file bytes to signedUrl, then pass publicUrl in a post’s media array. Read only: no. ## Parameters - `fileName` (string, required) — Original file name, including extension. - `fileType` (string, required) — MIME type, for example image/jpeg or video/mp4. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # List webhooks MCP tool `list_webhooks`, calling `GET /v1/webhooks`. Returns the webhook endpoints registered for a profile. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Create a webhook MCP tool `create_webhook`, calling `POST /v1/webhooks`. Registers an endpoint to receive events. Set scope to account to receive events from every profile through one endpoint. Read only: no. ## Parameters - `url` (string, required) — HTTPS endpoint that will receive POSTs. - `events` (enum | string[], required) — Event types to subscribe to, or ["*"] for everything. - `scope` (enum) — Defaults to profile. One of: profile, account. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Send a test event MCP tool `test_webhook`, calling `POST /v1/webhooks/{id}/test`. Delivers a synthetic event so the receiver can be verified before real activity happens. Read only: no. ## Parameters - `id` (string, required) — Webhook subscription id. - `eventType` (enum) — Event type to simulate. Defaults to post.published. One of: account.connected, account.disconnected, connection.completed, connection.failed, post.created, post.scheduled, post.published, post.failed, post.partially_published, post.cancelled, approval.requested, approval.approved, approval.rejected, comment.received. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # List webhook deliveries MCP tool `list_webhook_deliveries`, calling `GET /v1/webhooks/{id}/deliveries`. Returns the delivery log for one subscription, with response codes, attempt counts, and the next scheduled retry. Read only: yes. ## Parameters - `id` (string, required) — Webhook subscription id. - `status` (enum) One of: pending, delivered, failed, all. - `limit` (integer) — Defaults to 20. - `offset` (integer) — Defaults to 0. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Delete a webhook MCP tool `delete_webhook`, calling `DELETE /v1/webhooks/{id}`. Removes a webhook endpoint. In-flight retries stop. Read only: no. ## Parameters - `id` (string, required) — Webhook subscription id. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Get profile policy MCP tool `get_policy`, calling `GET /v1/policy`. Returns the guardrails on a profile: whether approval is required, which platforms are allowed, and the daily post cap. Check this before publishing. Read only: yes. ## Parameters - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Update profile policy MCP tool `update_policy`, calling `PUT /v1/policy`. Changes a profile’s guardrails. Only the fields you send are modified. Read only: no. ## Parameters - `requireApproval` (boolean) — Require human approval before anything publishes. - `allowedPlatforms` (enum[] | null) — Restrict publishing to these platforms. null clears the limit. - `maxPostsPerDay` (integer | null) — Cap posts per day. null clears the limit. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # List audit events MCP tool `list_events`, calling `GET /v1/events`. Returns the audit trail for a profile: which actions were taken, and whether by a key, an agent, or a person. Read only: yes. ## Parameters - `limit` (integer) — Defaults to 20. - `offset` (integer) — Defaults to 0. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # List API request logs MCP tool `list_logs`, calling `GET /v1/logs`. Returns recent API calls against a profile with status codes, durations, and request ids. Use it to debug a failing integration. Read only: yes. ## Parameters - `limit` (integer) — Defaults to 20. - `offset` (integer) — Defaults to 0. - `profileId` (string) — Profile to act on. Omit to use the profile this API key belongs to. Call list_profiles first when managing several customers. --- # Error reference Every error code the API returns, what causes it, and whether retrying helps. Every failure from `/v1` and `/mcp` uses one envelope. Branch on `code`; it is stable. Never branch on `message`, which is written for humans and will change. ```json { "error": { "code": "accounts_not_found", "message": "None of the supplied accountIds matched a connected account in this profile.", "docsUrl": "https://cutedyno.com/docs/errors#accounts_not_found", "requestId": "8f14e45f-ea0c-4d7a-9f1a-2b3c4d5e6f70" }, "message": "None of the supplied accountIds matched a connected account in this profile." } ``` Four fields carry the useful information: - **`code`** — the stable identifier to switch on. - **`message`** — a human explanation, safe to log and usually safe to show an operator. - **`docsUrl`** — a deep link to this page, anchored at the code. - **`requestId`** — echoes the `X-Request-Id` header. Quote it when reporting a problem and we can find the exact request. Some codes add a `details` object. `plan_limit` carries `entitlement`, `usage`, and `limit`; `insufficient_permission` carries the `required` permission; `daily_post_limit` carries `limit`, `used`, and `resetsAt`. ## Handling errors in code ```typescript import { CuteDynoError } from '@cutedyno/node'; try { await cutedyno.posts.create(input); } catch (error) { if (!(error instanceof CuteDynoError)) throw error; switch (error.code) { case 'no_accounts_connected': case 'accounts_not_found': return promptUserToReconnect(); case 'plan_limit': case 'trial_expired': return showUpgradePrompt(error.details); case 'rate_limit_exceeded': return retryAfter(error.retryAfter ?? 60); default: if (error.isRetryable) return scheduleRetry(); log.error('CuteDyno rejected the post', { code: error.code, requestId: error.requestId, }); throw error; } } ``` The rule of thumb: **`4xx` is your bug or your user's state, `5xx` and `429` are ours.** Retry the second group with the same [idempotency key](/docs/guides/idempotency); fix or surface the first. ## Codes by status {{error-codes}} ## Legacy error format Before this envelope existed, errors were `{ "error": "some string" }`. If you have a client written against that shape and cannot change it yet, send: ```text CuteDyno-Error-Format: legacy ``` and responses revert to `{ "error": "", "code": "" }`. This is a migration aid with a limited life. Prefer reading `error.code` from the structured shape, which every current response already includes. ## MCP errors Tool calls surface the same codes. A failing tool returns `isError: true` with the code, message, and request ID in the text, so an agent can explain what went wrong rather than retrying blindly. See [MCP](/docs/mcp). --- # Glossary The words this API uses, and the ones it used to use. Some of these have a history. Where an older name is still accepted, it is noted, because you will meet it in stored data and in code written against earlier versions. ## Account A connected social account: a Facebook Page, an Instagram Professional account, a TikTok account, a LinkedIn profile or Company Page, a YouTube channel. Accounts belong to a profile. Every account has two identifiers. `id` is CuteDyno's, and `platformAccountId` is the platform's. Both are accepted wherever an account ID is taken. `accountId` is a deprecated alias of `id`. ## Account (billing) The unhelpful collision: your CuteDyno account, which holds one or more profiles and is what gets billed. Where it matters, these docs say "billing account" or "your account" for this one and "connected account" for the social kind. An **account-level API key** can act across every profile in the account. See [Build a platform](/docs/build-a-platform). ## API key A credential starting `cdyn_live_`. Carries a [scope](/docs/authentication), an optional set of profiles it may reach, and optional restrictions on accounts, daily volume, and approval. Shown once at creation. ## Approver A person who must approve a post before it publishes. Nominated per post, or defaulted to the key's owner. Approval happens over email; see [agent governance](/docs/guides/agent-governance). ## Delivery One attempt to send one webhook event to one endpoint. Retried on failure, up to seven attempts over roughly 31 hours. Inspect them with [`GET /v1/webhooks/:id/deliveries`](/docs/api/list-webhook-deliveries). ## Event Two different things, unfortunately: - A **webhook event** — something that happened, delivered to your endpoint. See [Webhooks](/docs/webhooks). - An **audit event** — a row in the trail returned by [`GET /v1/events`](/docs/api/list-events), recording who did what. ## Idempotency key A caller-supplied string on a write, making a retry safe. The same key with the same body replays the original response; the same key with a different body is an error. See [Idempotency](/docs/guides/idempotency). ## Job The old name for a post. It survives in internal logs and in some dashboard URLs. The API says **post**. ## Media An image or video, uploaded to storage and referenced by URL in a post's `media` array. See [Media uploads](/docs/guides/media-uploads). ## MCP Model Context Protocol, the standard that lets an AI agent call tools. CuteDyno's [MCP server](/docs/mcp) exposes the API as tools for Claude, Cursor, and other clients. ## Policy Per-profile settings that constrain publishing: `requireApproval`, `allowedPlatforms`, `maxPostsPerDay`. Read with [`GET /v1/policy`](/docs/api/get-policy). Combines with a key's own restrictions — either one saying no is enough. ## Post Content plus target accounts plus timing. One post can target several accounts on several platforms, and each is published independently, which is why `partially_published` exists. Called a **job** in older code and internal logs. ## Profile A container for accounts, posts, and policy. One profile per customer if you are building a platform; one profile total if you are publishing for yourself. Called a **workspace** in the dashboard UI and in the database. Same object; the API says profile. ## Request ID An identifier on every response, and on every error. Include it in a support request and we can find the exact call. Also visible in [`GET /v1/logs`](/docs/api/list-api-logs). ## Result One entry per target account in a published post, carrying `status`, the platform's response on success, and the reason on failure. The thing to read when a post is `partially_published`. ## Scope The permission preset on an API key: `full`, `agent`, or `readonly`. See [Authentication](/docs/authentication). ## Status Where a post is in its lifecycle: `draft`, `pending_approval`, `approved`, `rejected`, `scheduled`, `queued`, `processing`, `published`, `partially_published`, `failed`, `cancelled`. A `state` field carrying the same value appears in some older webhook payloads. It is deprecated — read `status`. ## Target Internal name for one account within a post. You will see it as `targetId` in `results`, where it holds the account's `id`. ## Workspace The dashboard's word for a [profile](#profile), and the database table's name. The API uses profile throughout. --- # Changelog What changed in the API, and what you need to do about it. Breaking changes get a new version. Everything below is additive or a deprecation with the old behaviour still working, so no action is required unless a line says so. Subscribe to `["*"]` on a webhook endpoint in staging if you want to see new event types as they ship — but not in production, where a new event arriving in an unprepared handler is a way to break things. ## 2026-07 — Multi-tenancy, one contract, and something to install The largest release so far. If you integrated before this, everything you wrote still works. ### Profiles and programmatic keys - `GET/POST /v1/profiles` and `GET/PATCH/DELETE /v1/profiles/:id` — create and manage one profile per customer without the dashboard. - `GET/POST /v1/api-keys` and `DELETE /v1/api-keys/:id` — mint keys scoped to specific profiles, with `scope`, `expiresIn`, `allowedAccountIds`, `maxPostsPerDay`, and `requireApproval`. - `profileId` accepted on every endpoint that touches customer data — a query parameter on reads, a body field on writes. - `profileId` included in every webhook payload, so one endpoint can route to the right customer. An existing key keeps working exactly as before, scoped to the one profile it was created in. See [Build a platform](/docs/build-a-platform). ### Restrictions that are actually enforced `allowedAccountIds`, `maxPostsPerDay`, and `requireApproval` were stored but never checked. They are now enforced on every write: - A post naming an account outside `allowedAccountIds` returns `account_not_allowed`. - The `maxPostsPerDay`th post in a UTC day returns `daily_post_limit`. - A key or profile with `requireApproval` routes posts to `pending_approval` regardless of `publishNow`, and an `agent`-scope key cannot nominate its own approver. - A profile's `allowedPlatforms` returns `platform_not_allowed` for accounts on other platforms. **If you set any of these fields expecting them to be inert, they are not.** Check your keys. ### One error shape Every failure from `/v1` and `/mcp` now returns: ```json { "error": { "code": "accounts_not_found", "message": "…", "docsUrl": "https://cutedyno.com/docs/errors#accounts_not_found", "requestId": "req_…" } } ``` There were four shapes before. The old flat `{ "error": "message" }` is still emitted alongside for one minor version. Branch on `code` and you will not have to revisit this. See [Errors](/docs/errors). ### Rate limit headers `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` on every response, and `Retry-After` on a `429`. Previously a key was limited with no signal at all. See [Rate limits](/docs/guides/rate-limits). ### Webhook maturity - `POST /v1/webhooks/:id/test` — deliver a synthetic event with a real signature. - `GET /v1/webhooks/:id/deliveries` — the delivery log, with attempt counts and your endpoint's responses. - `PATCH /v1/webhooks/:id` — change the URL or event list, or pause delivery without losing the secret and history. - `POST /v1/webhooks/:id/rotate-secret` — issue a new signing secret. - A management screen at [Webhooks](/dashboard/webhooks), which the docs had promised for a while. - `CuteDyno-Event-Id` header, stable across retries, for deduplication. - Documented retry schedule: seven attempts over roughly 31 hours. - `scope: 'account'` subscriptions, so one endpoint receives events from every profile. - `post.created`, `post.scheduled`, `post.cancelled`, `account.disconnected`, `approval.requested`, `approval.approved`, and `approval.rejected` now actually fire. They were documented before they were delivered. ### Naming - `GET /v1/accounts` returns both `id` (CuteDyno's) and `platformAccountId` (the platform's). Either is accepted anywhere an account ID is taken, so stored IDs from an earlier integration keep working. - `accountId` is now a deprecated alias of `id`. - Comment fields are camelCase, matching the rest of the API. - `status` is the field to read everywhere. `state` remains as a deprecated alias in webhook payloads. - The approval state is `pending_approval` in the API. It was leaking the internal `in_review` in some responses. - `contentType` is `text`, `image`, or `video`. The enum previously advertised `reel`, `story`, `carousel`, and `slideshow`, which nothing ever returned — Instagram video is a Reel and several images are a carousel without you naming the format. ### Something to install - **`@cutedyno/node`** — typed client generated from the OpenAPI document, with retries, automatic idempotency keys, media upload, webhook verification, and pagination. See [SDKs](/docs/sdks). - **`cutedyno-mcp`** — the MCP server as a published package, so `npx -y cutedyno-mcp` works. Every setup snippet in the docs previously referenced a package that did not exist. ### MCP - 27 tools, up from 19, including `get_profile`, `list_comments`, `list_profiles`, `create_profile`, `update_policy`, `test_webhook`, and `list_webhook_deliveries`. - Loose string parameters replaced with enums, so an agent gets a validation error instead of inventing a platform name. - `linkedin` and `youtube` added to the connect tool. - Idempotency and request logging now run on `/mcp`, so agent calls appear in [`GET /v1/logs`](/docs/api/list-api-logs) like any other client. - `CUTEDYNO_API_URL` is honoured, which the stdio server previously ignored. - The tool list is generated from the server's registry into a committed manifest, so the documented tools and the running tools cannot diverge. ### Media - `POST /v1/media/upload` validates `fileType` against the accepted image and video types, returning `unsupported_media_type` instead of presigning an upload that no platform would accept. - The response carries `maxBytes`, and `expiresIn` now matches the signed URL's actual lifetime. It previously reported 15 minutes for a URL valid for an hour. ### Documentation Rebuilt from the OpenAPI document and the MCP manifest rather than hand-maintained: 13 endpoints were documented while 24 shipped, and 10 MCP tools while 19 were registered. Endpoint and tool pages are generated, guides are hand-written, and [`/llms.txt`](/llms.txt) plus [`/llms-full.txt`](/llms-full.txt) exist for agents reading the docs directly. The old `/agents` marketing page is gone; [MCP](/docs/mcp) covers the same ground with tools that match what the server registers. ## Deprecations Still working, but going away. Nothing here has a removal date yet; a minor version will announce it. | Deprecated | Use instead | | --- | --- | | `accounts[].accountId` | `accounts[].id` | | `state` in webhook payloads | `status` | | Flat `{ "error": "message" }` responses | `{ "error": { "code", "message", … } }` | | `session.url` on connect responses | `session.authUrl` | ---