Bloom's JSON API - the same endpoints the app's own Composer, Campaigns, and Settings pages call. All request/response bodies are JSON unless noted.
Every /api/* endpoint below requires a valid session cookie, set by logging in through the web app (POST /login) or completing signup (POST /signup) / install (POST /install). There is no separate API token today - the JSON API is meant for this app's own frontend, not third-party integration, though every endpoint here is plain JSON over HTTP and easy to script against once you hold a session cookie.
/api/posts/generateGenerates a caption, hashtags, and a cover graphic for a topic/platform, without saving anything - this is what the Composer page calls as you type and hit "Generate post". Uses the workspace's own AI key if one is set in Settings, otherwise the deployment-wide key, otherwise the built-in mock engine.
{ "platform": "instagram" | "twitter" | "linkedin" | "facebook" | "tiktok",
"topic": "what the post is about", "tone"?: "optional extra tone guidance" }{ "ok": true, "platform": "...", "topic": "...", "caption": "...", "hashtags": "...",
"imageSvg": "", "engine": "mock" | "gemini" | "claude" }500 (`{ ok:false, error }`) if generation fails for any reason - the AI layer already falls back to the mock engine on a bad key/rate limit, so this only fires on something unexpected.
/api/postsSaves a post - either as a draft or, if scheduledAt is set, scheduled for later. Typically called right after /api/posts/generate with that response's caption/hashtags/imageSvg, but any of those fields can be hand-edited first.
{ "platform": "...", "caption": "...", "topic"?: "...", "hashtags"?: "...",
"imageSvg"?: "", "engine"?: "mock" | "gemini" | "claude",
"campaignId"?: "...", "scheduledAt"?: "2026-09-10T14:00:00.000Z" }{ "ok": true, "post": { ...the saved post row... } }400 if caption or platform is missing. 404 if campaignId is set but doesn't belong to this workspace.
/api/posts/:id/postedManually marks a post as posted, with no real publish attempt - the fallback for platforms without a real connector (Instagram, Facebook, TikTok), or for any platform you posted to by hand.
(none)
{ "ok": true, "post": { ... } }404 if the post doesn't exist in this workspace.
/api/campaigns/:id/ideasAI-brainstorms 6 on-theme post ideas for a campaign, based on its name and goal. Used by the "Generate 6 ideas" button on a campaign's detail page.
(none)
{ "ok": true, "ideas": [ "idea one", "idea two", ... ], "engine": "mock" | "gemini" | "claude" }404 if the campaign doesn't exist in this workspace. 500 (`{ ok:false, error }`) if generation fails unexpectedly.
/api/campaigns/:id/ideas/useTurns one brainstormed idea line straight into a fully generated, saved draft post tied to the campaign (generates caption + hashtags + cover graphic and saves it in one step) - the "+ Turn into post" button.
{ "idea": "the idea text from the ideas list", "platform"?: "instagram" | "twitter" | "linkedin" | ... }{ "ok": true, "post": { ...the saved post row... } }400 if idea is missing. 404 if the campaign doesn't exist in this workspace. 500 if generation fails unexpectedly.
These are the endpoints behind Settings → Platform connections and the "📤 Publish now" button - see the README's "Real platform publishing" section for how the Mastodon / Twitter-X / LinkedIn connectors and the scheduler fit together.
/api/posts/:id/publishPublishes one post for real, right now, through whatever platform connector applies - the same path the in-process scheduler uses for a due scheduled post. Always writes the outcome back onto the post (a real platform URL on success, a clear error on failure) before responding.
(none)
{ "ok": true, "post": { ...updated post, status "posted", platform_post_url set... } }400 (`{ ok:false, error, post }`) if the platform has no connector, the workspace has no connected account for it, the workspace is at its plan's monthly publish limit, or the connector call itself fails (expired token, platform outage, etc). 404 if the post doesn't exist in this workspace.
/api/connections/:platform/credentialsSaves a developer app's Client ID/Secret for Twitter/X or LinkedIn (the app credentials Bloom uses to start that platform's OAuth flow) - step one of connecting, before /oauth/:platform/start.
{ "clientId": "...", "clientSecret"?: "..." } (:platform is "twitter" or "linkedin"){ "ok": true, "connection": { ...the platform_connections row... } }400 if :platform isn't a connectable platform, or clientId is missing.
/api/connections/mastodon/connectConnects a Mastodon account directly with an instance URL + personal access token (no OAuth redirect needed - Mastodon connects in one call, unlike Twitter/LinkedIn).
{ "instanceUrl": "https://mastodon.social", "accessToken": "..." }{ "ok": true, "connection": { ...the platform_connections row, status "connected"... } }400 if instanceUrl or accessToken is missing, or the instance rejects the token (invalid/expired).
/api/connections/:platform/disconnectDisconnects a platform - clears its stored tokens and account info and sets it back to "disconnected". Posts already published through it are unaffected.
(none)
{ "ok": true, "connection": { ...the platform_connections row, status "disconnected"... } }404 if there's no connection row for that platform in this workspace.
These are browser-redirect routes, not JSON endpoints - Settings links to /oauth/:platform/start, which redirects to the platform's own consent screen, which redirects back to /oauth/:platform/callback. Documented here for completeness; nothing calls these with fetch(). Both require an app Client ID (and Secret, for LinkedIn) saved first via POST /api/connections/:platform/credentials.
/oauth/twitter/startBuilds a state + PKCE code-verifier/challenge pair, stores them on the workspace's connection row, and redirects (302) to Twitter/X's OAuth 2.0 authorize URL.
(none - browser navigation, not fetched)
302 redirect to Twitter/X.
400 (HTML error page) if no Client ID has been saved for this workspace yet.
/oauth/twitter/callbackTwitter/X redirects the browser back here with a code and state. Verifies state matches, exchanges the code (with the stored PKCE verifier) for an access token, fetches the connected account's handle/URL, and marks the connection "connected".
(none - query params `code`, `state`, or `error` set by Twitter/X)
302 redirect to /dashboard/settings?connected=twitter.
400 (HTML error page) if Twitter/X reports an error, the state doesn't match, or the token exchange fails.
/oauth/linkedin/startSame idea as the Twitter start route (state only, no PKCE - LinkedIn uses plain Authorization Code) - redirects (302) to LinkedIn's OAuth consent screen.
(none - browser navigation, not fetched)
302 redirect to LinkedIn.
400 (HTML error page) if no Client ID + Secret have been saved for this workspace yet.
/oauth/linkedin/callbackLinkedIn redirects the browser back here with a code and state. Verifies state, exchanges the code for an access token, fetches the connected account's profile URN, and marks the connection "connected".
(none - query params `code`, `state`, or `error` set by LinkedIn)
302 redirect to /dashboard/settings?connected=linkedin.
400 (HTML error page) if LinkedIn reports an error, the state doesn't match, or the token exchange fails.
/billing/checkoutStarts a subscription upgrade/downgrade. Redirects (302) to a real Stripe Checkout page when Stripe is configured (STRIPE_SECRET_KEY + a price ID for the plan); otherwise falls back to updating the stored plan directly ("demo mode") and redirects to Settings. Session-authenticated, form-encoded (not JSON) since it's posted by a real HTML form.
plan=starter | growth | agency (form-encoded)
302 redirect (to Stripe Checkout, or back to Settings in demo mode).
/billing/successStripe redirects the browser here after a successful Checkout. Does a best-effort immediate sync of the new plan/subscription so the UI reflects it right away, without waiting on the webhook round-trip (the webhook below is still the real source of truth).
(none - query param `session_id` set by Stripe)
302 redirect to /dashboard/settings?saved=1&billing_success=1.
/billing/portalRedirects to a real Stripe Billing Portal session for the current workspace (requires Stripe to be configured and the workspace to already have a Stripe customer).
(none)
302 redirect to Stripe, or back to Settings with an error.
/webhooks/stripeStripe calls this directly - not session-authenticated, verified instead via the Stripe-Signature header (HMAC-SHA256, requires STRIPE_WEBHOOK_SECRET). Handles checkout.session.completed, customer.subscription.updated/created, and customer.subscription.deleted to keep each workspace's plan/subscription_status/current_period_end in sync.
Raw Stripe event JSON (sent by Stripe, not by you).
{ "received": true } on success, or { "error": "..." } with 400 on a bad/missing signature.