# OmniPosts documentation > Every page of https://jamp.sh/docs as Markdown, in reading order. The short index is https://jamp.sh/llms.txt. --- # OmniPosts documentation: posting, channels, queue and API URL: https://jamp.sh/docs Updated: 2026-10-22 OmniPosts publishes one post to every channel you have connected, then tells you plainly, channel by channel, what happened. Start from what you're here to do. ## I write posts Composer, queue, inbox - [Send your first post](/docs/quickstart#in-the-app) - [Set your posting times](/docs/guides/use-the-queue#set-your-weekly-posting-times) - [When a post fails](/docs/reference/failure-classes) ## I build with the API Keys, schedules, webhooks - [Make an API key](/docs/api/authentication#create-a-key) - [Schedule to any connector](/docs/api/posts#create-a-post) - [Open the API reference](/docs/api) ## I use an agent Claude Code, Cursor and other MCP clients - [Connect an agent](/docs/agents/connect) - [Choose what it can do](/docs/agents/connect#choose-what-it-can-do) - [Things to ask it](/docs/agents/connect#ask-for-something) *** OmniPosts is a social publishing tool for creators and for developers. You write a post once, choose the channels it goes to, and send it now, at a time you pick, or at the next free time in your weekly queue. Then OmniPosts shows you the result for **each channel**: posted, with a link; failed, with the reason and one thing to do about it; or still being checked. > **Beta.** OmniPosts is in early access. The **Free** plan is open. **Creator** and **Pro** are in beta: > [join the waitlist](/pricing#waitlist) and we will email you when they open. ## What OmniPosts does - **One composer.** Write once. Each channel shows a preview with its own character limit, and you can change the text for one channel without touching the others. - **A calendar and a queue.** Post now, pick a time, or add the post to a weekly list of posting times. See [Calendar and queue](/docs/concepts/calendar-and-queue). - **Every post exactly once.** If a platform does not answer clearly, OmniPosts checks whether the post went out before it does anything else. It never sends a post twice just to be safe. See [Posts and results](/docs/concepts/posts-and-results). - **Honest numbers.** Engagement numbers appear only where a platform shares them. Where it does not, you see "Not available" and the reason, never a made-up zero. See [Honest numbers](/docs/concepts/honest-numbers). - **An API.** Everything in the app is available over HTTP with API keys and signed webhooks. ## About these docs The pages are written in Markdown and kept in the repository next to the code, so they change with the product. If something here does not match what you see in OmniPosts, the product is right: tell us at [join@omniposts.dev](mailto:join@omniposts.dev) and we will fix the page. --- # Quickstart: connect a channel and send a post URL: https://jamp.sh/docs/quickstart Updated: 2026-10-22 Create an account, connect a channel and send your first post, in the app or from the API. This takes a few minutes. You need an account, one channel to post to, and something to say. ## In the app 1. **Create an account** at [omniposts.dev/app/signup](/app/signup) with your email and a password. 2. **Connect a channel.** Open **Channels** and choose a platform. Each page under [Channels](/docs/channels) says what that platform asks for: Bluesky wants an app password you create in its settings, Mastodon sends you to your own server to sign in, and so on. 3. **Write a post.** Choose **New post**, type your text, and tick the channels it should go to. Each channel shows a preview with its own character limit. Change the text for one channel without touching the others. 4. **Send it.** Pick one of three ways: **Post now**, **Schedule for later** (a date and time), or **Add to queue** (the next free time in your weekly list, see [Calendar and queue](/docs/concepts/calendar-and-queue)). 5. **Check the result.** Open **Inbox**. Every post shows one result per channel: posted (with a link), failed (with the reason and one button to fix it), or being checked. > **Tip.** If you are stuck on the first connection, **Help** in the menu has a step-by-step setup > guide inside the app. ## From the API You can do the same over HTTP. The API is part of every plan. 1. In the app, open **Settings**, then **Agents & API**, and create an API key. Copy it when it is shown: it is displayed once. Keys start with `omniposts_`. 2. List your channels to find a channel id: ```bash curl https://omniposts.dev/v1/channels \ -H "Authorization: Bearer omniposts_..." ``` 3. Send a post to it. The `Idempotency-Key` makes a retry safe: the same request twice is one post. ```bash curl https://omniposts.dev/v1/groups \ -H "Authorization: Bearer omniposts_..." \ -H "Idempotency-Key: my-first-post" \ -H "Content-Type: application/json" \ -d '{"body": "Hello from the API.", "targets": [{"channelId": "CHANNEL_ID"}]}' ``` The response lists a result for every channel you named. From here, read [Authentication](/docs/api/authentication), [Posts](/docs/api/posts) and [Errors](/docs/api/errors). ## What next - [Posts and results](/docs/concepts/posts-and-results): why a post is never sent twice, and what each status means. - [Use the queue](/docs/guides/use-the-queue): set your weekly posting times once. - [Plans](/docs/concepts/plans): what Free includes, and the beta plans. --- # Channels: connections, health and Coming soon URL: https://jamp.sh/docs/concepts/channels Updated: 2026-10-22 A channel is one connected account on one platform. What connecting stores, what the health states mean, and what "Coming soon" means. A **channel** is one connected account on one platform. Your Bluesky account and your Mastodon account are two channels. Two Bluesky accounts are also two channels. Every post goes to one or more channels, and every channel gets its own result. ## What connecting stores When you connect a channel, OmniPosts keeps the sign-in details the platform gave it: an access token, an app password or a bot token, depending on the platform. These are stored encrypted. They are used only to post for you and to read back how a post did. The details are deleted when you disconnect the channel. Disconnecting also deletes the raw copies of what OmniPosts downloaded from that platform. Your list of posts, your publishing record and the numbers already on the Performance screen stay, and they stop updating. For the exact promise see [Export or delete your data](/docs/guides/export-or-delete-your-data) and the [privacy notice](/privacy). Connecting an account that is already connected is a reconnect. It does not use up another place on your plan. See [Plans](/docs/concepts/plans). ## Health states Each connected channel shows one status on the **Channels** screen. The API returns the same state in `health.state` on `GET /v1/channels`. | API state | What you see | What it means | |---|---|---| | `OK` | Connected | The channel can take a post. | | `DEGRADED` | Working, with problems | Posting works, but something is wrong. A sentence under the status says what. | | `NEEDS_RECONNECT` | Needs attention | The platform no longer accepts the saved sign-in, or it is about to expire. Reconnect to keep posting. | | `BLOCKED` | Blocked by the platform | The platform has blocked posting from this account. Reconnecting will not fix this. | Some platforms give OmniPosts no way to renew a sign-in. For those, the channel moves to **Needs attention** about a day before the sign-in expires, so you can reconnect before a post fails. A channel that needs a reconnect is not offered as a place to post in the composer. It is listed with its reason and a **Reconnect** link instead. See [Reconnect a channel](/docs/guides/reconnect-a-channel). ### Posting paused A connected channel can also show **Posting paused**. This is not a health problem with your account. It means OmniPosts has switched that platform off for now, so no post will go to it. The channel stays connected. ## Coming soon Not every platform is open to connect yet. The **Channels** screen lists those under **Coming soon**. A channel marked Coming soon opens on its own; nothing needs to be done on your side. To see what is open today, look at the **Channels** screen in the app and at [Channels](/docs/channels). > **Note.** A channel that is coming soon cannot be connected at all, so OmniPosts can never read numbers > from it before it can post to it. --- # Posts and results: how delivery really works URL: https://jamp.sh/docs/concepts/posts-and-results Updated: 2026-10-22 One post, one result per channel. How a result moves from waiting to posted, and why OmniPosts checks instead of resending. You write **one post** and choose the channels it goes to. OmniPosts keeps **one result for each channel**. A post to five channels has five results, and each one succeeds or fails on its own. One channel rejecting the post does not stop the others. The API calls a post sent to several channels a **group**, and the result for one channel a **target**. The app calls them a post and a result. ## What OmniPosts guarantees - **A post and the job that publishes it are saved together.** There is no state where a post exists but nothing will ever send it. - **A dispatch that may have reached a platform is never sent again.** If the platform does not answer clearly, OmniPosts looks the post up on the platform instead of resending it. - **Posted is final.** Once a result is posted it can never change back or be sent again. The database enforces this, not only the code. - **Every channel is reported separately.** You get a result per channel, each with its own reason and one thing to do. - **Missing numbers say "Not available".** They are never shown as 0. See [Honest numbers](/docs/concepts/honest-numbers). ## The life of a result A result moves through these steps. The wording is what you see in the app. ```text Waiting to send -> Checking the post -> Scheduled -> Sending now |-> Posted |-> Not posted |-> Waiting for the platform to finish -> Posted / Not posted |-> Checking whether this posted -> Posted / Not posted Cancelled (only before sending starts) ``` | Step | API status | Meaning | |---|---|---| | Waiting to send | `pending` | Saved, not yet looked at. | | Checking the post | `preflight` | OmniPosts is checking the text, media and the channel before it sends. | | Scheduled | `queued` | Ready, waiting for its time or for a free worker. | | Sending now | `publishing` | OmniPosts has started handing the post to the platform. | | Waiting for the platform to finish | `awaiting_platform` | The platform took the post and is still processing it. | | Checking whether this posted | `unknown` | The post was sent but the platform did not confirm. | | Posted | `published` | It is live. The result has a link to the post. | | Not posted | `failed` | It did not go out. The result has a reason and one action. | | Cancelled | `cancelled` | You cancelled it before sending started. | Every status, and which are final, is in [Post statuses](/docs/reference/post-statuses). ## Checking whether this posted Sometimes OmniPosts sends a post and then loses the answer: the connection drops, the platform times out, or it replies in a way that does not say yes or no. The post may or may not be live. Resending would risk a double post to a real audience, and a double post cannot be undone. So OmniPosts does not resend. The result shows **Checking whether this posted** while OmniPosts looks the post up on the platform. Then one of two things happens: - The post is found. The result becomes **Posted**, with its link. - The post is not found. The result becomes **Not posted**. Only now, because OmniPosts has confirmed it did not go out, can the result be retried. If OmniPosts cannot confirm either way, the result says so and offers an **Open** button for the platform (for example **Open Bluesky**), so you can look yourself. See [Failure classes](/docs/reference/failure-classes). ## The status of the whole post The post as a whole gets a status from its results. It is **Posted everywhere** only when every channel that was not cancelled has posted. While any channel is still being sent or checked, the post stays **Sending**. Once nothing is left to send, a mix of posted and not posted is **Posted to some channels**. The rules are in [Post statuses](/docs/reference/post-statuses#status-of-the-whole-post). ## Cancelling and retrying You can cancel a post, or move it to another time, until sending starts for a channel. After that, the result can no longer be cancelled, because it may already be on the platform. Cancelling works per channel: results already sending or finished stay as they are, and the response lists which were cancelled and which were not. In the API: `POST /v1/groups/{id}/cancel`. A result that failed can be retried only when its failure says a retry is safe. The app shows **Try again** on those results. In the API: `POST /v1/groups/{id}/retry`. A result that is still **Checking whether this posted** is never retried. --- # Schedule posts: calendar, queue and time zones URL: https://jamp.sh/docs/concepts/calendar-and-queue Updated: 2026-10-22 Post now, pick a time, or add a post to a weekly list of posting times. How slots, time zones and clock changes work. There are three ways to send a post. You choose one in the composer under **When**. - **Post now.** Sending starts straight away. - **Schedule for later.** Pick a date and time. The time is read in your browser's time zone. - **Add to queue.** The post takes the next free time in your weekly list of posting times. The **Calendar** shows everything that is scheduled or sent, by day. You can cancel or move a post there until it starts sending. ## The weekly posting times The queue is a list of times that repeat every week, such as Monday 09:30 and Thursday 18:00. You set it once in **Settings**, under **Posting times**, and choose one time zone for the whole list. Steps are in [Use the queue](/docs/guides/use-the-queue). "Add to queue" looks forward from now and takes the earliest posting time that is still free and at least a minute away. It looks up to 12 weeks ahead. A list can hold up to 56 posting times. ## One post per slot Each posting time holds one post. When two posts are queued, the second takes the next free time after the first. You never get two posts at the same moment by accident. A post keeps its slot while it can still be sent. A slot becomes free again when: - you cancel the post, or - the post failed before its time, for example because every channel rejected it before sending. ## A hand-picked time leaves the queue If you change a queued post to a time you choose yourself, the post leaves the queue and its slot is free for the next post. A post you schedule by hand never uses a slot in the first place. In the API, the `queued` field of the post is `true` only while the queue placed it. ## Time zones and clock changes Posting times are local times in the zone you chose, not fixed moments. A 09:30 slot stays at 09:30 on the wall clock when the clocks change in spring or autumn. Two edge cases come from how clocks work: - When the clocks go forward, some local times do not exist. A slot inside that gap moves later by the length of the gap. A 02:30 slot on a night when 02:00 jumps to 03:00 goes out at 03:30. - When the clocks go back, a local time happens twice. The slot uses the first one. If a gap moves two slots onto the same moment, they count as one. ## When the queue is empty or full - **Empty.** If your list has no posting times, "Add to queue" cannot place the post. The composer says your queue has no posting times yet and links to **Settings**. Add times, or pick a time by hand. The API answers `422` with the code `queue_empty`. - **Full.** If every posting time in the next 12 weeks is taken, the post cannot be placed. Add more times in **Settings**, or pick a time by hand. The API answers `422` with the code `queue_full`. > **Tip.** Changing your posting times later does not move posts that are already queued. They keep > the time they were given. --- # Honest numbers: engagement where it is shared URL: https://jamp.sh/docs/concepts/honest-numbers Updated: 2026-10-22 Engagement numbers appear only where a platform shares them. Everything else says Not available and why, never a made-up zero. OmniPosts shows an engagement number only when a platform shares it with apps. When it does not, you see **Not available** and the reason. OmniPosts never fills the gap with 0, because 0 would say that nobody liked the post, and that is a claim OmniPosts cannot make. ## Two kinds of record The **Performance** screen draws on two different things. - **Your publishing record.** What you posted, where, when, and what happened: posted, failed, and why. This is OmniPosts's own record. It exists for every channel, and it does not depend on what a platform shares. - **What the platform shows.** Likes, reposts, replies, views and similar counts. These come from the platform, and only where it shares them. A platform's own numbers can differ from OmniPosts's. A platform may count across its whole network while OmniPosts can read only your home server, or the other way round. Where the source matters, OmniPosts says who counted the number. ## Why a number is Not available Every missing number carries a reason. The API returns it as `reason` (a sentence) and `reasonKind` (one of a fixed set). | `reasonKind` | What it means | |---|---| | `platform_hides` | The platform does not share this number with apps. | | `not_collected` | OmniPosts does not collect this number yet. This one is on OmniPosts, and the screen says so. | | `unverified` | Not yet confirmed to be reliable, so OmniPosts is not showing it. | | `collection_stopped` | OmniPosts stopped collecting this number for this post. | | `window_closed` | The platform shares it only for a limited time, and that time has passed. The last value OmniPosts saw is kept. | | `private_post` | The platform does not report numbers for private posts. | | `reconnect` | Reconnect the account and allow statistics, then OmniPosts can read it. | | `paid_plan` | The platform shares it only on its paid plan, which OmniPosts does not use. | | `business_only` | The platform shares it only for business accounts connected through its business programme. | The screen words each one as what it is. A gap that belongs to the platform says **Not shared by** the platform. A gap that is OmniPosts's says **We don't collect these yet** or **We're still checking these numbers**. OmniPosts does not blame a platform for something OmniPosts has not built. A gap can also be a number OmniPosts should have recorded and did not. The screen marks those **Not recorded**, and says that is on OmniPosts's side. ## What OmniPosts keeps OmniPosts keeps your own publishing record for as long as your account exists. It is deleted when you delete your account. Platform data is held under tighter limits: - Raw data downloaded from a platform is deleted automatically after at most 30 days. It is deleted at once when you disconnect the channel. - The engagement figures OmniPosts derives from it are kept for the life of your account, unless a platform sets a shorter limit. - Some platforms cap how long reporting data may be kept. LinkedIn Page reporting data, when that channel is available, is kept for at most 1 year. > **Important.** Do not treat OmniPosts as an archive of a platform's numbers. A platform can limit how > long its data may be kept, can stop sharing a number, or can change what it reports. OmniPosts does not > promise to keep platform numbers forever. Your publishing record is the part OmniPosts keeps for the > life of your account. The full list of what OmniPosts keeps and for how long is in the [privacy notice](/privacy). ## In the API The metric endpoints return a `value`, an `availability` and, when a number is missing, its `reason` and `reasonKind`. A missing number has a `value` of `null`, not `0`. Ratios with nothing to measure, such as a success rate before anything was sent, are also `null`. --- # Plans and limits: channels and posts per month URL: https://jamp.sh/docs/concepts/plans Updated: 2026-10-22 Free is open. Creator and Pro are in beta with a waitlist. Plans differ only in connected channels and posts per month. OmniPosts has three plans: Free, Creator and Pro. They differ in two things only: how many channels you can have connected, and how many posts you can create in a month. Every plan has every feature, every channel OmniPosts supports, and the API. The numbers for each plan are on the [pricing page](/pricing). > **Beta.** The **Free** plan is open to everyone. **Creator** and **Pro** are in beta. To hear when > they open, [join the waitlist](/pricing#waitlist). It asks for an email address only: no password > and no card. ## What counts as a channel A channel is one connected account on one platform. Your Bluesky account and your Mastodon account are two channels. A channel counts while it is connected. When you disconnect it, its place is free again. Reconnecting an account that is still connected does not use a new place. ## What counts as a post A post is one thing you create, however many channels it goes to. A post to five channels counts once. A post made through the API counts the same as one made in the app. The month is a calendar month in UTC. The count restarts on the 1st. - A post you **cancel** does not count. - A post that was sent, that failed, or that is still scheduled does count. ## When you reach a limit OmniPosts stops new connections and new posts above your plan's limit and tells you which limit it was. It does not touch anything that already exists. If you reach the limit on posts, you can create posts again on the 1st. In the API this is a `422` with the code `plan_limit_reached`. The `details.limit` field is `channels` or `posts_per_month`. ## A posting limit on your account Separately from your plan, OmniPosts may set a posting limit on an account. It counts channels, not posts: a post to three channels uses three. A limit is either per day (it resets at 00:00 UTC) or in total. It applies to you across all your workspaces, and a post made with an API key counts against the person who created the key. Cancelling a post does not give its places back. When a post would go past it, OmniPosts refuses the whole post and tells you why; nothing is sent. In the API a daily limit that will clear is a `429` with the code `posting_limit_reached`, a `Retry-After` header (seconds until 00:00 UTC) and `details.resetsAt`. A total limit, or a post to more channels than the limit allows at all, is a `422` with the same code: choose fewer channels. `details` also has `period`, `maxTargets`, `used`, `requested` and `remaining`. ## A smaller plan never removes anything Moving to a smaller plan never disconnects a channel and never cancels a scheduled post. It only stops you from adding more channels or posts above the smaller plan's limits, until you are under them. ## See where you stand **Settings** shows your plan, how many channels you have connected and, where the plan has a monthly limit, how many posts you have used this month. The same numbers are available from the API. --- # Workspaces and members: share with your team URL: https://jamp.sh/docs/concepts/workspaces Updated: 2026-10-22 A workspace holds channels, posts and keys. Invite people to share one, give them a role, and switch between the workspaces you belong to. A workspace is where everything in OmniPosts lives: connected channels, posts and drafts, media, the posting queue, webhooks, API keys and the plan. When you sign up you get one workspace of your own. You can create more, and you can invite other people into a workspace so you work on the same channels together. The API calls a workspace an *organization* in `GET /v1/me`. It is the same thing. ## One workspace at a time You can belong to several workspaces, but each signed-in session acts for one of them, the *current* workspace. Everything you see and do (channels, posts, the queue) is in the current workspace. - `GET /v1/workspaces` lists the workspaces you belong to. `current` marks the one you are in. - `POST /v1/workspaces/{id}/switch` makes another one current for this session. Other browsers where you are signed in stay where they are. An API key always acts for the workspace it was created in. A key sees only that workspace in `GET /v1/workspaces`. ## Roles A member is either an **owner** or a **member**. | | Owner | Member | |---|---|---| | Connect channels, post, schedule, upload media, use the queue | Yes | Yes | | Create and revoke API keys | Yes | Yes | | Rename or delete the workspace | Yes | No | | Invite people, change roles, remove members | Yes | No | | Change the plan | Yes | No | | Delete their own account | Yes | Yes | A workspace always has at least one owner. To step down as the last owner, make someone else an owner first. `PATCH /v1/workspaces/{id}/members/{userId}` changes a role. Trying to remove the last owner gets `409 last_owner`. ## Invite someone An owner creates an invite with `POST /v1/workspaces/{id}/invites`. The answer holds an `inviteUrl`, a link to the OmniPosts web app (`https://omniposts.dev/app/invite/...`) where the person signs in, or signs up, and accepts. OmniPosts does not send emails, so send the link yourself. ```json { "email": "grace@example.com", "role": "member" } ``` - The link works **once**, for **7 days**. An invite to become an **owner** must name the person's email and works for **48 hours**. OmniPosts stores only a hash of the link, so it is shown only in this answer. - An invite carries its creator's authority: it stops working if they stop being an owner, and their pending invites are revoked when they are demoted or removed. - With `email`, only the account with that address can accept it. Leave `email` out for a link anyone signed in can use, and share it with care. - The address is compared with the email of the OmniPosts account that opens the link. OmniPosts does not confirm email addresses at sign-up, so treat the link itself as the secret: send it only to the person you mean. - `GET /v1/workspaces/{id}/invites` lists invites still waiting, and `DELETE /v1/workspaces/{id}/invites/{inviteId}` revokes one. The person who opens the link signs in (or signs up), sees which workspace it is for with `GET /v1/invites/{token}`, and joins with `POST /v1/invites/{token}/accept`. Joining does not switch them to the workspace; they switch when they are ready. ## Leave or remove someone `DELETE /v1/workspaces/{id}/members/{userId}` removes a member. An owner can remove anyone, and anyone can remove themselves to leave. Access stops at once: their sessions move to another workspace they belong to, and the API keys they created in this workspace are revoked. **The channels they connected are disconnected when they leave**: the platform sign-in was theirs, so OmniPosts deletes its copy of the tokens. Their pending connects and invites there go too. Keys and channels made by others keep working. **Settings → Agents & API** shows who created each key, and each channel shows who connected it. A channel connected before OmniPosts recorded who connected it shows no one, and is never disconnected on anyone's behalf. > **Warning.** Posts still scheduled to a channel that is disconnected this way fail when their time > comes, with the failure code `disconnected`. Reconnect the channel before then (anyone in the workspace > can), and they go out as planned. See [Reconnect a channel](/docs/guides/reconnect-a-channel). - You cannot leave your only workspace (`409 last_workspace`). Create or join another one first. - If an owner removes someone from the only workspace they had, that person gets a new, empty workspace of their own, so they can still sign in. ## Delete a workspace An owner deletes a workspace with `DELETE /v1/workspaces/{id}`, sending the workspace's name to confirm: ```json { "confirm": "Marketing" } ``` > **Warning.** This deletes the workspace's channels, posts, media, API keys and webhooks, the same way > [deleting your account](/docs/guides/export-or-delete-your-data) does. It cannot be undone. You can delete a workspace only when you are its only member (remove the others first, else `409 members_remain`) and it is not your only workspace (`409 last_workspace`). Your sessions in it move to your other workspace. ## Limits You can own up to 10 workspaces, and a workspace can have up to 50 invites waiting at once. Each workspace has its own [plan](/docs/concepts/plans): a new one starts on the plan a new sign-up gets. ## Delete your account Deleting your account (`POST /v1/settings/delete-account`) deletes you, not a workspace you share. You leave every workspace that has other members (the channels you connected there are disconnected), and every workspace where you are the only member is deleted. Nobody else's account changes. If you are the only owner of a workspace that has other members, OmniPosts refuses with `409 sole_owner_with_members` until you make one of them an owner or remove them. See [Export or delete your data](/docs/guides/export-or-delete-your-data#delete-your-account). --- # Supported platforms and how to connect a channel URL: https://jamp.sh/docs/channels Updated: 2026-10-22 What a channel is, how to connect one, and which platforms you can connect today and which are coming soon. A channel is one account on one platform that you have connected to OmniPosts, such as a Bluesky account or a Telegram channel. A post goes out to the channels you tick, and you get one result per channel. See [Channels](/docs/concepts/channels) for how OmniPosts thinks about them. ## Connect a channel 1. Open **Channels** in the app. 2. Pick a platform from the list of platforms you can connect. 3. Fill in the form or sign in on the platform when OmniPosts sends you there, then approve OmniPosts. OmniPosts checks the connection before it saves the channel. Credentials you paste are stored encrypted, and disconnecting deletes them. ## Channels | Channel | Status | |---|---| | [Bluesky](/docs/channels/bluesky) | Available | | [Mastodon](/docs/channels/mastodon) | Available | | [Telegram](/docs/channels/telegram) | Available | | Facebook Pages | Coming soon | | Instagram | Coming soon | | [LinkedIn](/docs/channels/linkedin) | Coming soon | | LinkedIn Pages | Coming soon | | Threads | Coming soon | | TikTok | Coming soon | | TikTok for Business | Coming soon | | X | Coming soon | | YouTube | Coming soon | The **Channels** screen in the app always shows the current status. A channel marked Coming soon cannot be connected yet; when it opens, you connect it the same way as any other channel. ## What every channel page covers The platform pages follow the same outline: what you need, how to connect, what you can post, which numbers the platform shares, and what to know about limits and reconnecting. Numbers a platform does not share show as "Not available" with the reason, never as zero. See [Honest numbers](/docs/concepts/honest-numbers). --- # Connect and post to Bluesky (password or OAuth) URL: https://jamp.sh/docs/channels/bluesky Updated: 2026-10-22 Connect Bluesky by signing in with Bluesky or with an app password, then post text and up to four images from OmniPosts. There are two ways to connect Bluesky. Neither gives OmniPosts your main password, and neither needs a platform review. | Way | What happens | Best for | |---|---|---| | **Sign in with Bluesky** (recommended) | You approve OmniPosts on your Bluesky server's own page. | Almost everyone. | | **App password** | You paste a separate password you created in Bluesky's settings. | Servers that do not support signing in this way yet. | ## Sign in with Bluesky On **Channels**, choose Bluesky, keep **Sign in with Bluesky** selected and enter your handle. Your name alone is enough: `alice` becomes `alice.bsky.social`. If you use your own domain, type it whole. OmniPosts finds the server that holds your account (your PDS), sends you to that server's sign-in page, and you approve OmniPosts there. You come back to OmniPosts and the channel is saved. OmniPosts asks for exactly three things, and the approval page lists them: - **Your identity**: which account you are, so OmniPosts posts to the right one. - **Create posts**: OmniPosts can publish new posts. It cannot edit or delete your posts, change your profile, follow anyone or read your messages. - **Upload images**: for the images in your posts. If you run your own server, OmniPosts uses it, as long as the server supports signing in this way and is reachable on the public internet. If it does not, OmniPosts tells you to connect with an app password instead. > **Note.** Sign in with the same account as the handle you typed. If you approve with a different > account, OmniPosts refuses the connection and asks you to start again. OmniPosts keeps the sign-in fresh: it renews it when it posts, and at least once a week otherwise. About an hour before a scheduled post is due, OmniPosts checks that its access still works, so if you ended it, the channel asks for a [reconnect](/docs/guides/reconnect-a-channel) while there is still time, not when the post should go out. You can end OmniPosts's access at any time from your Bluesky account settings. Disconnecting the channel in OmniPosts ends it too: OmniPosts deletes its copy and tells your server to cancel the sign-in. ## App password Choose **Use an app password** instead. First create one in Bluesky under Settings, then Privacy and security, then App passwords. The form asks for two things: | Field | What to enter | |---|---| | Handle or email | Your name, like `alice` (OmniPosts adds `.bsky.social`), your own domain, or your account email. | | App password | The app password you created. It is hidden as you type. | > **Warning.** Do not use your main Bluesky password. If you do and your account has two-factor > sign-in, Bluesky asks for a code and OmniPosts tells you to use an app password instead. OmniPosts signs in, finds the server that holds your account and saves the channel. Channels you connected with an app password before keep working; you do not need to switch. ## What you can post - **Text.** Up to 300 characters, counted as Bluesky counts them: what a person sees as one character is one, so an emoji or an accented letter counts once. Bluesky also caps a post at 3000 bytes, and OmniPosts checks that too. - **Images.** Up to 4 per post, JPEG, PNG or WebP, up to 2,000,000 bytes each. Alt text is supported, up to 2000 characters. - **Video.** Not supported in OmniPosts yet. - **Links and hashtags.** OmniPosts turns links and `#hashtags` in your text into clickable ones. Mentions are not turned into links. The composer shows no extra Bluesky settings. ## Numbers you get Per post, Bluesky shares likes, reposts, replies and quotes. It does not share views, reach or impressions, so those show "Not available" with the reason. For the account, OmniPosts records followers, following and post count each day. Likes received show as not available. Counters appear only when OmniPosts has switched numbers on for Bluesky. When they are off, the cell says OmniPosts has not yet confirmed the number is reliable. Per-post numbers are read for 30 days after posting. See [Honest numbers](/docs/concepts/honest-numbers). ## Good to know - **Each post goes out once.** OmniPosts gives each post a fixed identity on Bluesky, so a retry cannot create a second copy. If OmniPosts is unsure whether a post went out, it checks before doing anything else. - **Sessions renew themselves.** OmniPosts renews the sign-in in the background. If Bluesky later ends OmniPosts's access (you removed it in Bluesky's settings, or deleted the app password), the channel needs a [reconnect](/docs/guides/reconnect-a-channel). - **Rate budget.** Bluesky allows 5,000 write points per hour and 35,000 per day for an account, and a post costs 3 points. Self-hosted servers set their own limits. - A deactivated, suspended or taken-down account cannot be posted to, and OmniPosts says so in the result. --- # Connect a Mastodon account on any server URL: https://jamp.sh/docs/channels/mastodon Updated: 2026-10-22 Connect an account on any Mastodon server, then post text and images with the limits your server sets. Mastodon has no central service: every account lives on a server of its own. OmniPosts registers itself with your server when you connect, so no review by OmniPosts or by Mastodon is needed. ## What you need - An account on a Mastodon server (or a server that speaks the same API). - The address of that server, for example `mastodon.social`. ## Connect it On **Channels**, choose Mastodon. The server box already says `mastodon.social`; if your account is on another server, type its address instead. OmniPosts sends you to that server to sign in and approve it, and you then return to OmniPosts. You sign in on Mastodon's own page, with the email and password you use there; OmniPosts never sees them. OmniPosts accepts a plain address or a pasted handle such as `@alice@mastodon.social`. The server must be reachable over `https` on a public address. Addresses that point at private networks are refused. OmniPosts asks for permission to read your account, read your posts, write posts and upload media. The read permissions are used to check that a post went out and to read numbers. ## What you can post - **Text.** Up to 500 characters on a standard server. Mastodon counts every link as a fixed length (23 by default) and a mention of a user on another server as just the username. Servers can change these numbers, and OmniPosts reads your server's limits when you connect and applies them before sending. - **Images.** Up to 4 per post on a standard server, JPEG, PNG, GIF or WebP, up to 16 MB each. Alt text is supported, up to 1500 characters on a standard server. OmniPosts uses your server's own limits when they are tighter. - **Video.** Not supported in OmniPosts yet. The composer shows one Mastodon setting: | Setting | Options | |---|---| | Visibility | Public (default), unlisted, or private (followers only) | ## Numbers you get Per post, Mastodon shares favourites, boosts, replies and quotes. Quotes need a server on Mastodon 4.5 or newer; on an older server that cell shows "Not available" and says the server is too old. Views, reach and impressions are not shared. Counts are your server's view of the post, not a network-wide total. Per account, OmniPosts records followers, following and post count each day. Numbers appear only when OmniPosts has switched them on for Mastodon. See [Honest numbers](/docs/concepts/honest-numbers). ## Good to know - **Tokens do not expire.** Mastodon sign-ins stay valid until you revoke OmniPosts on your server. If you do, the channel needs a [reconnect](/docs/guides/reconnect-a-channel). - **Duplicates.** OmniPosts sends an idempotency key with each post, which Mastodon honours for about an hour. After that, OmniPosts checks your recent posts before it would ever try again, and a post whose fate is unknown is never sent a second time on its own. - **Server changes.** OmniPosts checks your server's address again before every call. If it stops resolving, the post is retried later. If it resolves to a private address, nothing is sent. - **Rate limits** are set by your server's admin. A standard server allows 300 requests per 5 minutes per account and 30 media uploads per 30 minutes. --- # Post to a Telegram channel or group by bot URL: https://jamp.sh/docs/channels/telegram Updated: 2026-10-22 Post to a Telegram channel or group through a bot you create and make an admin. OmniPosts posts to Telegram through a bot that you create and add to your channel as an admin. There is no platform review. ## What you need - A Telegram channel or group you manage. - A bot. Create one by talking to @BotFather in Telegram. It gives you a **bot token**. - The bot added to your channel as an **admin with permission to post**. Do the last step before you connect. OmniPosts refuses the connection if the bot cannot post there. ## Connect it On **Channels**, choose Telegram. The form asks for: | Field | What to enter | |---|---| | Bot token | The token from @BotFather. It is hidden as you type. | | Channel | The channel's `@username`, or its numeric id for a private channel. | For the channel you can also paste `t.me/yourchannel`; OmniPosts turns it into `@yourchannel`. Before it saves anything, OmniPosts checks three things: the token works, the chat exists and is not a private one-to-one chat, and the bot is the owner or an admin who can post. ## What you can post - **Text.** Up to 4096 characters, sent exactly as you wrote it. OmniPosts does not apply Telegram formatting, so nothing you type is read as markup. - **Images.** Up to 10 per post, JPEG, PNG or WebP, up to 10 MB each. One image is sent as a photo; two to ten are sent as one album. Alt text is not supported, because Telegram has no field for it. - **Captions.** When images are attached, your text becomes the caption, which Telegram limits to 1024 characters. OmniPosts checks this before sending. In an album the caption goes on the first image. - **Video.** Not supported in OmniPosts yet. The composer shows two Telegram settings: | Setting | What it does | Default | |---|---|---| | Show link preview | Shows a preview card for the first link in a text-only post. | On | | Send silently | Subscribers get the post without a notification sound. | Off | ## Numbers you get None. Telegram does not tell bots how many people viewed a message, and OmniPosts does not collect reactions or replies. Every cell shows "Not available" with the reason. See [Honest numbers](/docs/concepts/honest-numbers). ## Good to know - **No automatic double-check.** A bot cannot read a channel's history, so if Telegram does not give a clear answer, OmniPosts cannot look the post up. Such a post is marked for you to verify by hand instead of being sent again. - **When it needs a reconnect.** If you revoke the token, remove the bot, take away its right to post, or the channel moves or is deleted, the channel asks to be [reconnected](/docs/guides/reconnect-a-channel). - **Pace.** Telegram allows about one message per second in a chat and 20 per minute in a group. An album counts as several messages. - **Links to the post.** OmniPosts links to the message only for public channels, since a private one has no public address. --- # Connect an AI agent to OmniPosts over MCP with an API key URL: https://jamp.sh/docs/agents/connect Updated: 2026-10-22 Let Claude Code, Cursor, Codex or another MCP client check, schedule and follow your posts through OmniPosts, with only the permissions you give it. OmniPosts runs an MCP server, so an agent can check, schedule and follow your posts for you. Today an agent connects with an API key that you make for it, and it gets only the scopes you give that key. > **Coming soon.** Signing an agent in with OAuth, where your browser opens OmniPosts, you choose what the agent > may do and there is no key to copy, is not available yet. Until then, use an API key as below. ## 1. Make a key for the agent In the app, open **Settings**, then **Agents & API**, and create an API key just for this agent. Give it only the scopes it needs (see [Choose what it can do](#choose-what-it-can-do)) and an end date. Copy the key when it is shown: it is displayed once. Keys start with `omniposts_`. ## 2. Add OmniPosts to your client The server is at `https://omniposts.dev/mcp`. It speaks streamable HTTP, and every call carries the key, so there is no session to start or keep. > **Tab: Claude Code.** Run this in a terminal, with your key in place of `omniposts_...`: > > ```bash > claude mcp add --transport http omniposts https://omniposts.dev/mcp \ > --header "Authorization: Bearer omniposts_..." > ``` > **Tab: Cursor.** Add the server to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in one project: > > ```json > { > "mcpServers": { > "omniposts": { > "url": "https://omniposts.dev/mcp", > "headers": { "Authorization": "Bearer omniposts_..." } > } > } > } > ``` > **Tab: Codex.** Add the server to `~/.codex/config.toml`, or to `.codex/config.toml` in a trusted project; > the Codex CLI and IDE extension share it. Codex reads the key from an environment variable and sends it as > `Authorization: Bearer …`: > > ```toml > [mcp_servers.omniposts] > url = "https://omniposts.dev/mcp" > bearer_token_env_var = "OMNIPOSTS_API_KEY" > ``` > > Then set `OMNIPOSTS_API_KEY` to your key (`omniposts_...`) in the environment Codex starts from. > **Tab: Other clients.** Any MCP client that speaks streamable HTTP and can send a header works. Point it at > `https://omniposts.dev/mcp` and send `Authorization: Bearer omniposts_...` with every request. Many clients read a > JSON config of this shape: > > ```json > { > "mcpServers": { > "omniposts": { > "url": "https://omniposts.dev/mcp", > "headers": { "Authorization": "Bearer omniposts_..." } > } > } > } > ``` > **Tab: Claude and ChatGPT apps.** Custom connectors in the Claude and ChatGPT apps sign in to a server with > OAuth and cannot send an API key, so they cannot connect to OmniPosts yet. OAuth sign-in is coming soon. Until > then, use Claude Code, Cursor, Codex or another client that can send a header. ## 3. Ask for something > "List my channels, then check this post for Bluesky and Mastodon and queue it for the next free slot: > Open studio this Saturday, 10 to 4. Bring a friend." A few more that work well: - "What happened to the post I queued this morning?" - "Retry the channels that failed on my last post." - "How did yesterday's post do on Mastodon?" Numbers appear only where a platform shares them. See [Honest numbers](/docs/concepts/honest-numbers). - "Connect my Mastodon account." The agent cannot sign in to a platform for you: it gets a link, you open it and finish in your browser, and the agent sees the new channel when you are done. OmniPosts tells the agent to check a post with `validate_post` before sending it, and never to send again a post whose outcome is unknown. OmniPosts itself never sends one post twice to the same channel. ## Choose what it can do A key does only what its scopes allow. Pick them when you create the key. | Scope | What the agent can do with it | |---|---| | `channels:read` | See your channels, each platform's limits and settings, and follow a connection you are finishing | | `posts:read` | Read your posts and the result on each channel | | `posts:write` | Check, send, schedule, queue, cancel and retry posts | | `media:write` | Add an image or video from a public link | | `metrics:read` | Read the numbers a platform shares about a post | | `channels:manage` | Start connecting a new channel, which you then finish in your browser | For an agent that writes and schedules posts, `channels:read`, `posts:read`, `posts:write` and `media:write` are enough. Leave `posts:write` out for one that should only read. `posts:write` covers sending right away as well as scheduling: a key cannot allow one without the other. Some things only a signed-in person can do, such as managing API keys or deleting the account. No key can do them, so no agent can. See [Authentication](/docs/api/authentication#person-only-actions). ## Tools the agent gets | Tool | What it does | Scope | |---|---|---| | `list_channels` | Your connected channels and their health | `channels:read` | | `list_providers` | Each platform's text and media limits and settings | `channels:read` | | `validate_post` | Checks a post without sending it | `posts:write` | | `create_post` | Sends a post now, at a time, or into your queue | `posts:write` | | `list_posts` | Your posts, newest first | `posts:read` | | `get_post` | One post, with the result on each channel | `posts:read` | | `cancel_post` | Cancels the channels not sent yet | `posts:write` | | `retry_post` | Sends again to the channels that can be retried | `posts:write` | | `upload_media_from_url` | Stores an image or video from a public link | `media:write` | | `start_channel_connect` | Starts connecting a channel and returns a link for you | `channels:manage` | | `get_connect_session` | Whether that connection finished | `channels:read` | | `get_post_metrics` | A post's numbers on one channel, where shared | `metrics:read` | The tools return the same data as the API. The [MCP server reference](/docs/api/mcp) has the details. ## Revoke access Delete the agent's key in **Settings**, under **Agents & API**. From then on every call it makes is refused. A key also stops working at its end date. Posts the agent already sent stay where they are. --- # Publish one post to several channels at once URL: https://jamp.sh/docs/guides/publish-to-several-channels Updated: 2026-10-22 Write one post in the composer, adjust it per channel, attach media, choose when it goes out, and read the result in the Inbox. This guide takes you through the composer from a blank post to the result for each channel. You need at least one connected channel. If you have none, start with the [Quickstart](/docs/quickstart). ## 1. Write the post Open **New post**. Type your text under **Write once**. The draft saves as you type, and the top of the page shows **Saved** with the time. If your session ends, the text is kept in the browser tab until you sign in again. ## 2. Pick the channels Under **Where to post**, tick **Post to** each channel you want. Only channels that can take a post right now have a tick box. The others are listed below: - A channel that needs attention is shown with its reason and a **Reconnect** link. See [Reconnect a channel](/docs/guides/reconnect-a-channel). - A connected channel whose platform is switched off shows **posting paused for now**. - A line at the bottom says how many more channels are coming soon. **See which** opens the Channels screen. ## 3. Check the preview and adjust a channel Under **Preview** there is one tab for each channel you ticked. A tab shows: - how the text will read on that channel; - a counter such as `120 / 300`, counted the way that platform counts. It turns into a warning when the text is too long; - how many images and videos that channel allows. To change the post for one channel, open **Adjust for** that channel and tick **Use different text on** that channel. Edit the text there. The other channels keep the original. The same drawer holds the channel's own options, where a platform has any. A required option is marked, and some options are fixed for now and say so. ## 4. Add media Under **Add a photo or video**, pick a file or drop one in. It uploads at once. Each file then shows a field named **Describe it for people who can't see it**. Fill it in. This is the alt text, and the description saves when you leave the field. If a file does not fit a channel you ticked, a message under the file names the channel and the problem. **Remove** takes a file off the post. ## 5. Choose when Under **When**, pick one: - **Post now** - **Add to queue**, which shows the next free time. See [Use the queue](/docs/guides/use-the-queue). - **Schedule for later**, which asks for a date and time in your time zone. ## 6. Fix what blocks sending The send button is **Publish now**, **Add to queue** or **Schedule**, depending on your choice. Until the post can go, a line under the button says what is missing. Press the button anyway and OmniPosts lists every problem in a box headed **Not sent yet**, and moves you to it. Typical problems are: - no channel ticked; - a channel with too many characters, or a channel that needs text or an image and has none; - a required channel option that is empty; - a time that is not in the future; - an empty queue. Nothing is sent until the list is empty. ## 7. Send and read the result Press the button. The page then shows one line per channel, for example **Posted to 4 of 5**, with a status chip for each channel. **Follow this post** opens the detail page. If a channel rejects the post at the start, the others still go out. The channel that rejected it is listed with the reason and one button. Open the **Inbox** at any time. It lists every post, newest first, with a result for each channel: - **Posted**, with a link to the post on the platform; - **Not posted**, with a one-sentence reason and one button; - a status such as **Checking whether this posted**, while OmniPosts confirms the outcome. What each button does is in [Failure classes](/docs/reference/failure-classes). What each status means is in [Posts and results](/docs/concepts/posts-and-results). > **Tip.** If you press **Publish now** and the connection drops before you see an answer, press it > again. Pressing again is safe: the same post is never sent twice. --- # Use the queue: set times, fill the next slot URL: https://jamp.sh/docs/guides/use-the-queue Updated: 2026-10-22 Set your weekly posting times once, then add posts to the queue and let each take the next free time. The queue is a weekly list of posting times. You set it once, and each post you add takes the next free time. For how it works behind the scenes, see [Calendar and queue](/docs/concepts/calendar-and-queue). ## Set your weekly posting times 1. Open **Settings** and find **Posting times**. 2. Choose your **Time zone**. A list that has never been saved starts in your browser's time zone. 3. Next to a day, enter a time and press **Add**. Add as many times as you like on each day. Times are 24-hour. 4. To remove a time, press it. Each time button shows a small `x`. 5. Press **Save posting times**. The page says **Saved.** Under the list, **Next free times** shows the next few posting times that are still open. A list can have up to 56 times. ## Add a post to the queue 1. In the composer, under **When**, choose **Add to queue**. Next to it you see the next free time. 2. A note says that the post goes out at the next free posting time, in your time zone, with a link to **Change posting times**. 3. Press **Add to queue**. The result page says **Added to your queue for** and the date and time. Each posting time holds one post, so the next post you queue takes the time after that. > **Note.** A post you schedule by picking a time does not use a posting time. If you move a queued > post to a time you pick, it leaves the queue and frees its slot. ## Change your times later Edit the list in **Settings** and save it. The change applies to posts you queue from then on. Posts that are already queued keep the time they were given. Changing the time zone changes what the weekly times mean from then on, not the times of posts that are already queued. ## If the queue is empty If **Posting times** has no times, choosing **Add to queue** shows "Your queue has no posting times yet" with a link to **Set your posting times**. Add at least one time, or choose **Schedule for later**. ## If the queue is full OmniPosts looks 12 weeks ahead. If every posting time in that period is taken, the post is not placed. Add more times in **Settings**, or pick a time by hand. ## From the API `GET /v1/queue` returns your time zone, your times and the next free times. `PUT /v1/queue` replaces the whole list. To queue a post, send `"queue": true` on `POST /v1/groups` instead of `scheduledFor`. --- # Reconnect a channel that says Needs attention URL: https://jamp.sh/docs/guides/reconnect-a-channel Updated: 2026-10-22 What Needs attention means on a channel, how to reconnect it, and what happens to posts meanwhile. A channel stops taking posts when the platform no longer accepts the sign-in OmniPosts saved. This guide shows how to spot it and fix it. ## What it means A channel that shows **Needs attention** on the **Channels** screen needs you to reconnect it. A sentence under the status says why. Typical causes are a sign-in that expired, or access you removed on the platform's side. Some platforms give OmniPosts no way to renew a sign-in. For those, the channel moves to **Needs attention** about a day before the sign-in runs out. That gives you time to reconnect before a post fails. If the status is **Blocked by the platform**, the platform itself has blocked posting from the account. Reconnecting does not lift that. Check the platform, and if it stays blocked, write to [join@omniposts.dev](mailto:join@omniposts.dev). ## How to reconnect 1. Open **Channels**. 2. On the card with **Needs attention**, press **Reconnect** followed by the platform name. 3. Follow the steps for that platform. They are the same as when you first connected: sign in and approve OmniPosts, or paste a new app password, token or key. Each platform's page under [Channels](/docs/channels) says what it asks for. Reconnecting an account that is still connected does not use up another place on your plan. You can also start from the composer. A channel that needs a reconnect is listed under **Where to post** with its reason and a **Reconnect** link. A failed result in the **Inbox** shows the same button. ## What happens to posts meanwhile - **New posts.** The composer will not let you tick the channel while it needs a reconnect. Through the API, a post that names the channel gets a rejection for that channel, with the failure code `needs_reconnect`, while the other channels still go out. - **Posts already scheduled.** OmniPosts checks the channel again when the post is due. If the channel still needs a reconnect, the result for that channel fails with the message that the platform needs you to reconnect, and the other channels are not held back. The failed result offers **Reconnect**, not **Try again**. So reconnect before a scheduled post is due. If you reconnect too late, write the post again for that channel. ## What disconnecting does **Disconnect** on a channel card asks you to confirm. OmniPosts then deletes the channel's sign-in details and the raw copies of what it downloaded from the platform. Your list of posts, your publishing record and the numbers already on Performance stay, and they stop updating. Disconnecting from OmniPosts does not remove OmniPosts's access on the platform's side. To do that as well, remove OmniPosts from the platform's own settings. OmniPosts deletes app passwords at once on disconnect because it cannot revoke them for you. --- # Export your data as JSON or delete your account URL: https://jamp.sh/docs/guides/export-or-delete-your-data Updated: 2026-10-22 Download everything OmniPosts holds for your account as JSON, or delete the account. What goes at once and what goes within 30 days. You can take your data with you and you can delete it, both from **Settings**, without contacting anyone. The public [data deletion page](/data-deletion) is the canonical description of how deletion works and what is removed when; this guide walks through the buttons. The data sections are near the bottom of the **Settings** page. ## Export your data 1. Open **Settings**. 2. Under **Your data**, press **Download all my data**. You get one JSON file named `omniposts-export-` followed by the date. It holds everything OmniPosts keeps for your account: your organization and its members, channels, drafts, posts with their per-channel results, posting times, media listed by its details, engagement figures, webhooks and the audit events. Secrets are never included: no password hashes, no sign-in details for channels, and no webhook secrets. The media files themselves are not in the file, only a description of each. The file's `format` field reads `neslivis-export/1`. That is the format name from before the product was called OmniPosts, and it is kept so existing tools keep working. > **Note.** You can export 3 times an hour for your account. Past that, OmniPosts answers that you have > had too many exports and asks you to try again later. In the API the export is `GET /v1/settings/export`. It needs a signed-in person, not an API key. ## Delete your account Deleting your account deletes you: your sign-in and your personal data. Every workspace where you are the only member is deleted too, with its posts, media, connected channels and their sign-in details. Workspaces you share with other people carry on without you; the channels you connected there are disconnected (posts still scheduled to them fail as `disconnected` unless someone reconnects them) and the API keys you made there stop working. It cannot be undone. Posts already on other platforms stay there. If you are the only owner of a workspace that has other members, make one of them an owner (or remove them) first. Until then OmniPosts refuses, so nobody is left in a workspace without an owner. 1. Open **Settings**, and find **Delete account**. 2. Type `DELETE` in the box. The button is disabled until you do. 3. Press **Delete my account**. OmniPosts shows a **confirmation code**. Keep it. You can use it on the deletion status page to see how the deletion is going. ### What is deleted, and when | When | What | |---|---| | Immediately | Your channel credentials: tokens, app passwords, bot tokens and Nostr keys. OmniPosts also asks the platform to revoke tokens where the platform allows it. Raw data fetched from platforms. Sessions and API keys. Your profile: name, email and password. | | Within 30 days | Posts, drafts, media files, schedules, your publishing record, engagement figures and webhook logs of the workspaces that are deleted. | | Backups | Kept for at most 30 days, then overwritten. They never hold raw platform data. If a backup is ever restored, the deletions are applied again before the service takes traffic. | | Kept | A record of the consents you gave, to prove what you agreed to, and security audit logs for up to 12 months. Neither contains your posts or platform data. | In the API, deleting is `POST /v1/settings/delete-account` with `confirm` set to `DELETE`. It answers `202` with the `confirmationCode`, or `409 sole_owner_with_members` in the case above. If you cannot sign in, email [join@omniposts.dev](mailto:join@omniposts.dev) from the address on your account with the subject "Data deletion request". The full promise is on the data deletion page of the site, at `/data-deletion`, which also lets you check a confirmation code. ## Disconnect one channel instead To remove only one channel, press **Disconnect** on its card in **Channels**. Its credentials and the raw data from that platform go at once. Your record of what you posted there stays. See [Reconnect a channel](/docs/guides/reconnect-a-channel#what-disconnecting-does). ## What you agreed to **Settings** has a section named **What you agreed to**. It lists the consents OmniPosts has recorded for you. It is read-only. The way OmniPosts handles personal data, and your rights, are in the [privacy notice](/privacy). --- # Post statuses: every status and which are final URL: https://jamp.sh/docs/reference/post-statuses Updated: 2026-10-22 Every status a result for one channel can have, every status of a whole post, which are final, and how the post's status is worked out. There are two levels of status. A **result** is what happened on one channel (the API calls it a target). A **post** is the whole thing you sent to several channels (the API calls it a group). The API returns the values below in lower case, in `status`. ## Results (one channel) | API status | Shown in the app as | Meaning | Final? | |---|---|---|---| | `pending` | Waiting to send | Saved, not yet picked up. | No | | `preflight` | Checking the post | OmniPosts is checking text, media and the channel before sending. | No | | `queued` | Scheduled | Ready, waiting for its time. Also where a retry waits. | No | | `publishing` | Sending now | OmniPosts has started handing the post to the platform. | No | | `awaiting_platform` | Waiting for the platform to finish | The platform took the post and is still processing it. | No | | `unknown` | Checking whether this posted | Sent, but the platform did not confirm. OmniPosts is looking it up and will not resend. | No | | `published` | Posted | Live. The result has a link. | Yes. It never changes again. | | `failed` | Not posted | Did not go out. Has a reason and one action. | Usually. It can go back to `queued` when a retry is allowed. | | `cancelled` | Cancelled | Cancelled before sending started. | Yes | How a result moves: - `pending` to `preflight` to `queued` to `publishing`. - From `publishing` it ends at `published`, `failed`, `awaiting_platform` or `unknown`. - `awaiting_platform` ends at `published`, `failed` or `unknown`. - `unknown` ends at `published` or `failed`, and only after OmniPosts has looked the post up. It is never resolved by sending again. - `pending`, `preflight` and `queued` can also go straight to `failed` (the checks rejected the post, so nothing was sent) or to `cancelled`. - `publishing` can return to `queued` when the failure happened strictly before the post reached the platform and is safe to try again, such as a refused connection. - `failed` can return to `queued` when a retry is allowed. See [Failure classes](/docs/reference/failure-classes). - A `failed` result can become `published` in one case: OmniPosts's look-up said the post had not gone out, and a later, real answer from the platform shows that it had. You can cancel a result only while it is `pending`, `preflight` or `queued`. After that it may already be on the platform. The statuses `publishing`, `awaiting_platform` and `unknown` mean a send may have reached the platform. That is why they cannot be cancelled and why OmniPosts never resends them. ## Posts (the whole thing) | API status | Shown in the app as | Meaning | Final? | |---|---|---|---| | `draft` | Draft | No channels yet. | No | | `scheduled` | Scheduled | Every channel is still waiting to send. | No | | `publishing` | Sending | At least one channel is being sent or checked, or some channels are done while others still wait. | No | | `published` | Posted everywhere | Every channel that was not cancelled has posted. | Yes | | `partial` | Posted to some channels | At least one channel posted and at least one did not. | Mostly. A retry can change it. | | `failed` | Not posted | No channel posted, and at least one failed. | Mostly. A retry can change it. | | `cancelled` | Cancelled | Every channel was cancelled. | Yes | ### Status of the whole post OmniPosts works the post's status out from its results. These rules are checked in this order: 1. A post with no results is a `draft`. 2. If every result is `cancelled`, the post is `cancelled`. 3. If any result is `publishing`, `awaiting_platform` or `unknown`, the post is `publishing`. OmniPosts never shows a final verdict while a send might still be in flight. 4. Otherwise, if any result is still `pending`, `preflight` or `queued`: the post is `publishing` when some other result is already `published` or `failed`, and `scheduled` when none is. 5. Otherwise nothing is waiting or in flight. If no result `failed`, the post is `published`. If some `failed`, it is `partial` when at least one `published`, and `failed` when none did. Cancelled results are ignored in rules 4 and 5, so cancelling one channel of three leaves a post that can still become `published`. ## Where you see them The **Inbox** lists results, one row per channel. The **Calendar** and the post detail page show the post. The API reads them with `GET /v1/groups/{id}` for a post and `GET /v1/targets` for results. The whole story of a result is in [Posts and results](/docs/concepts/posts-and-results). --- # Failure classes: why a post failed, what to do URL: https://jamp.sh/docs/reference/failure-classes Updated: 2026-10-22 The five kinds of failure a result can have, the one action each offers, and which can be retried by you or by OmniPosts. When a result for a channel does not post, it carries a **failure**. Every failure has one class, a stable `code`, a one-sentence `message`, one `action`, and a `retryable` flag. The app shows the message and exactly one button chosen by the action. ```json { "class": "RECONNECT", "code": "needs_reconnect", "message": "Bluesky needs you to reconnect this account before we can post.", "action": "RECONNECT", "retryable": false } ``` ## The five classes | Class | What happened | Default action | Retried by | |---|---|---|---| | `RECONNECT` | The sign-in is dead, revoked or missing. | `RECONNECT` | Nobody. Reconnect the channel first. | | `RETRYABLE` | A temporary problem before the platform accepted the post, such as a rate limit or a refused connection. | `NONE_AUTO_RETRYING` | OmniPosts, automatically. You can retry by hand once OmniPosts has given up. | | `UNKNOWN` | Sent, but the outcome is not known, such as a timeout. | `VERIFY_MANUALLY` | Never. OmniPosts reads the post back instead. | | `USER_ERROR` | The post itself is the problem: text, media or a missing setting. | `FIX_CONTENT` | You, after you fix it. | | `PLATFORM_BLOCKED` | The platform or OmniPosts has stopped posting for this channel: a policy block, a switched-off platform, a platform OmniPosts no longer supports. | `CONTACT_SUPPORT` | Nobody. | `RETRYABLE` is the only class OmniPosts retries on its own. It is strictly a failure that happened before the post could have reached the platform, so a retry cannot double-post. `UNKNOWN` is never retried, because a retry might post twice to a real audience. ## The six actions | Action | Button in the app | What to do | |---|---|---| | `RECONNECT` | Reconnect *platform* | Reconnect the channel. See [Reconnect a channel](/docs/guides/reconnect-a-channel). | | `NONE_AUTO_RETRYING` | None. The result says OmniPosts is trying again. | Nothing. OmniPosts retries on its own. If it gives up, the action becomes `RETRY`. | | `VERIFY_MANUALLY` | Open *platform* | The post may be live. Look on the platform before you do anything else. OmniPosts will not send it again. | | `FIX_CONTENT` | Edit the post | Change the text, media or settings the message names, then send again. | | `CONTACT_SUPPORT` | Contact support | Nothing you can fix. Write to [join@omniposts.dev](mailto:join@omniposts.dev) with the code from the result. | | `RETRY` | Try again | Press **Try again**. The failure is safe to retry. | ## Which results you can retry **Try again** appears only on a result that has `failed` and whose `retryable` is `true`. That means: - a `RETRYABLE` failure whose automatic retries are used up. Its class stays `RETRYABLE` and its action becomes `RETRY`; - a result that was `unknown` and that OmniPosts's look-up proved had **not** posted. An `UNKNOWN` result is never retryable, and a `USER_ERROR` result is fixed by editing the post rather than by retrying. In the API, `POST /v1/groups/{id}/retry` re-queues only the failed results whose `failure.retryable` is `true`, and never one whose outcome is unconfirmed. > **Note.** If a result has failed but offers no retry, nothing is trying again. The app then > offers **Contact support**. See also [Post statuses](/docs/reference/post-statuses) and [Posts and results](/docs/concepts/posts-and-results). --- # FAQ: channels, scheduling, plans, data, API URL: https://jamp.sh/docs/reference/faq Updated: 2026-10-22 Short answers to common questions about channels, scheduling, plans, your data and the API. ## Which platforms can I connect? The ones marked Available in the [Channels](/docs/channels) table and on the **Channels** screen in the app. The rest are marked Coming soon and open on their own; nothing needs to be done on your side. See also [Coming soon](/docs/concepts/channels#coming-soon). ## Can I post to X? Not yet: X is coming soon. The **Channels** screen shows the current status. OmniPosts does not show a platform as available before it can post to it. ## Will my scheduled posts go out if I close my laptop? Yes. Posts are sent from OmniPosts's servers at their time. Your laptop and browser do not need to be open. ## What happens if the connection drops while I press Publish? Press **Publish** again. The same post is never sent twice: OmniPosts recognises the repeat and shows you the post that already exists. See [Posts and results](/docs/concepts/posts-and-results). ## What does "Checking whether this posted" mean? OmniPosts sent the post and the platform did not clearly answer. OmniPosts looks the post up on the platform instead of sending it again, so you cannot get a double post. It then marks the result Posted or Not posted. See [Posts and results](/docs/concepts/posts-and-results#checking-whether-this-posted). ## Why does it say "Not available" instead of 0? Because 0 would claim that nobody reacted. Where a platform does not share a number, or OmniPosts does not read it yet, OmniPosts says so and gives the reason. See [Honest numbers](/docs/concepts/honest-numbers). ## Do cancelled posts count toward my plan? No. A post you cancel before it goes out does not count toward the month. A post that was sent, that failed, or that is still scheduled does count. A post to several channels counts once. See [Plans](/docs/concepts/plans). ## What happens if I go over my plan or move to a smaller one? OmniPosts stops new connections and new posts above the limit. It never disconnects a channel and never cancels a scheduled post. Plan numbers are on the [pricing page](/pricing). ## How do I join the waitlist for Creator and Pro? Creator and Pro are in beta. Enter your email address on the [waitlist](/pricing#waitlist). It asks for nothing else: no password and no card. We will email you when they open. The Free plan is open now. ## Can I take my data with me? Yes. **Settings** has a button that downloads everything OmniPosts holds for your account as a JSON file. You can also delete the account yourself. See [Export or delete your data](/docs/guides/export-or-delete-your-data). ## Is there an API? Yes, on every plan. Create an API key in **Settings**, under **Agents & API**. Everything in the app is available over HTTP, with idempotency keys and signed webhooks. Start with the [Quickstart](/docs/quickstart) and the [API reference](/docs/api). The interactive reference is at [/swagger-ui.html](/swagger-ui.html). ## What does "Needs attention" on a channel mean? OmniPosts can no longer post with the saved sign-in. Open **Channels** and press **Reconnect**. See [Reconnect a channel](/docs/guides/reconnect-a-channel). ## Where do I report a problem? Write to [join@omniposts.dev](mailto:join@omniposts.dev). If a post failed, include the code shown on the result. If a page in these docs does not match what you see in OmniPosts, tell us as well. The product is right and the page will be fixed. --- # OmniPosts HTTP API reference: base URL, auth and resources URL: https://jamp.sh/docs/api Updated: 2026-10-22 Base URL, formats and a map of the OmniPosts HTTP API, with the two words it uses for a post and the result for one channel. The OmniPosts API lets your code do what the app does: connect to your channels, send or schedule posts, read the result for each channel, and get told when something happens. The web app uses this same API. ## Basics - **Base URL:** `https://omniposts.dev` - **Paths:** every endpoint is under `/v1`, for example `https://omniposts.dev/v1/channels`. - **Format:** requests and responses are JSON (`Content-Type: application/json`). File uploads use `multipart/form-data`. - **Times:** every timestamp is an ISO-8601 instant in UTC, for example `2026-10-20T09:30:00Z`. - **Ids:** every id is a UUID. - **One organization per call.** A key or a session belongs to one organization, and every call acts for it. You never send an organization id. A first call: ```bash curl https://omniposts.dev/v1/me \ -H "Authorization: Bearer omniposts_..." ``` Read [Authentication](/docs/api/authentication) to get a key. ## Try it - **Interactive reference:** [/swagger-ui.html](/swagger-ui.html) lists every endpoint, field and status, and lets you send requests from the page. - **The spec:** [/openapi.yaml](/openapi.yaml) is the OpenAPI 3.0 file the reference is built from, and [/openapi.json](/openapi.json) is the same document as JSON. When a page in these docs and the spec disagree, the spec is right. - **For AI tools:** [/llms.txt](/llms.txt) is a short index of the site, and [/llms-full.txt](/llms-full.txt) is every page of these docs as one Markdown file. - **Insomnia or Postman:** import `https://omniposts.dev/openapi.yaml` as a new collection. The spec lists `https://omniposts.dev` as its server, so requests work after you add your key as a Bearer token. ## A post and a result for one channel Two words appear in paths and field names: - A **group** is one post that you send to one or more channels. In these docs we call it a post. It has a body, an optional time, and a list of targets. - A **target** is the result for one channel. Each channel you name gets its own target with its own status (`published`, `failed`, `queued` and so on), its own link on the platform, and its own failure reason if it went wrong. One post to three channels is one group with three targets. A target can fail while the others succeed, so a post can end up `partial`. See [Posts](/docs/api/posts) and [Posts and results](/docs/concepts/posts-and-results). ## Where to go next | Page | What it covers | |---|---| | [Authentication](/docs/api/authentication) | API keys, the session cookie, and what a key cannot do | | [Errors](/docs/api/errors) | The error envelope, status and code pairs, and the per-channel failure object | | [Idempotency](/docs/api/idempotency) | Making a retry of "create a post" safe | | [Pagination](/docs/api/pagination) | Cursors and page sizes | | [Rate limits](/docs/api/rate-limits) | The limits, and what a 429 looks like | | [Providers](/docs/api/providers) | What each platform supports: limits, media rules, settings | | [Channels](/docs/api/channels) | List, connect and disconnect channels | | [Posts](/docs/api/posts) | Create, list, edit, cancel and retry posts, and the results inbox | | [Videos](/docs/api/videos) | One video to one channel, with typed settings for its platform | | [Drafts](/docs/api/drafts) | Autosaved drafts and publishing them | | [Queue](/docs/api/queue) | Your weekly posting times | | [Media](/docs/api/media) | Uploads and image descriptions | | [Reports](/docs/api/reports) | Delivery, metrics, performance and audience reports | | [Webhooks](/docs/api/webhooks) | Signed events, and how to verify them | | [Account](/docs/api/account) | Who you are, API keys, your plan, data export and deletion | --- # API authentication: keys, scopes and expiry URL: https://jamp.sh/docs/api/authentication Updated: 2026-10-22 Call the API with an API key in the Authorization header, and know which actions need a signed-in person instead. There are two ways to call the API. Scripts and servers use an **API key**. The OmniPosts web app uses a **session cookie**. Both act for one organization. ## API keys A key starts with `omniposts_`. Keys created before OmniPosts was renamed start with `jamp_` or `nsk_` and keep working until you revoke them. Send the key as a Bearer token on every request: ```bash curl https://omniposts.dev/v1/channels \ -H "Authorization: Bearer omniposts_..." ``` A request with no key, or a key that is wrong or revoked, gets `401` with code `unauthenticated`: ```json { "error": { "code": "unauthenticated", "message": "Please sign in." } } ``` A key needs no CSRF header. If the `Authorization` header is present and starts with `Bearer `, OmniPosts uses only the key and ignores any cookie. ### Create a key Create keys in the app: open **Settings**, then **Agents & API**. You can also call the API, but only from a signed-in session (see [Person-only actions](#person-only-actions)). `POST /v1/api-keys` takes an optional `name` (blank or missing becomes `API key`), optional `scopes` and an optional `expiresAt`. ```json { "name": "ci", "scopes": ["posts:write", "media:write", "channels:read"], "expiresAt": "2027-01-01T00:00:00Z" } ``` The response is `201`: ```json { "id": "0192a3b4-0000-7000-8000-0000000000a1", "name": "ci", "prefix": "omniposts_Ab12Cd3", "key": "omniposts_...", "scopes": ["channels:read", "posts:write", "media:write"], "expiresAt": "2027-01-01T00:00:00Z" } ``` ### Scopes A key created without `scopes` can do everything the account can do through the API (apart from the [person-only actions](#person-only-actions)). Give a key for one job only the scopes that job needs: a leaked key then cannot reach the rest. | Scope | What it covers | |---|---| | `posts:read` | Posts, their per-channel results, drafts and videos | | `posts:write` | Create, check, schedule, edit, cancel, retry and promote posts, drafts and videos | | `media:read` | List media and download its bytes | | `media:write` | Upload, edit and delete media | | `channels:read` | Providers, channels and the state of a connect session | | `channels:manage` | Start a connect session; disconnect a channel | | `metrics:read` | Metrics and reports | | `webhooks:manage` | Webhook endpoints and their deliveries | | `account:read` | `GET /v1/me`, the plan, the posting queue, consents, the API-key list, and the key's own workspace and its members | | `settings:write` | Change the plan or the posting queue | A request outside a key's scopes gets `403` with code `insufficient_scope`; the `message` names the scope that is missing. Scopes are checked per resource (`GET` needs the `:read` scope, anything else the write one), so a new endpoint under a resource you already have is covered. An empty `scopes` list is refused with `400`: leave the field out for a key with every scope. ### Expiry `expiresAt` must be in the future (else `400`). From that moment the key gets `401 unauthenticated`, exactly like a revoked key, and it stays in the list with its `expiresAt` so you can see why. A key without `expiresAt` never expires. > **Warning.** `key` is shown once, in this response. OmniPosts keeps only a hash of it, so it cannot be shown > again. Copy it into your secret store straight away. If you lose it, revoke it and create a new one. ### List and revoke keys `GET /v1/api-keys` lists your organization's keys. It shows `id`, `name`, `prefix` (the first characters of the key, so you can recognise it), `createdAt` and `lastUsedAt` (`null` if never used). It never shows the secret. `DELETE /v1/api-keys/{id}` revokes a key. The answer is `204`. A revoked key gets `401` from then on. An id that is not in your organization gets `404`. ## The session cookie The web app signs a person in with `POST /v1/auth/login` (or `POST /v1/auth/signup`) and gets a cookie named `nsid`. It is `HttpOnly`, `SameSite=Lax` and `Secure`, and lasts 30 days. `POST /v1/auth/logout` ends the session and clears the cookie. Scripts do not need any of this: use a key. See [Account](/docs/api/account#sign-up-and-log-in). ### Why cookie writes need `X-OmniPosts-Client: web` A browser attaches a cookie to requests that another website triggers. To stop a hostile page from making your browser change your data, every request that changes something (anything other than `GET`) and is authenticated by the cookie must also send this header: ```http X-OmniPosts-Client: web ``` A cross-site form cannot set that header. Without it you get `403` with code `csrf`. The sign-up, log-in and log-out endpoints need the header even though they have no cookie yet. The older name `X-Neslivis-Client: web` is still accepted. Use `X-OmniPosts-Client` in new code. This only matters if you call the API with a cookie. A Bearer key never needs the header. ## Person-only actions Some actions need a signed-in person, not a key. A key that tries one gets `403` with code `forbidden`: ```json { "error": { "code": "forbidden", "message": "..." } } ``` These are the person-only operations: | Operation | Why | |---|---| | `POST /v1/api-keys` and `DELETE /v1/api-keys/{id}` | A key must not be able to mint or remove keys | | `DELETE /v1/channels/{id}` | Disconnecting destroys tokens that only a person can restore | | `GET /v1/settings/consents` | The consent record belongs to a person | | `GET /v1/settings/export` | Your whole data set | | `PUT /v1/plan` | An organization owner's decision | | `POST /v1/settings/delete-account` | Deletes the person who asks | | Changing workspaces, members and invites (`POST /v1/workspaces` and the rest) | Who belongs to a workspace is decided by people. See [Workspaces and members](/docs/concepts/workspaces) | `PUT /v1/plan` also needs the person to be an owner of the organization. A member who is not an owner gets `403 forbidden` too. So do renaming or deleting a workspace and managing its members and invites. Connecting a channel is the one person-only step a key can *start*: `POST /v1/connect-sessions` with a key answers a one-time `handoffUrl` for a person to open. See [Start a connect from an API key](/docs/api/channels#start-a-connect-from-an-api-key-a-handoff). Everything else, including creating posts, uploading media, the queue, reports and webhooks, works with a key. ## Related - [Errors](/docs/api/errors) for the `401` and `403` envelopes. - [Rate limits](/docs/api/rate-limits) for the limits on `/v1/auth/*`. --- # API errors: status codes, error codes, failures URL: https://jamp.sh/docs/api/errors Updated: 2026-10-22 The error envelope, every status and code the API returns, and the failure object that explains why one channel did not post. There are two kinds of error. A **request error** means the API refused your request: it comes back as an HTTP error status with an error envelope. A **channel failure** means the request was fine but one channel did not post: it sits on that channel's result as a `failure` object, and the HTTP status is still a success. ## The error envelope Every request error has the same shape: ```json { "error": { "code": "text_too_long", "message": "One plain sentence you can show to a person.", "details": {} } } ``` - `code` is stable and lower-case with underscores. Branch on this, not on `message`. - `message` is one plain sentence. It may change wording, so do not parse it. - `details` is present only when it carries something. Otherwise the key is absent. `details` can hold: | Key | When | |---|---| | `rejections` | `nothing_to_publish`: a list of `{channelId, failure}` | | `current` | `stale_write` and `not_editable`: the post or draft as it is now | | `failure` | A refused connect: the full [failure object](#the-failure-object) | | `limit` and `plan` | `plan_limit_reached`: which limit (`channels` or `posts_per_month`) and the plan id | ## Status and code pairs | Status | Code | When | |---|---|---| | 400 | `invalid_request` | Malformed JSON, a missing or mistyped field, id, cursor or time, an unreadable `If-Match`, an `Idempotency-Key` outside 1 to 255 characters, `queue: true` together with `scheduledFor`, or an upload with no `file` part | | 401 | `unauthenticated` | No key or session, or a bad one | | 401 | `invalid_credentials` | Log in: the email and password do not match | | 403 | `csrf` | A cookie-authenticated write without `X-OmniPosts-Client: web` | | 403 | `forbidden` | A key on a [person-only action](/docs/api/authentication#person-only-actions), or a non-owner on an owner-only one | | 404 | `not_found` | No such id. This is also what another organization's id returns: OmniPosts never reveals that it exists | | 405 | `method_not_allowed` | The path does not take that method | | 409 | `idempotency_conflict` | Same `Idempotency-Key`, different request. See [Idempotency](/docs/api/idempotency) | | 409 | `stale_write` | `If-Match` did not match. `details.current` holds the current object | | 409 | `already_promoted` | The draft was already published or discarded | | 409 | `not_editable` | The draft is no longer a draft. `details.current` holds it | | 409 | `already_dispatched` | A post cannot be edited because a channel has started sending | | 409 | `already_sent` | Alt text cannot change because a post using that media was already sent | | 409 | `email_taken` | Sign up with an email that already has an account | | 415 | `unsupported_media_type` | The body is not JSON (or multipart for uploads) | | 422 | `nothing_to_publish` | Every channel was rejected. `details.rejections` says why for each | | 422 | `invalid_edit` | The new body fails a channel's rules | | 422 | `plan_limit_reached` | Your plan's limit is used up. See `details.limit` and `details.plan` | | 422 | `queue_empty` | `queue: true`, but you have no posting times | | 422 | `queue_full` | `queue: true`, but every posting time in the next 12 weeks is taken | | 422 | `media_rejected` | The upload is empty, too large, unreadable or the wrong type | | 422 | `invalid_webhook` | The webhook URL or event list was refused | | 422 | `terms_required`, `weak_password`, `password_too_long` | Sign up | | 422 | `confirmation_required` | Delete account without `"confirm": "DELETE"` | | 422 | a failure code | A refused connect. `error.code` is the failure's code, for example `channel_switched_off` or `not_configured` | | 429 | `rate_limited` | Too many requests. See [Rate limits](/docs/api/rate-limits) | | 500 | `internal_error` | Something went wrong on our side. Try again | A `422` always means the request was understood but cannot be done as sent. Change something before you retry. A `409` means the state moved: fetch the current object, then decide. A `5xx` and a `429` are safe to retry with backoff. For `POST /v1/groups`, send an `Idempotency-Key` so the retry cannot post twice. ## The failure object A target (the result for one channel) that did not post carries a `failure`. The same object appears in `rejections`, in webhook payloads, and in `details.failure` on a refused connect. ```json { "class": "RETRYABLE", "code": "platform_error", "message": "One plain sentence.", "action": "NONE_AUTO_RETRYING", "retryable": true } ``` | Field | Meaning | |---|---| | `class` | `RECONNECT`, `RETRYABLE`, `UNKNOWN`, `USER_ERROR` or `PLATFORM_BLOCKED`. The kind of problem | | `code` | A stable token for this problem. The set of codes grows and differs per platform, so do not switch on a fixed list | | `message` | One plain sentence to show a person | | `action` | The single thing to offer: `RECONNECT`, `NONE_AUTO_RETRYING`, `VERIFY_MANUALLY`, `FIX_CONTENT`, `CONTACT_SUPPORT` or `RETRY` | | `retryable` | Whether `POST /v1/groups/{id}/retry` will try this channel again | Show `message` and exactly one button chosen by `action`. Decide by `class` and `action`, never by `code`. | Class | Usual action | What it means and what to do | |---|---|---| | `RECONNECT` | `RECONNECT` | The platform no longer accepts the saved login. Send the person to reconnect the channel. `retryable` is `false`: once reconnected, send a new post | | `RETRYABLE` | `NONE_AUTO_RETRYING` | A temporary problem. OmniPosts is retrying by itself, so do nothing. While it is retrying the target is not yet `failed` | | `RETRYABLE` | `RETRY` | Automatic retries are used up and the target is `failed`. `retryable` is `true`: call [retry](/docs/api/posts#retry-failed-channels) | | `UNKNOWN` | `VERIFY_MANUALLY` | OmniPosts could not confirm whether the post went out. Look at the channel yourself. `retryable` is never `true`, because a retry could post twice | | `USER_ERROR` | `FIX_CONTENT` | The post itself is the problem: too long, wrong media, a missing setting. Change the text, media or settings, then send again | | `PLATFORM_BLOCKED` | `CONTACT_SUPPORT` | The platform or the channel is blocking posts (for example the channel is switched off, or the account is suspended). Retrying will not help. Contact support | A target rejected when you create the post (a `rejection`) is stored as a failed target, so it also appears in `targets` and in the results inbox. > **Note.** The meaning of every post and target status is in > [post statuses](/docs/reference/post-statuses) and [failure classes](/docs/reference/failure-classes). --- # Idempotency keys: never post twice by mistake URL: https://jamp.sh/docs/api/idempotency Updated: 2026-10-22 Add an Idempotency-Key to create-a-post so a retry after a timeout never sends the same post twice. If a request to create a post times out, you cannot tell whether OmniPosts received it. Sending it again could post twice. The `Idempotency-Key` header removes the doubt: send the same key and the same request again and you get the original post back instead of a second one. ## Use it Send the header on `POST /v1/groups`. It also works on `POST /v1/ideas/{id}/promote`, which creates a post the same way. ```bash curl https://omniposts.dev/v1/groups \ -H "Authorization: Bearer omniposts_..." \ -H "Idempotency-Key: 6f1c2a52-launch-post-1" \ -H "Content-Type: application/json" \ -d '{"body": "We shipped.", "targets": [{"channelId": "CHANNEL_ID"}]}' ``` - The key is any string of 1 to 255 characters. A UUID, or an id from your own database, works well. Anything else gets `400 invalid_request`. - The header is optional, but send it every time. Without it, a retry is a new post. - A key is scoped to your organization. Two organizations can use the same key. - Use a new key for each different post. Reuse a key only to retry the very same request. ## What a replay looks like If the key and the request both match an earlier call, OmniPosts creates nothing. It answers `200` with the header `Idempotent-Replayed: true`, and the body is the post as it is now (so its statuses may have moved on since the first call): ```http HTTP/1.1 200 OK Idempotent-Replayed: true ``` The first call answers `201` (all channels accepted) or `207` (some rejected). A replay is always `200` and carries `rejections` too. Check the `Idempotent-Replayed` header to tell a replay from a first answer. ## Same key, different request If you reuse a key with a request that differs, OmniPosts refuses it so it cannot guess which one you meant: ```json { "error": { "code": "idempotency_conflict", "message": "This Idempotency-Key was already used for a different request." } } ``` The status is `409`. Pick a new key for the new request. ## What counts as the same request OmniPosts compares a SHA-256 fingerprint of these parts of the request: - `body` - `mediaIds` - `scheduledFor` and `timezone` - `queue`, when it is `true` - the draft being published, when you call `POST /v1/ideas/{id}/promote` - `targets`, **in the order you sent them**, with each target's `channelId`, `body`, `mediaIds` and `settings` So changing the text, adding a channel, reordering the targets, or moving the time makes it a different request. A missing top-level `body` or `mediaIds` counts the same as an empty one. On a target, leaving out `mediaIds` (use the post's media) is different from sending `[]` (no media). The `Idempotency-Key` itself is not part of the fingerprint, and neither are headers such as `Authorization`. Sending the same JSON with a different key is a new post. ## A 422 does not use up the key The key is saved only when a post is actually created. If the request is refused, nothing is saved, so you can fix the problem and send again with the same key. This covers `nothing_to_publish`, `plan_limit_reached`, `queue_empty` and `queue_full`, and any `400`. A `207` is different. It means a post was created and some channels were rejected, so the key is used. To resend those channels, use [retry](/docs/api/posts#retry-failed-channels), or create a new post with a new key. ## Two requests at once If two requests with the same key arrive at the same moment, one creates the post and the other gets the replay. You still end up with one post. ## Related - [Posts](/docs/api/posts) for the whole create call. - [Errors](/docs/api/errors) for `idempotency_conflict`. --- # Paginate API lists with cursors, in order URL: https://jamp.sh/docs/api/pagination Updated: 2026-10-22 How to page through lists with a cursor, which endpoints are paginated, and in what order they return items. List endpoints that can grow long return one page at a time. You ask for a page with `limit` and `cursor`, and each answer tells you the cursor for the next page. ## Parameters and response | Parameter | Meaning | |---|---| | `limit` | How many items you want. The default is 25. A value above 100 is lowered to 100. A value of 0 or less means the default | | `cursor` | The `nextCursor` from the previous page. Leave it out for the first page | The response is always the same shape: ```json { "items": [], "nextCursor": "0192a3b4-0000-7000-8000-000000000042" } ``` `nextCursor` is `null` when there is nothing more. Treat the cursor as an opaque string: pass it back unchanged. (Today it is a UUID, and a `cursor` that is not a UUID gets `400 invalid_request`.) ## Walk through every page ```bash curl "https://omniposts.dev/v1/targets?limit=50" \ -H "Authorization: Bearer omniposts_..." # then, with the nextCursor from that answer: curl "https://omniposts.dev/v1/targets?limit=50&cursor=0192a3b4-0000-7000-8000-000000000042" \ -H "Authorization: Bearer omniposts_..." ``` Stop when `nextCursor` is `null`. > **Note.** OmniPosts returns a `nextCursor` whenever a page is full. If the list ends exactly at the end of a full > page, the next request returns an empty `items` array and a `null` cursor. Handle an empty page without > treating it as an error. Keep the same filters (`status`, `channel`, `from`, `to`) on every page of one walk. A cursor only continues the list you started. ## Paginated endpoints | Endpoint | Order, newest first | |---|---| | `GET /v1/groups` | By the post's time: `scheduledFor`, else `createdAt`. Ties by newest id | | `GET /v1/targets` | By sent time: `publishedAt`, else the post's `scheduledFor`, else creation. Ties by newest id | | `GET /v1/ideas` | Drafts, newest first. Discarded drafts are never listed | | `GET /v1/metrics/summary` | Published results, by `publishedAt` | | `GET /v1/webhooks/{id}/deliveries` | A webhook's delivery log, newest first | These return everything in one answer, with no cursor: `GET /v1/channels`, `GET /v1/providers`, `GET /v1/api-keys`, `GET /v1/webhooks` and `GET /v1/settings/consents`. They return `{ "items": [...] }` with no `nextCursor`. ## Related - [Posts](/docs/api/posts#list-posts) for the filters on `GET /v1/groups` and `GET /v1/targets`. - [Reports](/docs/api/reports) for `GET /v1/metrics/summary`. --- # API rate limits, 429 responses and Retry-After URL: https://jamp.sh/docs/api/rate-limits Updated: 2026-10-22 How many requests you can make per minute, how OmniPosts counts them, and what to do when you get a 429. OmniPosts limits how fast you can call the API. The limits protect everyone's posting from a runaway script. Normal use does not come near them. ## The buckets Every request is counted in one bucket. Counting uses fixed windows: a one-minute bucket starts fresh at the start of each clock minute. | Bucket | Limit | Window | Counted per | Which requests | |---|---|---|---|---| | Read | 600 | 1 minute | organization | Every `GET` (and `HEAD`) | | Write | 60 | 1 minute | organization | Every other write: `POST`, `PUT`, `PATCH`, `DELETE` | | Autosave | 30 | 1 minute | organization | `PATCH /v1/ideas/{id}` only | | Media | 10 | 1 minute | organization | Writes under `/v1/media`: uploads and `PATCH /v1/media/{id}` | | Auth | 20 | 1 minute | client IP address | `/v1/auth/*`: sign up, log in, log out | | Auth, per account | 20 | 1 minute | email address | `POST /v1/auth/login`, on top of the IP limit | | Export | 3 | 1 hour | organization | `GET /v1/settings/export`, on top of the Read bucket | "Per organization" means everything you do with any key or session of one organization shares the bucket. Two keys in the same organization do not get two budgets. The Read, Write, Autosave and Media counts are kept per API server. If OmniPosts runs more than one API server, each one allows its own budget, so the real ceiling can be higher than the number above, never lower. Auth and Export are shared across servers. Requests that fail to authenticate (`401`) are not counted. The beta waitlist form on the pricing page (`POST /waitlist`) is not part of the API. It has its own limit of 5 attempts an hour per client address, and answers with a redirect back to the page instead of a `429`. ## Headers | Header | When | Meaning | |---|---|---| | `X-RateLimit-Remaining` | Every request that was counted, not only a refused one | Requests left in the current window. `0` on a refusal | | `Retry-After` | Every `429` | Whole seconds until the current window ends. At least 1 | ```http HTTP/1.1 200 OK X-RateLimit-Remaining: 587 ``` ## What a 429 looks like ```http HTTP/1.1 429 Too Many Requests Retry-After: 23 X-RateLimit-Remaining: 0 Content-Type: application/json ``` ```json { "error": { "code": "rate_limited", "message": "Too many requests. Wait a moment and try again." } } ``` The export and log-in limits have their own wording in `message` (for example, how many exports you can download an hour), with the same code and the same `Retry-After`. ## What to do - Wait `Retry-After` seconds, then try again. Do not retry in a tight loop. - Watch `X-RateLimit-Remaining` and slow down before it reaches 0. - To post to many channels, put them in one [post](/docs/api/posts#create-a-post) with several targets. One call to `POST /v1/groups` counts once, however many channels it names. - Use an `Idempotency-Key` on every `POST /v1/groups`, so retrying after a `429` or a timeout is safe. See [Idempotency](/docs/api/idempotency). - Use [webhooks](/docs/api/webhooks) instead of polling for results. ## Related - [Errors](/docs/api/errors) for the envelope. --- # Providers API: platform limits and media rules URL: https://jamp.sh/docs/api/providers Updated: 2026-10-22 Read what each platform supports (text limits, media rules, settings) and why one may be locked, then build your client from it. A **provider** is a platform OmniPosts can post to, such as Bluesky or LinkedIn. `GET /v1/providers` is the capability registry: for every provider it states what a post may contain, which extra settings it accepts, how to connect it, which numbers it can report, and whether it is open right now. OmniPosts's own composer draws its screens from this registry and nothing is hard-coded. Your client can do the same and stay correct when limits or availability change. ## List providers ```bash curl https://omniposts.dev/v1/providers \ -H "Authorization: Bearer omniposts_..." ``` The answer is `{ "items": [ ... ] }` with one object per provider. This example is abridged to the parts that matter most. The values belong to one provider and differ for others, so always read them live. ```json { "items": [ { "id": "bluesky", "displayName": "Bluesky", "tier": "FREE", "enabled": true, "lockedReason": null, "lockedKind": null, "auth": { "kind": "APP_PASSWORD", "fields": [ { "name": "identifier", "label": "Handle or email", "help": "...", "secret": false }, { "name": "appPassword", "label": "App password", "help": "...", "secret": true } ], "inputs": [] }, "text": { "maxLength": 300, "counter": "GRAPHEMES", "required": true }, "media": { "maxImages": 4, "maxImageBytes": 2000000, "imageMimes": ["image/jpeg", "image/png", "image/webp"], "maxVideos": 0, "maxVideoBytes": 0, "videoMimes": [], "maxAltTextLength": 2000, "mediaRequired": false, "ingest": "UPLOAD" }, "uiFields": [], "readback": "EXACT" } ] } ``` ## Fields | Field | Meaning | |---|---| | `id`, `displayName` | The provider id you pass as `providerId` when connecting, and its name | | `tier` | `FREE` or `GATED`. A gated provider depends on an outside approval or a paid platform plan | | `enabled` | `true` when you can connect it and post to it now | | `lockedReason`, `lockedKind` | When `enabled` is `false`: one sentence of why, and a machine kind (below). Both are `null` when enabled | | `auth` | How to connect. See below | | `text` | `maxLength`, how it is counted (`counter`), and whether text is `required` | | `media` | Image and video rules | | `uiFields` | Extra per-channel settings a post can carry | | `readback` | How OmniPosts can check a post after sending: `EXACT`, `SEARCH` or `NONE` | | `analytics`, `accountAnalytics` | Which numbers the platform can report. See [Reports](/docs/api/reports) | | `apiVersion`, `checkedDate`, `stale` | The platform API version OmniPosts targets, when it was last checked, and `true` if that was over 90 days ago | ### Text `maxLength` is measured by `counter`: - `GRAPHEMES` counts what a person sees as one character (an emoji is one). - `CODE_POINTS` counts Unicode code points. - `WEIGHTED` counts with the platform's own weighting, where some characters count for more than one. If `required` is `true`, a post with no text and no media is refused for that channel. ### Media | Field | Meaning | |---|---| | `maxImages`, `maxVideos` | How many of each a post may carry. `0` means none | | `maxImageBytes`, `maxVideoBytes` | Largest file, in bytes | | `imageMimes`, `videoMimes` | Accepted types | | `maxAltTextLength` | Longest image description | | `mediaRequired` | `true` if a post must have an image or video | | `ingest` | `UPLOAD` (OmniPosts uploads the file) or `URL` (the platform fetches it from a public address) | A post cannot mix images and video. Upload files with [Media](/docs/api/media), which also reports, per provider, what each file would be refused for. ### Settings (`uiFields`) Some platforms take extra options, such as a visibility or a title. Each entry has a `name`, a `label`, a `type` (`TEXT`, `TEXTAREA`, `SELECT` or `TOGGLE`), `options` (for `SELECT`), a `defaultValue`, whether it is `required`, some `help`, and a `lockedValue` (a value you must keep for now). Send values as strings in a target's `settings` map when you [create a post](/docs/api/posts#create-a-post): ```json { "channelId": "CHANNEL_ID", "settings": { "name": "value" } } ``` A `TOGGLE` takes `"true"` or `"false"`. A name that is not listed is refused for that channel as `setting_unknown`. ### Why a provider is locked `lockedKind` is one of: | Kind | Meaning | |---|---| | `platform_review` | Waiting for the platform's own app review or audit | | `paid_access` | The platform charges for posting and paid posting is not switched on | | `not_configured` | This OmniPosts server has no app credentials for the platform yet | | `switched_off` | Posting is switched off for another reason, such as a safety switch | | `upcoming` | Coming soon: OmniPosts has announced it, and it is not open to connect yet | While a provider is locked, connecting it is refused, and a post to an existing channel on it is rejected with `channel_switched_off`. Availability changes as reviews finish. `lockedReason` and `lockedKind` are for your integration and your logs: the OmniPosts app and website show every locked provider simply as "Coming soon", and the failure messages for a locked provider say only that ("X is coming soon to OmniPosts.", "Posting to X is paused for now, so this post wasn't sent."). See [Channels](/docs/channels) and the Channels screen in the app. ## How to use it - Fetch the registry when your app starts and cache it for a short time, not forever. - Validate text length and media against it before you call the API, using `counter` to count. The API checks again and answers per channel, so your check is a convenience. - Show a provider as "Coming soon" when `enabled` is `false`. Keep `lockedReason` and `lockedKind` for your logs and your own logic; do not show them to people. - Draw connect forms from `auth.fields` (and `auth.inputs`), and settings forms from `uiFields`. - Do not hard-code a list of providers or limits. New providers appear here first. --- # Channels API: list, connect and disconnect URL: https://jamp.sh/docs/api/channels Updated: 2026-10-22 List your connected channels, connect a new one with credentials or OAuth, and disconnect one. A **channel** is one connected account on one platform, for example a Bluesky account. You post to channels. Their ids go in the `targets` of a [post](/docs/api/posts). ## List channels `GET /v1/channels` returns the channels that are connected now. ```bash curl https://omniposts.dev/v1/channels \ -H "Authorization: Bearer omniposts_..." ``` ```json { "items": [ { "id": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "displayName": "Ada", "handle": "ada.example.com", "avatarUrl": null, "health": { "state": "OK", "reason": null, "action": null }, "connectedAt": "2026-10-01T08:00:00Z" } ] } ``` `displayName`, `handle` and `avatarUrl` can be `null`. `providerId` matches an `id` from [Providers](/docs/api/providers). `health.state` tells you whether a post to this channel is likely to work: | State | Meaning | |---|---| | `OK` | Nothing is wrong | | `DEGRADED` | The channel has a problem short of needing a reconnect. Read `reason` | | `NEEDS_RECONNECT` | The platform no longer accepts the saved login. Posts to this channel are refused until you reconnect it | | `BLOCKED` | The platform or account is blocking posts. Read `reason` | `reason` is a sentence for a person, and `action` names the fix to offer. Both can be `null`. When a channel needs reconnecting, OmniPosts also sends the `token.expired` [webhook](/docs/api/webhooks). ## Disconnect a channel `DELETE /v1/channels/{id}` removes the login and the cached platform data for that channel. The answer is `204`. OmniPosts keeps its own history of what you posted. An id that is not yours gets `404`. This needs a signed-in person, not a key. A key gets `403 forbidden`. See [Authentication](/docs/api/authentication#person-only-actions). A `channel.disconnected` webhook is sent. ## Connect a channel Connecting is a **connect session**. How it goes depends on the provider's `auth.kind` in [Providers](/docs/api/providers): - `APP_PASSWORD` and `BOT_TOKEN` take credentials that you send. The session completes at once. - `OAUTH2` sends the person to the platform to approve. The session stays `pending` until they return. Starting a session is person-only (`403 forbidden` for a key), because a person has to approve it. ### Start a session `POST /v1/connect-sessions` takes: | Field | Meaning | |---|---| | `providerId` | Required. A provider `id`. Missing gives `400 invalid_request` | | `fields` | Credentials, as a map of strings. Names come from the provider's `auth.fields` | | `input` | Non-secret start values for OAuth, such as a server address. Names come from `auth.inputs` | For a provider that takes credentials: ```bash curl https://omniposts.dev/v1/connect-sessions \ -H "Cookie: nsid=..." \ -H "X-OmniPosts-Client: web" \ -H "Content-Type: application/json" \ -d '{"providerId": "bluesky", "fields": {"identifier": "ada.example.com", "appPassword": "xxxx-xxxx-xxxx-xxxx"}}' ``` The answer is `201` with `status: "completed"` and the new channel: ```json { "id": "0192a3b4-0000-7000-8000-0000000000c1", "providerId": "bluesky", "status": "completed", "channel": { "id": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "displayName": "Ada", "handle": "ada.example.com", "avatarUrl": null, "health": { "state": "OK", "reason": null, "action": null }, "connectedAt": "2026-10-01T08:00:00Z" }, "error": null } ``` For an OAuth provider the answer is `201` with `status: "pending"`, `channel: null`, and an `authorizationUrl`. Send the person's browser to that URL. The platform then returns them to OmniPosts (the `GET /v1/oauth/callback` redirect, which is for browsers and not something you call) and OmniPosts finishes the connection. The return must happen in the same browser session that started it. ### Check a session `GET /v1/connect-sessions/{id}` returns the same object. Poll it after the redirect. `status` is one of: | Status | Meaning | |---|---| | `pending` | Waiting for the person to approve. A pending session lasts one hour | | `completed` | Connected. `channel` is set | | `failed` | It did not work. `error` is one plain sentence | | `expired` | The hour passed. Start again | `authorizationUrl` appears only in the `201` that starts an OAuth session. ### Start a connect from an API key (a handoff) A key cannot sign in to a platform, but it can start the connect and hand a person the link. `POST /v1/connect-sessions` with a key (scope `channels:manage`) answers `201`: ```json { "id": "0192a3b4-0000-7000-8000-0000000000c1", "providerId": "bluesky", "status": "pending", "handoffUrl": "https://omniposts.dev/connect/handoff/…", "channel": null, "error": null } ``` Give `handoffUrl` to a person who is a member of the workspace. When they open it, signed in, the session becomes theirs and their browser goes to the platform; the OAuth return finishes in that same browser. The key polls `GET /v1/connect-sessions/{id}` until `status` is `completed` (and `channel` is set), `failed` (`error` says why: the link was opened by an account outside the workspace, the platform refused, …) or `expired` (an hour after the start, or if nobody opened it). - `handoffUrl` is shown once, in this answer, and works once. Lose it: start again. - Platforms that connect with a pasted token or password (Telegram, an app password) cannot be handed off: `422` with code `handoff_not_available`. A person connects those in the app. - A session started by a person has no `handoffUrl`; a session started by a key has no `authorizationUrl`. ### When OmniPosts refuses A refused start answers `422`. `error.code` is the failure's code and `details.failure` is the full [failure object](/docs/api/errors#the-failure-object). Common causes: - the provider is locked: `channel_switched_off`, or `not_configured` when this server has no credentials for it - an unknown `providerId`: `unknown_provider` - a credential the platform rejected - your plan's channel limit: `plan_limit_reached`, with `details.limit` set to `channels` - you were removed from the workspace, or left it, while connecting: `not_a_member` - the workspace was deleted while connecting: `workspace_deleted` An OAuth connect that fails after the platform's page ends with `status: failed`, and `error` holds the same failure as one sentence, for example "You're no longer a member of this workspace, so the channel wasn't connected." To reconnect a channel whose state is `NEEDS_RECONNECT`, connect the same account again. ## Related - [Reconnect a channel](/docs/guides/reconnect-a-channel) for the steps in the app. - [Channels](/docs/channels) for what each platform asks for. --- # Posts API: create, schedule, retry, read results URL: https://jamp.sh/docs/api/posts Updated: 2026-10-22 Create, schedule, list, edit, cancel and retry posts, and read the per-channel result of each, including the results inbox. A post is one message sent to one or more [channels](/docs/api/channels). The API calls it a **group**. The result for each channel is a **target**. This page covers the whole life of a post. For what each status means in plain words, see [Posts and results](/docs/concepts/posts-and-results). ## Create a post `POST /v1/groups` creates a post and starts sending it, or schedules it. ```bash curl https://omniposts.dev/v1/groups \ -H "Authorization: Bearer omniposts_..." \ -H "Idempotency-Key: launch-post-1" \ -H "Content-Type: application/json" \ -d '{ "body": "We shipped version 2.", "targets": [ { "channelId": "0192a3b4-0000-7000-8000-000000000001" }, { "channelId": "0192a3b4-0000-7000-8000-000000000002", "body": "A shorter note for this channel." } ], "scheduledFor": "2026-10-20T09:30:00Z", "timezone": "Europe/London" }' ``` Always send an `Idempotency-Key`. A retry with the same key and request returns the original post instead of posting again. See [Idempotency](/docs/api/idempotency). ### Request fields | Field | Type | Meaning | |---|---|---| | `body` | string | The text. Each target posts this unless it has its own `body`. Defaults to an empty string | | `mediaIds` | array of ids | Files from [Media](/docs/api/media), used by every target that has no `mediaIds` of its own | | `targets` | array | Required in practice. One entry per channel. See below. An empty list is refused | | `scheduledFor` | time | When to send, as a UTC instant. Leave it out (and leave `queue` out) to send as soon as possible | | `timezone` | string | A time zone name such as `Europe/London`. It is stored and returned with the post. The send time is `scheduledFor` | | `queue` | boolean | `true` puts the post in your next free posting time. See [Use the queue](#use-the-queue). Do not send it with `scheduledFor` | Each entry in `targets`: | Field | Type | Meaning | |---|---|---| | `channelId` | id | Required. A channel from `GET /v1/channels` | | `body` | string | Replaces the post's `body` for this channel only | | `mediaIds` | array of ids | Replaces the post's `mediaIds` for this channel. Leave it out to use the post's media. Send `[]` for no media | | `settings` | object of strings | Platform options from the provider's `uiFields`. See [Providers](/docs/api/providers) | A channel listed twice is posted to once. OmniPosts checks every target against that platform's limits, so one channel can be refused while the others go ahead. ### Settings for each connector The call is the same for every channel. Only a target's `settings` changes shape: each connector takes its own. These are the settings each connector lists in its `uiFields`; [Providers](/docs/api/providers) (`GET /v1/providers`) always has the current list. > **Tab: Bluesky.** Bluesky takes no settings. Leave `settings` out. > **Tab: Mastodon.** > > | Setting | Values | Default | > |---|---|---| > | `visibility` | `public`, `unlisted` (kept off public timelines) or `private` (followers only) | `public` | > **Tab: Telegram.** > > | Setting | Values | Default | > |---|---|---| > | `link_preview` | `"true"` shows a preview card for the first link in a text-only post | `"true"` | > | `silent` | `"true"` delivers the post without a notification sound | `"false"` | > **Tab: LinkedIn.** > > | Setting | Values | Default | > |---|---|---| > | `visibility` | `PUBLIC` (anyone on LinkedIn) or `CONNECTIONS` (your connections only) | `PUBLIC` | > **Tab: Bluesky.** > > ```json > { "channelId": "BLUESKY_CHANNEL_ID" } > ``` > **Tab: Mastodon.** > > ```json > { > "channelId": "MASTODON_CHANNEL_ID", > "settings": { "visibility": "unlisted" } > } > ``` > **Tab: Telegram.** > > ```json > { > "channelId": "TELEGRAM_CHANNEL_ID", > "settings": { "link_preview": "false", "silent": "true" } > } > ``` > **Tab: LinkedIn.** > > ```json > { > "channelId": "LINKEDIN_CHANNEL_ID", > "settings": { "visibility": "CONNECTIONS" } > } > ``` Values are strings, even for a toggle. A setting the connector does not list is refused for that channel as `setting_unknown`, and the other channels go ahead. ### The post you get back All of `201`, `207` and `200` return the same shape: the post, plus `rejections`. ```json { "id": "0192a3b4-0000-7000-8000-0000000000f1", "status": "scheduled", "body": "We shipped version 2.", "mediaIds": [], "scheduledFor": "2026-10-20T09:30:00Z", "timezone": "Europe/London", "queued": false, "createdAt": "2026-10-14T09:12:33.481516Z", "updatedAt": "2026-10-14T09:12:33.481516Z", "targets": [ { "id": "0192a3b4-0000-7000-8000-0000000000f2", "groupId": "0192a3b4-0000-7000-8000-0000000000f1", "channelId": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "status": "pending", "failure": null, "externalId": null, "externalUrl": null, "publishedAt": null, "attempt": 0, "body": null } ], "rejections": [] } ``` Post fields: | Field | Meaning | |---|---| | `status` | `draft`, `scheduled`, `publishing`, `published`, `partial`, `failed` or `cancelled`. It is worked out from the targets: `partial` means some channels posted and some did not | | `body`, `mediaIds` | As sent | | `scheduledFor` | The send time, or `null` | | `timezone` | As sent, or `null` | | `queued` | `true` if the queue chose the time. See below | | `createdAt`, `updatedAt` | Timestamps with microseconds. `updatedAt` is what you send back in `If-Match` when [editing](#edit-a-post) | | `targets` | One result per channel | Target fields: | Field | Meaning | |---|---| | `id`, `groupId`, `channelId`, `providerId` | Ids | | `status` | `pending`, `preflight`, `queued`, `publishing`, `awaiting_platform`, `unknown`, `published`, `failed` or `cancelled`. See [post statuses](/docs/reference/post-statuses) | | `failure` | `null`, or a [failure object](/docs/api/errors#the-failure-object) explaining why this channel did not post | | `externalId`, `externalUrl` | The post's id and link on the platform, once published | | `publishedAt` | When it went out | | `attempt` | How many times OmniPosts has tried | | `body` | This channel's own text if you gave one, otherwise `null` (it posts the post's `body`) | ### The status codes | Status | Meaning | |---|---| | `201` | Every channel was accepted. `rejections` is empty | | `207` | The post was created, but some channels were rejected. `rejections` says which and why. The accepted channels go ahead | | `200` | A replay of an earlier request with the same `Idempotency-Key`. The header `Idempotent-Replayed: true` is set | | `422` | Nothing was created. See below | `201` and `207` do not mean the post has been published. They mean it was accepted. Read the targets, or use [webhooks](/docs/api/webhooks), for the outcome. ### Rejections When a channel fails OmniPosts's checks at the door, it appears in `rejections` and also in `targets` as a `failed` target, so the [results inbox](#the-results-inbox) shows why it never ran. Each rejection is `{channelId, failure}`: ```json { "status": "scheduled", "targets": [ ], "rejections": [ { "channelId": "0192a3b4-0000-7000-8000-000000000002", "failure": { "class": "USER_ERROR", "code": "text_too_long", "message": "Bluesky allows 300 characters; this post has 341. Shorten it for Bluesky.", "action": "FIX_CONTENT", "retryable": true } } ] } ``` (`targets` is shortened here.) The codes you can see at this stage: | Code | Why | |---|---| | `unknown_channel` | The channel is not connected to your organization | | `needs_reconnect` | The channel must be reconnected first | | `channel_switched_off`, `provider_removed` | Posting to that platform is off, or the platform is no longer supported | | `media_missing` | A `mediaIds` entry was not found. Upload it again | | `text_required`, `text_too_long` | The text is missing or over the platform's limit | | `media_required`, `too_many_images`, `too_many_videos`, `mixed_media`, `media_format`, `media_too_large`, `alt_text_too_long` | The media breaks that platform's rules | | `setting_unknown`, `setting_invalid`, `setting_required`, `setting_locked` | A `settings` value is not allowed | | `no_targets` | You sent no channels (the only rejection with a `null` `channelId`) | Limits come from [Providers](/docs/api/providers), so you can check before you send. ### When nothing is created (422) If every channel is rejected, nothing is created and you get `422`: ```json { "error": { "code": "nothing_to_publish", "message": "Bluesky allows 300 characters; this post has 341. Shorten it for Bluesky.", "details": { "rejections": [ { "channelId": "0192a3b4-0000-7000-8000-000000000002", "failure": { "class": "USER_ERROR", "code": "text_too_long", "message": "...", "action": "FIX_CONTENT", "retryable": false } } ] } } } ``` The `Idempotency-Key` is not used up, so fix the problem and send again with the same key. Other `422` answers: | Code | Meaning | |---|---| | `plan_limit_reached` | Your plan's posts per month are used up. `details.limit` is `posts_per_month`, `details.plan` is the plan id | | `queue_empty` | `queue: true` but you have no posting times | | `queue_full` | `queue: true` but every posting time in the next 12 weeks is taken | ### Use the queue Send `"queue": true` instead of `scheduledFor` and OmniPosts picks the earliest free posting time from your weekly schedule. See [Queue](/docs/api/queue) to set that schedule. ```bash curl https://omniposts.dev/v1/groups \ -H "Authorization: Bearer omniposts_..." \ -H "Idempotency-Key: queued-post-1" \ -H "Content-Type: application/json" \ -d '{ "body": "From the queue.", "queue": true, "targets": [{ "channelId": "CHANNEL_ID" }] }' ``` The answer has `queued: true` and `scheduledFor` set to the time it chose. Sending `queue: true` together with `scheduledFor` is `400 invalid_request`. If you later set a time by hand with [edit](#edit-a-post), `queued` becomes `false` and the post leaves the queue. ## Threads On platforms that support it (a provider whose `thread` lists `maxParts`: Bluesky and Mastodon today), a post can continue as a chain of replies to itself. Give the further parts in `thread`, in order; the post's own `body` and `mediaIds` are the first part. A target can carry its own `thread` to replace the post's for that channel. ```json { "body": "Three things we learned shipping threads 🧵", "targets": [{ "channelId": "..." }], "thread": [ { "body": "1. Post the first part, then reply to it — never the other way round." }, { "body": "2. Each part gets its own stable id, so a retry can never post a part twice.", "mediaIds": ["..."] } ] } ``` Each part is checked like a post (text limit, media rules) when you create, and `thread_not_supported` or `thread_too_long` rejects the target before anything is sent. Once accepted, the first part goes out as usual and the rest follow one by one, each replying to the one before; while that happens the target's status is `awaiting_platform`. The target's `externalId` and `externalUrl` are the first part's. `threadState` says how far the thread got: ```json "threadState": { "parts": 2, "posted": [{ "externalId": "…", "externalUrl": "…" }, { "externalId": "…", "externalUrl": "…" }], "failure": null } ``` If a part fails for good — or its outcome cannot be confirmed — the thread stops there: the first part and everything in `posted` are live, `failure` says which part and why, and the target is still `published`, because it is. OmniPosts never deletes a part from the platform and never re-sends one on a guess. ## Check a post before sending it `POST /v1/groups/validate` takes exactly the body of [Create a post](#create-a-post) and tells you what would happen, without creating anything. It runs the same checks on every channel (connected, platform switched on, account healthy, access not ending before the schedule, files present, text and settings within the platform's limits) and looks at your plan's monthly posts and your posting limit. It never consumes an `Idempotency-Key` and never counts against a limit. ```bash curl https://omniposts.dev/v1/groups/validate \ -H "Authorization: Bearer omniposts_..." \ -H "Content-Type: application/json" \ -d '{ "body": "Hello", "targets": [{ "channelId": "..." }, { "channelId": "..." }] }' ``` The answer is always `200`: ```json { "ok": true, "accepted": [{ "channelId": "0192a3b4-...-0001", "providerId": "bluesky" }], "rejections": [ { "channelId": "0192a3b4-...-0002", "failure": { "class": "USER_ERROR", "code": "text_too_long", "message": "...", "action": "FIX_CONTENT", "retryable": false } } ], "problems": [] } ``` - `ok` is `true` when the real create would answer `201` or `207`: at least one channel in `accepted` and nothing in `problems`. - `rejections` is the same list the create would return. Fix the content and check again. - `problems` lists reasons the whole post would be refused: `plan_limit_reached` or `posting_limit_reached`. The verdict is a snapshot: a limit can fill up, or a channel can lose its access, between the check and the create. Treat the create's own answer as final. ## List posts `GET /v1/groups` lists posts, newest first by their time (`scheduledFor`, else `createdAt`). It is the data for a calendar. | Query | Meaning | |---|---| | `status` | Only posts with this status | | `channel` | Only posts that include this channel id | | `from`, `to` | Posts whose time is in `[from, to)`, as UTC instants | | `limit`, `cursor` | See [Pagination](/docs/api/pagination) | ```bash curl "https://omniposts.dev/v1/groups?status=scheduled&from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z" \ -H "Authorization: Bearer omniposts_..." ``` The answer is `{ "items": [post, ...], "nextCursor": ... }`. Each item is a post with its targets. Items have no `rejections` field. ## Get a post `GET /v1/groups/{id}` returns one post with all its targets. An id from another organization is `404`. ## Edit a post `PATCH /v1/groups/{id}` changes the text and/or the time of a post that has not started sending. Send only what you want to change. ```bash curl -X PATCH https://omniposts.dev/v1/groups/0192a3b4-0000-7000-8000-0000000000f1 \ -H "Authorization: Bearer omniposts_..." \ -H 'If-Match: "2026-10-14T09:12:33.481516Z"' \ -H "Content-Type: application/json" \ -d '{ "body": "We shipped version 2.0.", "scheduledFor": "2026-10-20T10:00:00Z" }' ``` | Field | Meaning | |---|---| | `body` | New text for the post. Channels with their own text keep it | | `scheduledFor` | New send time. Setting it takes a queued post out of the queue (`queued` becomes `false`) and frees its slot | You cannot change channels or media. Cancel the post and create a new one for that. ### If-Match and stale_write `If-Match` protects against overwriting a change made elsewhere. Set it to the post's `updatedAt`, in quotes. If the post changed since then, you get `409 stale_write` and `details.current` holds the post as it is now. ```json { "error": { "code": "stale_write", "message": "This post changed since you opened it. Reload to see the latest.", "details": { "current": { "id": "...", "updatedAt": "2026-10-14T09:15:02.118204Z" } } } } ``` **Send `updatedAt` back exactly as you received it.** It has microsecond precision. If you read it into a JavaScript `Date` and write it out again it loses the last three digits and will never match. Keep it as a string. A value that is not a time gives `400 invalid_request`. The header is optional; without it no check is made. ### When edits are refused | Status and code | Meaning | |---|---| | `409 already_dispatched` | A channel has already started sending, so the post can no longer be edited | | `422 invalid_edit` | The new text fails a channel's rules. The message says how | | `404 not_found` | No such post | ## Cancel a post `POST /v1/groups/{id}/cancel` cancels every channel that has not started sending. It has no body. ```json { "cancelled": ["0192a3b4-0000-7000-8000-000000000001"], "refused": [ { "channelId": "0192a3b4-0000-7000-8000-000000000002", "status": "published", "reason": "already_finished" } ] } ``` - `cancelled` lists channel ids that were stopped. Their targets become `cancelled`. - `refused` lists the others, with their current `status` and a `reason`: `already_dispatched` (sending has started) or `already_finished` (it already ended, for example `published` or `failed`). Cancel wins only before a channel is picked up for sending. Once one has started, OmniPosts cannot call it back. A cancelled queued post frees its queue slot. ## Retry failed channels `POST /v1/groups/{id}/retry` puts the failed channels of a post back in line to be sent. It has no body and returns the post. Only a target that is `failed` and whose `failure.retryable` is `true` is retried. A result OmniPosts could not confirm (`UNKNOWN` class) is never retried, because the post might already be on the platform. Targets that are not eligible are left alone. Look at the returned `targets` to see which ones were re-queued. Retrying helps for problems that have gone away: a platform outage, or a rate limit. It does not fix a problem with the post itself. See [Errors](/docs/api/errors#the-failure-object). ## The results inbox `GET /v1/targets` lists results one channel at a time, across all posts. Use it to see what happened to everything you sent, or what needs attention. | Query | Meaning | |---|---| | `status` | Only targets with this status, for example `failed` | | `channel` | Only this channel | | `from`, `to` | Targets that last changed in `[from, to)` | | `limit`, `cursor` | See [Pagination](/docs/api/pagination) | Newest first by sent time: `publishedAt`, else the post's `scheduledFor`, else creation. ```bash curl "https://omniposts.dev/v1/targets?status=failed&limit=20" \ -H "Authorization: Bearer omniposts_..." ``` ```json { "items": [ { "id": "0192a3b4-0000-7000-8000-0000000000f3", "groupId": "0192a3b4-0000-7000-8000-0000000000f1", "channelId": "0192a3b4-0000-7000-8000-000000000002", "providerId": "bluesky", "status": "failed", "failure": { "class": "RETRYABLE", "code": "platform_error", "message": "...", "action": "RETRY", "retryable": true }, "externalId": null, "externalUrl": null, "publishedAt": null, "attempt": 3, "body": "A shorter note for this channel.", "scheduledFor": "2026-10-20T09:30:00Z" } ], "nextCursor": null } ``` An inbox item is a target plus `scheduledFor` from its post. One difference from a target inside a post: here `body` is the text this channel posts, which is its own text if it has one, otherwise the post's. --- # Drafts API: autosave, then publish or schedule URL: https://jamp.sh/docs/api/drafts Updated: 2026-10-22 Save a post as you write it, autosave changes safely, then publish or schedule it in one call. A draft is text you have started but not sent. The API calls it an **idea**, and the paths say `/v1/ideas`. The OmniPosts composer saves to it as you type. You can use it the same way, or to hand a draft from one tool to another. A draft has a `status`: `draft` (you can still change it), `promoted` (it was sent as a post), or `discarded`. ```json { "id": "0192a3b4-0000-7000-8000-0000000000d1", "body": "Draft of the launch post", "mediaIds": [], "status": "draft", "targetDrafts": { "0192a3b4-0000-7000-8000-000000000001": { "body": "Shorter version for this channel" } }, "createdAt": "2026-10-14T09:00:00.120331Z", "updatedAt": "2026-10-14T09:00:00.120331Z" } ``` `targetDrafts` is a map from channel id to that channel's tweaks. Each tweak can have `body`, `mediaIds` and `settings`, the same as a target in [Posts](/docs/api/posts#create-a-post). ## Create a draft `POST /v1/ideas` takes `body`, `mediaIds` and `targetDrafts`, all optional. The answer is `201` with the draft. ```bash curl https://omniposts.dev/v1/ideas \ -H "Authorization: Bearer omniposts_..." \ -H "Content-Type: application/json" \ -d '{ "body": "Draft of the launch post" }' ``` ## List and get drafts `GET /v1/ideas` lists drafts, newest first. Filter with `status=draft` or `status=promoted`. Discarded drafts are never listed. It is paginated: see [Pagination](/docs/api/pagination). `GET /v1/ideas/{id}` returns one draft. An id from another organization is `404`. ## Autosave a draft `PATCH /v1/ideas/{id}` saves changes. Send only the fields you want to change: a field you leave out stays as it was. A field you send replaces the old value completely (`mediaIds` and `targetDrafts` are replaced as a whole, not merged). Send `If-Match` with the draft's `updatedAt` so two tabs cannot overwrite each other: ```bash curl -X PATCH https://omniposts.dev/v1/ideas/0192a3b4-0000-7000-8000-0000000000d1 \ -H "Authorization: Bearer omniposts_..." \ -H 'If-Match: "2026-10-14T09:00:00.120331Z"' \ -H "Content-Type: application/json" \ -d '{ "body": "Draft of the launch post, second pass" }' ``` The answer is `200` with the draft, including its new `updatedAt`. Use that value for your next save. - **Send `updatedAt` back exactly as received.** It has microsecond precision. Keep it as a string; do not round-trip it through a date type that keeps only milliseconds. - If the draft changed since, you get `409 stale_write` and `details.current` holds the draft as it is now. Merge your change into it and save again. - If the draft is no longer a `draft`, you get `409 not_editable`, with `details.current`. - `If-Match` is optional. Without it the save always wins. Autosave has its own, lower rate limit, 30 saves a minute for your organization. See [Rate limits](/docs/api/rate-limits). Save when the person pauses, not on every key press. ## Publish a draft `POST /v1/ideas/{id}/promote` turns a draft into a post. It behaves exactly like [`POST /v1/groups`](/docs/api/posts#create-a-post): the same `201`, `207`, `200` and `422` answers, the same `rejections`, and the same `Idempotency-Key` header. The text and media come from the draft. | Field | Meaning | |---|---| | `targets` | The channels to post to, as in a normal post. A channel with saved tweaks in the draft's `targetDrafts` uses them, unless you set that field here | | `scheduledFor`, `timezone` | When to send, as in a normal post | | `queue` | `true` to take the next free posting time. Not with `scheduledFor` | ```bash curl https://omniposts.dev/v1/ideas/0192a3b4-0000-7000-8000-0000000000d1/promote \ -H "Authorization: Bearer omniposts_..." \ -H "Idempotency-Key: promote-launch-draft" \ -H "Content-Type: application/json" \ -d '{ "targets": [{ "channelId": "0192a3b4-0000-7000-8000-000000000001" }], "queue": true }' ``` A draft is published once: - Send an `Idempotency-Key`. If your request times out, send the same call again with the same key and you get the same post back with `200` and `Idempotent-Replayed: true`. See [Idempotency](/docs/api/idempotency). - Without a matching key, promoting a draft that is already published (or discarded) gives `409 already_promoted`. Nothing is sent twice. - If nothing can be published (`422 nothing_to_publish`), the draft stays a draft, so you can fix it and try again. - After a successful promote the draft's `status` is `promoted`, and it can no longer be changed. ## Discard a draft `DELETE /v1/ideas/{id}` discards a draft. The answer is `204`, and the draft no longer appears in the list. Discarding twice is not an error. A draft that was already published cannot be discarded: you get `409 not_editable`, because it is part of the record of what was sent. --- # Queue API: weekly times and next free slots URL: https://jamp.sh/docs/api/queue Updated: 2026-10-22 Set your weekly posting times, read the next free ones, and send a post to the queue with queue true. The queue is a weekly list of posting times, such as "Monday 09:30" and "Thursday 18:00", in one time zone. A post sent to the queue takes the next time in the list that is still free. For the idea behind it, see [Calendar and queue](/docs/concepts/calendar-and-queue). ## Read the queue `GET /v1/queue` returns your schedule and the next free times. ```bash curl https://omniposts.dev/v1/queue \ -H "Authorization: Bearer omniposts_..." ``` ```json { "timezone": "Asia/Kolkata", "slots": [ { "day": "mon", "time": "09:30" }, { "day": "thu", "time": "18:00" } ], "upcoming": [ "2026-10-15T12:30:00Z", "2026-10-19T04:00:00Z" ] } ``` | Field | Meaning | |---|---| | `timezone` | An IANA time zone name, such as `Europe/London`. A new organization has no times and the zone is `UTC` until you set one | | `slots` | The weekly times. `day` is `mon`, `tue`, `wed`, `thu`, `fri`, `sat` or `sun`. `time` is local time as `HH:MM` in 24-hour time | | `upcoming` | The next free times as UTC instants, earliest first. At most 5. A time already taken by a queued post is skipped | ## Set the queue `PUT /v1/queue` replaces the whole schedule. Send both fields every time; the list you send is the new schedule. ```bash curl -X PUT https://omniposts.dev/v1/queue \ -H "Authorization: Bearer omniposts_..." \ -H "Content-Type: application/json" \ -d '{ "timezone": "Asia/Kolkata", "slots": [ { "day": "mon", "time": "09:30" }, { "day": "thu", "time": "18:00" } ] }' ``` The answer is `200` with the schedule as stored, in the same shape as `GET /v1/queue`. - `slots` is required. Send `[]` to clear the schedule. Leaving it out is `400 invalid_request`, so that a mistake cannot empty your queue. - `timezone` must be a valid IANA name. An unknown or empty one is `400 invalid_request`. - Each slot needs a valid `day` and a `time` like `09:30`. A bad value is `400 invalid_request` with a message that says what is wrong. - A schedule holds at most 56 times. Duplicate times count once. - A key can set the queue. You do not need a signed-in person. Changing the schedule does not move posts already in the queue. They keep the time they were given. ### Time zones and daylight saving Slots are local times, so a `09:30` slot stays 09:30 on the clock when your clocks change. A time that does not exist on the day clocks go forward (for example 02:30 when 02:00 jumps to 03:00) moves later by the length of the gap. When clocks go back and a time happens twice, the first one is used. ## Send a post to the queue Create a post with `"queue": true` instead of `scheduledFor`. See [Posts](/docs/api/posts#use-the-queue) for the full request. ```bash curl https://omniposts.dev/v1/groups \ -H "Authorization: Bearer omniposts_..." \ -H "Idempotency-Key: queued-post-2" \ -H "Content-Type: application/json" \ -d '{ "body": "Next slot, please.", "queue": true, "targets": [{ "channelId": "CHANNEL_ID" }] }' ``` How the slot is picked: - OmniPosts takes the earliest slot that is at least one minute from now and that no other live queued post holds. - It looks up to 12 weeks ahead. - One post holds one slot. Two queued posts never share a time. - A post holds its slot until it can no longer be sent. Cancelling a post frees its slot. So does a post that failed before its time because every channel was cancelled or rejected. The response has `queued: true` and `scheduledFor` set to the slot. You can also queue a draft: `POST /v1/ideas/{id}/promote` accepts `queue: true` too. ### Errors | Status and code | Meaning | |---|---| | `422 queue_empty` | You have no posting times. Set some with `PUT /v1/queue`, or send a `scheduledFor` | | `422 queue_full` | Every posting time in the next 12 weeks is taken. Add more times or send a `scheduledFor` | | `400 invalid_request` | `queue: true` together with `scheduledFor` | ## Setting a time by hand leaves the queue If you [edit](/docs/api/posts#edit-a-post) a queued post and set a new `scheduledFor`, the post leaves the queue: `queued` becomes `false` and its slot is free for another post. The time you set is used exactly as given and does not have to match any slot. ## Related - [Use the queue](/docs/guides/use-the-queue) for the same thing in the app. --- # Media API: upload images and video, check limits URL: https://jamp.sh/docs/api/media Updated: 2026-10-22 Upload images and video, check them against each platform before you post, and edit an image description. Upload a file once, then attach it to posts by its id in `mediaIds`. OmniPosts reads the file, records its size and dimensions, and tells you which platforms would object to it before you send anything. ## Upload a file `POST /v1/media` takes `multipart/form-data` with these parts: | Part | Meaning | |---|---| | `file` | Required. The image or video | | `altText` | Optional. A description of the image for people who cannot see it | ```bash curl https://omniposts.dev/v1/media \ -H "Authorization: Bearer omniposts_..." \ -F "file=@bowls.jpg" \ -F "altText=A stack of six hand-thrown bowls on a wooden shelf" ``` The answer is `201`: ```json { "id": "0192a3b4-0000-7000-8000-0000000000b1", "mime": "image/jpeg", "bytes": 482113, "width": 1600, "height": 1200, "altText": "A stack of six hand-thrown bowls on a wooden shelf", "checks": { "bluesky": [], "mastodon": [] } } ``` (The platform ids in `checks` depend on which platforms are open for posting, and change over time.) ### Limits - Types: JPEG, PNG, GIF, WebP images, and MP4 or QuickTime video. OmniPosts reads the file's own bytes to decide the type. The `Content-Type` you send is ignored. - Size: up to 50 MB (50,000,000 bytes). An image can be at most 50 megapixels. - A platform's own limits are stricter and differ. They show up in `checks`, and in [Providers](/docs/api/providers). - Uploads share a limit of 10 a minute for your organization. See [Rate limits](/docs/api/rate-limits). ### Errors | Status and code | Meaning | |---|---| | `400 invalid_request` | The request has no `file` part | | `422 media_rejected` | The file is empty, over 50 MB, over 50 megapixels, not a supported type, or an image that cannot be read. The `message` says which | | `415 unsupported_media_type` | The request was not `multipart/form-data` | ## Upload from a link If the file is already on the web, send `application/json` to the same endpoint and OmniPosts downloads it: ```bash curl https://omniposts.dev/v1/media \ -H "Authorization: Bearer omniposts_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://cdn.example.com/bowls.jpg", "altText": "Six bowls on a shelf" }' ``` The answer is the same `201` media object. The link must be a public `https` address (port 443 or 8443); private, internal and loopback addresses are refused, and so is a redirect to one. Up to three redirects are followed. The 50 MB limit and the type rules above apply to what is downloaded, not to what the link claims to be. | Status and code | Meaning | |---|---| | `400 invalid_request` | The body has no `url` | | `422 media_rejected` | The link is not a public https address, could not be downloaded, answered an error, redirected too often, is over 50 MB, or is not a supported type. The `message` says which | ## List your media `GET /v1/media` returns your files, newest first, as `{ "items": [...], "nextCursor": ... }`. Each item is the media object above. Pass `limit` (default 25, at most 100) and the previous page's `nextCursor` as `cursor`. See [Pagination](/docs/api/pagination). ## Delete a file `DELETE /v1/media/{id}` removes the file. The answer is `204`. - `404 not_found` for an id that is not yours. - `409 media_in_use` while a draft, or a post that has not finished (anything but published or cancelled), still names the file. Remove it from the draft, or cancel the post or wait for it to finish, then delete again. A published post is not affected by a delete: the platform has its own copy. ## Preflight verdicts: `checks` `checks` is a map from provider id to a list of objections that platform has to this file. It has an entry for each platform that is open for posting. An **empty list means the platform is fine with the file**. A non-empty list means a post with this file to that platform would be refused for that channel. Each objection is `{code, message}`: ```json { "code": "media_too_large", "message": "A file is 2.1 MB; Bluesky accepts up to 2.0 MB." } ``` Codes you can see include `media_format` (wrong type), `media_too_large` (file over that platform's size limit), `too_many_images`, `too_many_videos` and `alt_text_too_long`. Read `message` to show a person; branch on `code`. The upload itself succeeds either way. `checks` is advice: use it to warn a person, or to pick the channels the file can go to. ## Edit alt text `PATCH /v1/media/{id}` changes the description. The body is `{ "altText": ... }`. The field is required, and `null` or a blank string clears the description. ```bash curl -X PATCH https://omniposts.dev/v1/media/0192a3b4-0000-7000-8000-0000000000b1 \ -H "Authorization: Bearer omniposts_..." \ -H "Content-Type: application/json" \ -d '{ "altText": "Six bowls on a shelf, glazed in blue" }' ``` The answer is `200` with the media object, and `checks` run again against the new description. - `altText` can be at most 2000 characters, the longest any platform allows. Longer is `400 invalid_request`. Each platform's own, shorter limit still applies when a post is checked, and shows in `checks`. - `404 not_found` for an id that is not yours. - `409 already_sent`: a post that uses this file has already started sending, so the description is settled. Upload the file again to use a new description. ## Download a file `GET /v1/media/{id}/content` returns the file's bytes with its type. Only your own organization can read it. ## Use media in a post Put the ids in `mediaIds` on the post to use them on every channel, or on one target to use them on that channel only. A target's `mediaIds` replaces the post's, so list the files again. See [Posts](/docs/api/posts#create-a-post). A post cannot mix images and video. --- # Reports API: delivery and engagement numbers URL: https://jamp.sh/docs/api/reports Updated: 2026-10-22 Read delivery reports from OmniPosts and engagement numbers from platforms, and how Not available always comes with a reason. There are two kinds of report: - **Delivery** is about OmniPosts's own work: what you sent, what posted, what failed and how on time it was. It is computed from OmniPosts's records, so it is always available. - **Metrics** are counters the platforms report, such as reactions and reposts. A platform may not share a number, and OmniPosts will not invent one. See [Not available, and why](#not-available-and-why). ## Common query parameters | Parameter | Meaning | |---|---| | `from`, `to` | The window, as UTC instants. The default is the last 30 days. `from` must be before `to`, and the window can be at most 400 days. Otherwise `400 invalid_request` | | `channel` | Only this channel id. An id that is not in your organization is `404` on the audience report | All of these are `GET` requests and need no special permission, so a key works. ## Delivery report `GET /v1/reports/delivery` counts results in the window and shows how reliable each channel was. ```bash curl "https://omniposts.dev/v1/reports/delivery?from=2026-09-14T00:00:00Z&to=2026-10-14T00:00:00Z" \ -H "Authorization: Bearer omniposts_..." ``` ```json { "window": { "from": "2026-09-14T00:00:00Z", "to": "2026-10-14T00:00:00Z" }, "totals": { "groups": 12, "targets": 30, "published": 27, "failed": 2, "cancelled": 0, "scheduled": 1 }, "byChannel": [ { "channel": "0192a3b4-0000-7000-8000-000000000001", "provider": "bluesky", "published": 14, "failed": 1, "successRate": 0.9333, "failuresByClass": { "RECONNECT": 0, "RETRYABLE": 1, "UNKNOWN": 0, "USER_ERROR": 0, "PLATFORM_BLOCKED": 0 }, "scheduleAdherence": { "p50Seconds": 2, "p95Seconds": 41, "lateOver5min": 0, "missed": 0 }, "cadence": { "postsPerWeek": 3.5 } } ], "mediaPreflight": { "attempted": 6, "rejected": 1, "rate": 0.1667 } } ``` - A result is in the window when its post's `scheduledFor` (or its creation time, for a post sent at once) falls in `[from, to)`. Media is counted by upload time. `channel` filters results, not media. - `scheduled` counts results that have not started sending yet. - `successRate` is `published / (published + failed)`. `failuresByClass` counts failed results by their [failure class](/docs/api/errors#the-failure-object). - `scheduleAdherence` covers published results that had a time: how many seconds after the scheduled time they went out (`p50Seconds`, `p95Seconds`), how many were more than 5 minutes late, and how many scheduled results are over 5 minutes past their time and neither published nor cancelled (`missed`). - `mediaPreflight` counts uploads checked against at least one platform and how many a platform objected to. - A ratio or percentile with nothing to measure is `null`, never `0`. ## Metrics summary `GET /v1/metrics/summary` has one row per published result in the window, newest first. It takes the common parameters plus `limit` and `cursor` ([Pagination](/docs/api/pagination)) and `metrics`. | `metrics` | Rows carry | |---|---| | `default` (the default) | Five metrics, in this order: `reactions`, `reposts`, `comments`, `quotes`, `zaps` | | `extended` | All twelve post metrics: `reactions`, `comments`, `reposts`, `quotes`, `shares`, `saves`, `zaps`, `views`, `impressions`, `reach`, `link_clicks`, `profile_visits` | Any other value is `400 invalid_request`. ```json { "items": [ { "targetId": "0192a3b4-0000-7000-8000-0000000000f2", "channelId": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "publishedAt": "2026-10-12T09:30:04Z", "externalUrl": "https://example.invalid/post/1", "groupId": "0192a3b4-0000-7000-8000-0000000000f1", "text": "We shipped version 2.", "scope": "appview_global", "metrics": [ { "metric": "reactions", "value": 12, "availability": "observed", "reason": null, "reasonKind": null, "observedAt": "2026-10-14T08:00:00Z" }, { "metric": "zaps", "value": null, "availability": "not_supported", "reason": "...", "reasonKind": "platform_hides", "observedAt": null } ] } ], "nextCursor": null } ``` (`metrics` is shortened. A real row lists every metric for its mode.) - `groupId` is the post. Rows with the same `groupId` are one post on several channels. - `text` is what the post said on that channel: its own text, else the post's. Spaces are collapsed and it is cut to 140 characters, with `…` counted in the 140. It is `null` for a post with no text. - `scope` says where the number was counted: `appview_global`, `home_instance`, `relay_set`, `channel_admin_push` or `first_party`. It is a footnote so you can say honestly what a number covers. ## Metrics for one result `GET /v1/metrics/post/{targetId}` returns the numbers for one published result over time. The id is a target id. `GET /v1/metrics/post/{targetId}/extended` does the same for all twelve metrics. An id that is not yours is `404`. ```json { "firstSixHours": [ { "metric": "reactions", "points": [ { "at": "2026-10-12T10:30:04Z", "value": 3 } ], "availability": "observed", "reason": null, "reasonKind": null } ], "lifetime": [ ] } ``` - `firstSixHours` holds the hourly readings from the first six hours after posting. Platforms keep no such history, so this is sampled by OmniPosts. `lifetime` holds every reading: hourly for the first 48 hours, then daily. - Values are cumulative totals at that moment. Subtract neighbouring points to get the change. - The default call lists the five default metrics. The `/extended` call lists all twelve. ## Performance report `GET /v1/reports/performance` rolls the numbers up per channel and lists the top posts. It always includes all twelve metrics. ```json { "window": { "from": "2026-09-14T00:00:00Z", "to": "2026-10-14T00:00:00Z" }, "channels": [ { "channel": "0192a3b4-0000-7000-8000-000000000001", "provider": "bluesky", "posts": 15, "metrics": [ { "metric": "reactions", "total": 120, "postsObserved": 15, "availability": "observed", "reason": null, "reasonKind": null } ], "engagement": { "interactions": 140, "denominator": "impressions", "denominatorTotal": 3000, "rate": 0.0467, "posts": 10 } } ], "topPosts": [ { "targetId": "...", "channelId": "...", "providerId": "bluesky", "publishedAt": "2026-10-12T09:30:04Z", "externalUrl": null, "groupId": "...", "text": "We shipped version 2.", "interactions": 42, "denominator": "impressions", "denominatorValue": 900 } ] } ``` - `total` is the sum, over the posts that have a reading, of each post's latest value. `postsObserved` says how many posts that was. - `interactions` is reactions + comments + reposts + quotes + shares + saves. - `rate` is `interactions` divided by `denominator`, which is impressions if the platform has them, otherwise views. It is worked out only over the `engagement.posts` posts that carry the denominator, so the two are never mixed. With no denominator, `rate` is `null`. - `topPosts` has up to five posts by interactions, newest first on ties. ## Audience report `GET /v1/reports/audience` returns a daily history of each channel's account numbers. Platforms do not keep this history, so OmniPosts samples it once a day per channel and this is the only record. ```json { "window": { "from": "2026-09-14T00:00:00Z", "to": "2026-10-14T00:00:00Z" }, "channels": [ { "channel": "0192a3b4-0000-7000-8000-000000000001", "provider": "bluesky", "metrics": [ { "metric": "followers", "points": [ { "at": "2026-09-14T00:00:00Z", "value": 410 }, { "at": "2026-10-13T00:00:00Z", "value": 452 } ], "first": 410, "last": 452, "change": 42, "availability": "observed", "reason": null, "reasonKind": null } ] } ] } ``` Each channel lists four metrics: `followers`, `following`, `posts` and `likes_received`. `change` is `last` minus `first`, or `null` with fewer than two points. Without `channel`, the report covers connected channels and any disconnected ones that left data in the window. ## Not available, and why OmniPosts never shows `0` for a number it does not have. Every metric has an `availability`: | `availability` | Meaning | `value` / `total` | |---|---|---| | `observed` | OmniPosts has a reading | The latest value, with `observedAt` | | `not_yet_collected` | A reading is expected but not in yet. Show "collecting" | `null` | | `not_supported` | The platform does not report it, or OmniPosts has not confirmed it is reliable | `null`. **Show "Not available" and the `reason`** | When a metric is `not_supported`, `reason` is a sentence for a person and `reasonKind` is a machine-readable kind, so you can group or style these cells without reading the sentence. `reasonKind` is `null` when the number is available or still being collected. | `reasonKind` | Meaning | |---|---| | `platform_hides` | The platform does not share this number with apps | | `not_collected` | OmniPosts does not collect this number yet | | `unverified` | Not yet confirmed as reliable. Every supported counter stays here while that platform's metrics switch is off | | `collection_stopped` | Collection ended for this post | | `window_closed` | The platform shares it only for a limited time, and that time passed. The last value seen is kept | | `private_post` | Not reported for private posts | | `reconnect` | Reconnect the account and allow statistics | | `paid_plan` | Only on the platform's paid plan | | `business_only` | Only for business accounts connected through the platform's business programme | [Providers](/docs/api/providers) shows the same `supported`, `reason` and `reasonKind` per platform and per metric, in `analytics` (post metrics) and `accountAnalytics` (the audience metrics), before any post exists. For the idea behind this, see [Honest numbers](/docs/concepts/honest-numbers). --- # Signed webhooks: what happened to a post URL: https://jamp.sh/docs/api/webhooks Updated: 2026-10-22 Get a signed HTTPS call when a post publishes or fails, or a channel needs attention, and verify the signature in your code. A webhook is a URL on your server that OmniPosts calls when something happens. Use it instead of polling for results. Each call carries one event, and is signed so you can prove it came from OmniPosts. ## Create a webhook `POST /v1/webhooks` registers a URL and the events you want. ```bash curl https://omniposts.dev/v1/webhooks \ -H "Authorization: Bearer omniposts_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/omniposts", "events": ["post.published", "post.failed"] }' ``` The answer is `201`: ```json { "id": "0192a3b4-0000-7000-8000-0000000000e1", "url": "https://hooks.example.com/omniposts", "events": ["post.published", "post.failed"], "active": true, "consecutiveFailures": 0, "createdAt": "2026-10-14T09:00:00Z", "disabledAt": null, "secret": "whsec_..." } ``` > **Warning.** `secret` is shown once, in this response. Store it now. If you lose it, delete the webhook and > create a new one. Your URL must pass these checks, or you get `422 invalid_webhook` with one sentence saying why: - It uses `https://`, with no user name or password, on port 443 or 8443. - The host is a public name or address that resolves. Not allowed: `localhost`, names with a single label, names ending in `.local`, `.localhost` or `.internal`, IP addresses in private, loopback, link-local or similar ranges, and names that resolve to such addresses. - `events` is not empty and only uses the names below. OmniPosts checks the URL again before every delivery, in case a name was later pointed at a private address. ## List and delete `GET /v1/webhooks` lists your webhooks, with the same fields as above but never the secret. `DELETE /v1/webhooks/{id}` removes a webhook and its delivery log. The answer is `204`. An id that is not yours is `404`. ## Events | Event | When | |---|---| | `post.published` | A channel's result reached `published` | | `post.failed` | A channel's result ended as `failed` | | `token.expired` | A channel needs to be reconnected | | `channel.disconnected` | A channel was disconnected | A webhook receives only the events you chose, from your own organization. ## The payload Every delivery is a `POST` with a JSON body of the same shape: ```json { "id": "0192a3b4-0000-7000-8000-0000000000e9", "type": "post.published", "createdAt": "2026-10-20T09:30:05Z", "data": { "targetId": "0192a3b4-0000-7000-8000-0000000000f2", "groupId": "0192a3b4-0000-7000-8000-0000000000f1", "channelId": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "status": "published", "externalUrl": "https://example.invalid/post/1", "publishedAt": "2026-10-20T09:30:04Z", "failure": null } } ``` `id` identifies this delivery and does not change across retries. `data` for `post.failed` has `status: "failed"` and a [failure object](/docs/api/errors#the-failure-object) in `failure`. `token.expired` and `channel.disconnected` carry the channel instead: ```json { "id": "0192a3b4-0000-7000-8000-0000000000ea", "type": "token.expired", "createdAt": "2026-10-21T07:00:00Z", "data": { "channelId": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "reason": "..." } } ``` `reason` appears on `token.expired` only. Payloads never contain tokens or raw platform responses. ## How a delivery is signed OmniPosts follows the Standard Webhooks scheme. Each request has these headers: | Header | Value | |---|---| | `webhook-id` | The delivery id. Same as `id` in the body, and the same on every retry | | `webhook-timestamp` | Unix time in seconds when this attempt was sent. It is new on each attempt | | `webhook-signature` | `v1,` followed by a base64 signature | The signature is an HMAC-SHA256 over the string `{webhook-id}.{webhook-timestamp}.{body}`, where `{body}` is the request body exactly as received. The key is the **base64-decoded** part of your secret after the `whsec_` prefix, not the secret text itself. ## Verify the signature Do three things on every request, and reject it if any fails: 1. Check `webhook-timestamp` is recent (five minutes is a good window). This stops an old request being replayed. 2. Compute the signature from the **raw bytes** of the body. Do not parse and re-serialise the JSON first: the signature covers the exact bytes OmniPosts sent. 3. Compare in constant time. A Node.js version for Express. `express.raw` keeps the body as a `Buffer`: ```js import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.OMNIPOSTS_WEBHOOK_SECRET; // "whsec_..." const TOLERANCE_SECONDS = 300; function verify(secret, headers, rawBody) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) return false; const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]); const expected = crypto.createHmac("sha256", key).update(signed).digest(); // The header may hold several space-separated signatures, each as "v1,". for (const part of signatures.split(" ")) { const [version, value] = part.split(","); if (version !== "v1" || !value) continue; const given = Buffer.from(value, "base64"); if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) { return true; } } return false; } const app = express(); app.post("/omniposts", express.raw({ type: "application/json" }), (req, res) => { if (!verify(SECRET, req.headers, req.body)) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); // Handle event.type and event.data. Use event.id to ignore a repeat. res.sendStatus(204); }); app.listen(3000); ``` The same in Python (standard library only). `body` must be the raw `bytes` your framework gives you: ```python import base64, hashlib, hmac, time def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not (msg_id and timestamp and signatures): return False try: if abs(time.time() - int(timestamp)) > tolerance: return False except ValueError: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = hmac.new(key, signed, hashlib.sha256).digest() for part in signatures.split(" "): version, _, value = part.partition(",") if version != "v1" or not value: continue try: given = base64.b64decode(value) except ValueError: continue if hmac.compare_digest(given, expected): return True return False ``` Answer with any `2xx` status as soon as you have stored the event, and do slow work afterwards. Header names are case-insensitive: in some frameworks they arrive in lower case, in others as `Webhook-Id`. ## Retries and disabling - OmniPosts waits up to 10 seconds for your answer and does not follow redirects. Any `2xx` is a success. - Anything else (another status, a timeout, a redirect, a connection error) is a failed attempt, and OmniPosts tries again after a growing delay: 30 seconds, 2 minutes, 8 minutes, 32 minutes, then every 2 hours. - There are 8 attempts in all. After the eighth the delivery's status is `failed` and OmniPosts stops. - OmniPosts counts failed attempts in a row across all deliveries to a webhook. A success resets the count to zero. After **50** in a row the webhook is switched off: `active` becomes `false` and `disabledAt` is set. A switched off webhook gets no more events. Fix your endpoint, then delete the webhook and create a new one. - Because of retries, you can get the same event more than once. Keep the `id` of events you have handled and ignore repeats. ## See what was delivered `GET /v1/webhooks/{id}/deliveries` lists the delivery log for one webhook, newest first. It is paginated: see [Pagination](/docs/api/pagination). ```json { "items": [ { "id": "0192a3b4-0000-7000-8000-0000000000e9", "event": "post.published", "status": "delivered", "responseStatus": 204, "attempt": 1, "createdAt": "2026-10-20T09:30:05Z" } ], "nextCursor": null } ``` `status` is `pending` (still trying), `delivered` or `failed`. `responseStatus` is your server's last answer, or `null` if there was none (a timeout, for example). `attempt` counts tries so far. The delivery `id` matches the `webhook-id` header and the `id` in the payload, so you can find a delivery from your own logs. --- # Account API: keys, plan, export and deletion URL: https://jamp.sh/docs/api/account Updated: 2026-10-22 Find out who a key acts for, manage API keys and your plan, and export or delete your data. This page covers your account and organization. Most of the actions here need a signed-in person, not an API key. Each section says which. The full list is in [Authentication](/docs/api/authentication#person-only-actions). ## Who am I `GET /v1/me` returns the person and the organization the call acts for. ```bash curl https://omniposts.dev/v1/me \ -H "Authorization: Bearer omniposts_..." ``` ```json { "user": { "id": "0192a3b4-0000-7000-8000-000000000a01", "email": "ada@example.com", "name": "Ada" }, "organization": { "id": "0192a3b4-0000-7000-8000-000000000b01", "name": "Ada Co" }, "role": "owner" } ``` With an API key, `user` and `role` are `null`: a key belongs to the organization, not to a person. `name` can be `null`. `organization` is the current workspace and `role` is your role in it (`owner` or `member`). See [Workspaces and members](/docs/concepts/workspaces). ## API keys - `GET /v1/api-keys` lists the organization's keys. - `POST /v1/api-keys` creates one. The secret `key` is in the response once. - `DELETE /v1/api-keys/{id}` revokes one. Creating and revoking need a signed-in person. All three, with examples, are in [Authentication](/docs/api/authentication#api-keys). ## Your plan `GET /v1/plan` shows your plan, what you have used, and every plan's limits. For the numbers and the plan descriptions see [Plans](/docs/concepts/plans) and [pricing](/pricing). ```json { "billing": "early_access", "plan": { "id": "free", "name": "Free" }, "usage": { "channels": 2, "postsThisMonth": 7 }, "plans": [] } ``` (`plan` is abridged here, and `plans` is empty. Read the live response for the full objects and the real limits.) | Field | Meaning | |---|---| | `billing` | `early_access` while there is no billing | | `plan` | Your plan: `id` (`free`, `creator` or `pro`), `name`, `summary`, `channels` (connected channels at once) and `postsPerMonth` (posts created in a UTC calendar month, or `null` for no limit) | | `usage` | `channels` connected now and `postsThisMonth` created so far | | `plans` | Every plan in the same shape | `usage.postsThisMonth` counts a post, not a channel: one post to several channels is one. A cancelled post does not count. When a limit is reached, connecting a channel or creating a post is refused with `422 plan_limit_reached`. `details.limit` is `channels` or `posts_per_month`, and `details.plan` is your plan id. Lowering a plan never disconnects a channel or cancels a scheduled post. It only refuses new ones above the limit. `PUT /v1/plan` with `{ "plan": "free" }` changes the plan. It needs a signed-in person who is an owner of the organization. A key or a non-owner gets `403 forbidden`, and a plan id that does not exist is `400 invalid_request`. > **Beta.** Switching is limited to plans that are open. **Free** is open. **Creator** and **Pro** are in > beta. To be told when they open, join the [waitlist](/pricing#waitlist). ## Consents `GET /v1/settings/consents` lists the notices you accepted, newest first. It needs a signed-in person. ```json { "items": [ { "purpose": "terms", "noticeVersion": "...", "grantedAt": "2026-10-01T08:00:00Z", "withdrawnAt": null } ] } ``` ## Export your data `GET /v1/settings/export` downloads everything in your organization as one JSON file. It needs a signed-in person. ```bash curl https://omniposts.dev/v1/settings/export \ -H "Cookie: nsid=..." \ -o omniposts-export.json ``` The response is an attachment named `omniposts-export-.json`, with `Cache-Control: no-store`. You can download three exports an hour (`429` with `Retry-After` after that). The file holds `exportedAt`, `format`, and the sections `organization`, `users`, `channels`, `consents`, `ideas`, `groups`, `postingSlots`, `media`, `metrics`, `webhooks` and `auditEvents`. The `format` value is `neslivis-export/1`: the id predates the OmniPosts name and stays the same so existing importers keep working. Sections are database rows, so their field names are the database's. The export does not contain secrets (channel tokens, webhook secrets), the bytes of your media files, or raw platform responses. See [Export or delete your data](/docs/guides/export-or-delete-your-data). ## Delete your account `POST /v1/settings/delete-account` deletes **you**: your sign-in and your personal data. It needs a signed-in person, and any member may do it. Workspaces you share with others carry on without you; workspaces where you are the only member are deleted with all their data. Channels you connected in a shared workspace are disconnected when you leave it. See [Workspaces and members](/docs/concepts/workspaces#delete-your-account). You must confirm in the body: ```json { "confirm": "DELETE" } ``` Anything else gives `422 confirmation_required`. While you are the only owner of a workspace that has other members, the answer is `409 sole_owner_with_members` and nothing changes: make one of them an owner, or remove them, first. On success the answer is `202`: ```json { "confirmationCode": "..." } ``` > **Warning.** This cannot be undone. At once, the session cookie is cleared and every session of yours stops > working, and so do the API keys you created. In a workspace that is deleted, every key stops working. Channel credentials and connect state are deleted straight away. Posts, media, > metrics and webhooks are then hard-deleted by a background job that starts immediately. Records OmniPosts must keep, > such as consent records and audit events, are retained. Keep `confirmationCode` as your reference for the request. ## Sign up and log in API clients normally use a key and never need these. They are how the web app starts a session. All three need the header `X-OmniPosts-Client: web` and are limited to 20 a minute per IP address (see [Rate limits](/docs/api/rate-limits)). ### Sign up `POST /v1/auth/signup` creates a person, an organization and a session. ```json { "email": "ada@example.com", "password": "correct horse battery", "name": "Ada", "organizationName": "Ada Co", "acceptTerms": true } ``` `email` and `password` are required (`400 invalid_request` if missing). `acceptTerms` must be `true` (`422 terms_required`). `name` is optional. `organizationName` defaults to the email address. The password must have at least 10 characters (`422 weak_password`) and at most 72 bytes (`422 password_too_long`). An email that already has an account is `409 email_taken`. The answer is `201` with the same body as `GET /v1/me`, and sets the `nsid` cookie. ### Log in `POST /v1/auth/login` takes `email` and `password` and answers `200` with the same body as `GET /v1/me`, and sets the cookie. A wrong pair is `401 invalid_credentials`. Log-ins are also limited to 20 a minute per email address. ### Log out `POST /v1/auth/logout` ends the session and clears the cookie. The answer is `204`. --- # MCP server: let an AI agent post through the API URL: https://jamp.sh/docs/api/mcp Updated: 2026-10-22 Let an AI agent post through OmniPosts with the Model Context Protocol — the same operations, shapes and refusals as the API, as tools. OmniPosts runs an [MCP](https://modelcontextprotocol.io) server at `POST /mcp`. An agent that connects to it gets tools for everything the API does for posting: see what each platform accepts, check a post, send or schedule it, follow each channel's result, retry, upload media from a link, and read metrics. The tools return the same JSON the API returns and refuse with the same codes, so what you read in these docs holds for the tools too. ## Connect a client The server speaks streamable HTTP and is stateless: every call carries its own credential, there is no session to start or keep. Any MCP client that supports streamable HTTP and can send a header works. For a step-by-step setup, see [Connect an agent](/docs/agents/connect). > **Tab: Claude Code.** > > ```bash > claude mcp add --transport http omniposts https://omniposts.dev/mcp \ > --header "Authorization: Bearer omniposts_..." > ``` > **Tab: Codex.** > > ```toml > # ~/.codex/config.toml (or .codex/config.toml in a trusted project) > [mcp_servers.omniposts] > url = "https://omniposts.dev/mcp" > bearer_token_env_var = "OMNIPOSTS_API_KEY" > ``` > > Codex sends the key from that environment variable as `Authorization: Bearer …`, so set > `OMNIPOSTS_API_KEY=omniposts_...` where Codex runs. The Codex CLI and IDE extension share this file. > **Tab: JSON config.** > > ```json > { > "mcpServers": { > "omniposts": { > "url": "https://omniposts.dev/mcp", > "headers": { "Authorization": "Bearer omniposts_..." } > } > } > } > ``` Cursor and many other clients read the JSON config. Clients that can only sign in with OAuth, such as custom connectors in the Claude and ChatGPT apps, cannot connect yet: OAuth sign-in for agents is coming soon. Use an API key with only the scopes the agent needs — `posts:write`, `posts:read`, `channels:read` and `media:write` cover posting — and give it an end date. See [Authentication](/docs/api/authentication#scopes). > **Note.** Connecting a channel still needs the platform's own sign-in in a browser. An agent can *start* it > with `start_channel_connect` and hand the person the link it gets back; the person finishes, and the agent > polls `get_connect_session`. See [Channels](/docs/api/channels#start-a-connect-from-an-api-key-a-handoff). ## The tools | Tool | Scope | What it does | |---|---|---| | `list_providers` | `channels:read` | Every platform with its text limit, media limits, per-platform settings and whether it is open for posting. The same as `GET /v1/providers` | | `list_channels` | `channels:read` | The connected accounts, with their health. `GET /v1/channels` | | `validate_post` | `posts:write` | A dry run of `create_post`: the same checks, nothing written. `POST /v1/groups/validate` | | `create_post` | `posts:write` | Publish now, at `scheduledFor`, or into the posting queue. `POST /v1/groups` | | `list_posts` | `posts:read` | Posts newest first, filterable by status, channel and time. `GET /v1/groups` | | `get_post` | `posts:read` | One post with a target per channel: status, link on the platform, failure. `GET /v1/groups/{id}` | | `cancel_post` | `posts:write` | Cancel every channel not yet sent. `POST /v1/groups/{id}/cancel` | | `retry_post` | `posts:write` | Re-queue the channels marked retryable. `POST /v1/groups/{id}/retry` | | `upload_media_from_url` | `media:write` | Download a public https link and store it; use the id in `mediaIds`. `POST /v1/media` | | `start_channel_connect` | `channels:manage` | Start connecting an OAuth platform; the result's `handoffUrl` is for a signed-in person to open. `POST /v1/connect-sessions` | | `get_connect_session` | `channels:read` | Whether that connect is still pending, completed (channel set), failed or expired. `GET /v1/connect-sessions/{id}` | | `get_post_metrics` | `metrics:read` | A target's platform counters. `GET /v1/metrics/post/{targetId}` | A tool whose scope the key lacks answers with an error result, code `insufficient_scope`, naming the scope. ## What a good run looks like 1. `list_channels` for the ids, and `list_providers` for the limits of the platforms behind them. 2. Compose. `validate_post` with the exact arguments you will send; fix what `rejections` and `problems` say. 3. `create_post` with an `idempotencyKey`, so a retried call can never post twice. 4. The result has a target per channel. `published`, `failed` and `cancelled` are final; for anything else (`publishing`, `awaiting_platform`, `unknown`) call `get_post` again later. 5. A `failed` target whose `failure.retryable` is `true` can go again with `retry_post`. One whose `action` is `VERIFY_MANUALLY` may be live on the platform: look there before doing anything, and never re-create the post. ## Results and errors Every tool returns structured content (the same JSON as the API endpoint in the table) and one sentence of text. A refusal is a tool result with `isError` and `{ "error": { "code", "message", "details" } }`, using the codes in [Errors](/docs/api/errors): `nothing_to_publish` with its `rejections`, `plan_limit_reached`, `posting_limit_reached`, `media_rejected`, `not_found`, `invalid_request`, and so on. A request with no key or a bad key is `401` at the HTTP level, before any JSON-RPC. Calls count against the WRITE rate limit (60 a minute per organization); a `429` carries `Retry-After`. ## For agents reading this `/llms.txt` points here and to `/openapi.yaml`. The REST API and the tools are the same system: a post created by a tool shows up in `GET /v1/groups`, in the web app and in webhooks, and the other way round.