🌱Bloom

API reference

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.

Authentication

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.

Composer (post generation)

POST/api/posts/generate

Generates 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.

Request
{ "platform": "instagram" | "twitter" | "linkedin" | "facebook" | "tiktok",
  "topic": "what the post is about", "tone"?: "optional extra tone guidance" }
Response
{ "ok": true, "platform": "...", "topic": "...", "caption": "...", "hashtags": "...",
  "imageSvg": "...", "engine": "mock" | "gemini" | "claude" }
Errors

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.

POST/api/posts

Saves 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.

Request
{ "platform": "...", "caption": "...", "topic"?: "...", "hashtags"?: "...",
  "imageSvg"?: "...", "engine"?: "mock" | "gemini" | "claude",
  "campaignId"?: "...", "scheduledAt"?: "2026-09-10T14:00:00.000Z" }
Response
{ "ok": true, "post": { ...the saved post row... } }
Errors

400 if caption or platform is missing. 404 if campaignId is set but doesn't belong to this workspace.

POST/api/posts/:id/posted

Manually 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.

Request
(none)
Response
{ "ok": true, "post": { ... } }
Errors

404 if the post doesn't exist in this workspace.

Campaigns

POST/api/campaigns/:id/ideas

AI-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.

Request
(none)
Response
{ "ok": true, "ideas": [ "idea one", "idea two", ... ], "engine": "mock" | "gemini" | "claude" }
Errors

404 if the campaign doesn't exist in this workspace. 500 (`{ ok:false, error }`) if generation fails unexpectedly.

POST/api/campaigns/:id/ideas/use

Turns 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.

Request
{ "idea": "the idea text from the ideas list", "platform"?: "instagram" | "twitter" | "linkedin" | ... }
Response
{ "ok": true, "post": { ...the saved post row... } }
Errors

400 if idea is missing. 404 if the campaign doesn't exist in this workspace. 500 if generation fails unexpectedly.

Real platform publishing

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.

POST/api/posts/:id/publish

Publishes 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.

Request
(none)
Response
{ "ok": true, "post": { ...updated post, status "posted", platform_post_url set... } }
Errors

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.

POST/api/connections/:platform/credentials

Saves 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.

Request
{ "clientId": "...", "clientSecret"?: "..." }  (:platform is "twitter" or "linkedin")
Response
{ "ok": true, "connection": { ...the platform_connections row... } }
Errors

400 if :platform isn't a connectable platform, or clientId is missing.

POST/api/connections/mastodon/connect

Connects a Mastodon account directly with an instance URL + personal access token (no OAuth redirect needed - Mastodon connects in one call, unlike Twitter/LinkedIn).

Request
{ "instanceUrl": "https://mastodon.social", "accessToken": "..." }
Response
{ "ok": true, "connection": { ...the platform_connections row, status "connected"... } }
Errors

400 if instanceUrl or accessToken is missing, or the instance rejects the token (invalid/expired).

POST/api/connections/:platform/disconnect

Disconnects a platform - clears its stored tokens and account info and sets it back to "disconnected". Posts already published through it are unaffected.

Request
(none)
Response
{ "ok": true, "connection": { ...the platform_connections row, status "disconnected"... } }
Errors

404 if there's no connection row for that platform in this workspace.

OAuth (Twitter/X and LinkedIn connect flow)

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.

GET/oauth/twitter/start

Builds 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.

Request
(none - browser navigation, not fetched)
Response
302 redirect to Twitter/X.
Errors

400 (HTML error page) if no Client ID has been saved for this workspace yet.

GET/oauth/twitter/callback

Twitter/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".

Request
(none - query params `code`, `state`, or `error` set by Twitter/X)
Response
302 redirect to /dashboard/settings?connected=twitter.
Errors

400 (HTML error page) if Twitter/X reports an error, the state doesn't match, or the token exchange fails.

GET/oauth/linkedin/start

Same idea as the Twitter start route (state only, no PKCE - LinkedIn uses plain Authorization Code) - redirects (302) to LinkedIn's OAuth consent screen.

Request
(none - browser navigation, not fetched)
Response
302 redirect to LinkedIn.
Errors

400 (HTML error page) if no Client ID + Secret have been saved for this workspace yet.

GET/oauth/linkedin/callback

LinkedIn 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".

Request
(none - query params `code`, `state`, or `error` set by LinkedIn)
Response
302 redirect to /dashboard/settings?connected=linkedin.
Errors

400 (HTML error page) if LinkedIn reports an error, the state doesn't match, or the token exchange fails.

Billing

POST/billing/checkout

Starts 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.

Request
plan=starter | growth | agency (form-encoded)
Response
302 redirect (to Stripe Checkout, or back to Settings in demo mode).
GET/billing/success

Stripe 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).

Request
(none - query param `session_id` set by Stripe)
Response
302 redirect to /dashboard/settings?saved=1&billing_success=1.
GET/billing/portal

Redirects 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).

Request
(none)
Response
302 redirect to Stripe, or back to Settings with an error.
POST/webhooks/stripe

Stripe 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.

Request
Raw Stripe event JSON (sent by Stripe, not by you).
Response
{ "received": true } on success, or { "error": "..." } with 400 on a bad/missing signature.