Home · OpenAPI · Privacy · Terms · Marketing · Console · App
Two products, one pipe:
| Surface | Who | Host |
|---|---|---|
| A. Postpipe (API) | Developers / other apps | https://api.variantpost.com |
| B. VariantPost (App) | People / orgs | https://app.variantpost.com |
Public docs: https://api.variantpost.com/docs · OpenAPI: https://api.variantpost.com/openapi.yaml. Legal: https://api.variantpost.com/legal/privacy + /legal/terms.
sk_* + curl)Canonical API host: https://api.variantpost.com. OpenAPI {origin} default matches. Compose remains for local sk_test_ without hitting production.
export BASE=https://api.variantpost.com # or http://localhost:8080 for compose
export KEY=sk_test_… # bootstrap / dashboard; shown once
export END_USER=usr_alice # tenant-supplied opaque id
export SUCCESS_URL=… # your app (e.g. https://app.variantpost.com/…)
export CANCEL_URL=… # your app
export WEBHOOK_URL=… # your HTTPS receiver
sk_test_ → sandbox adapter only (never calls providers). sk_live_ Connect when host client env is set: LinkedIn / linkedin_org / X / YouTube / TikTok / Meta (instagram|facebook|threads); Mastodon needs instance_url (dynamic app). Bluesky/Telegram: POST /v1/connect/{bluesky|telegram}. Missing OAuth client env → 501. Live publish also covers Bluesky post, Mastodon status, Telegram message/photo/video, and linkedin_org company pages. TikTok Direct Post (kind=video) needs TIKTOK_DIRECT_POST=true (default off; else draft_video). X credit buy still out of scope. Idempotency-Key required on every ★ write. Test and live keys do not share the idempotency namespace.
Open https://app.variantpost.com.
Ready now
linkedin_org), X, YouTube, TikTok, Bluesky/Mastodon/Telegram (and Meta when API secrets are present)end_user_id via App BFF (sk_* never in the browser)GET /v1/posts?end_user_id= on the APIHonest limits
GET /v1/uploads/:id/content?token=). Configure the R2 env vars below for durable storage; MEDIA_DIR remains the fallbackdraft_video; Direct Post (kind=video) only when API has TIKTOK_DIRECT_POST=true. Out-of-plan nets (whatsapp/snapchat/…) → 422 network_not_in_planhttps://console.variantpost.com) is API keys/logs — not the AppAuth on every /v1 call except the hosted Connect GET and the media PUT:
Authorization: Bearer $KEY
Minted secret is whsec_…, shown once. Empty events = all v1 types.
curl -sS -X POST "$BASE/v1/webhooks" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{\"url\":\"$WEBHOOK_URL\",\"events\":[]}"
201
{
"id": "wh_…",
"url": "https://…",
"secret": "whsec_…",
"events": []
}
Verify deliveries:
X-Timestamp: <unix>
X-Signature: v1=<hmac_sha256(secret, "{timestamp}.{raw_body}")>
Reject if |now - timestamp| > 300. Header is X-Signature only (no X-Signature-256). Catch-up: GET /v1/events?after=evt_&limit=.
v1 types: connection.connected, connection.needs_reconnect, connection.disconnected, post.accepted, media.ready, media.rejected, job.updated, delivery.succeeded, delivery.failed, quota.warning.
curl -sS -X POST "$BASE/v1/connect/sandbox/sessions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{\"end_user_id\":\"$END_USER\",\"success_url\":\"$SUCCESS_URL\",\"cancel_url\":\"$CANCEL_URL\"}"
201 { "session_id": "ses_…", "url": "$BASE/connect/sandbox?session_id=ses_…", "expires_at": "…" } (10 min).
url is the hosted Connect page, not a Meta redirect. Complete it (sandbox finishes immediately):
curl -sS "$SESSION_URL" -H "Accept: application/json"
200 { "connection_id": "conn_…" } and a redirect to $SUCCESS_URL?connection_id=conn_…. Tokens never appear on GET /v1/connections.
curl -sS "$BASE/v1/connections?end_user_id=$END_USER" \
-H "Authorization: Bearer $KEY"
200 { "data": [{ "id": "conn_…", "network": "sandbox", "status": "active", … }] } — no access_token.
Webhook: connection.connected.
Live OAuth redirects (unauth, no trailing slash, not this sandbox step):
GET $BASE/v1/connect/{instagram|facebook|threads|linkedin|linkedin_org|x|youtube|tiktok|mastodon}/callback?code=&state=Slash twin is 404, not 301. Hosted Connect (/connect/{network}?session_id=) is not the redirect URI. Live Connect when client env is set: LinkedIn / linkedin_org / X / YouTube / TikTok / Meta (instagram|facebook|threads). linkedin_org uses Community Management scopes (w_organization_social); prefer LINKEDIN_ORG_CLIENT_ID/SECRET, optional session body organization_id. App — multi Page: if the member admins more than one company Page and organization_id was omitted, the callback redirects to success_url with needs_org_pick=1&session_id=… (no connection_id). Then GET /v1/connect/sessions/:id/organizations → pick → POST /v1/connect/sessions/:id/complete { "organization_id" } (+ Idempotency-Key) → { connection_id, network: "linkedin_org", remote_account_id }. Single-Page or pre-selected organization_id still finish in one hop.
# After needs_org_pick=1&session_id=… on success_url
curl -sS "$BASE/v1/connect/sessions/$SESSION_ID/organizations" \\
-H "Authorization: Bearer $KEY"
curl -sS -X POST "$BASE/v1/connect/sessions/$SESSION_ID/complete" \\
-H "Authorization: Bearer $KEY" \\
-H "Content-Type: application/json" \\
-H "Idempotency-Key: $(uuidgen)" \\
-d "{\"organization_id\":\"123456789\"}"
200 list { "data": [{ "id", "name" }] }. 200 complete { connection_id, network: "linkedin_org", remote_account_id }. Mastodon sessions need instance_url (https base). Missing OAuth client env → 501. TikTok Direct Post (kind=video) requires env TIKTOK_DIRECT_POST=true; otherwise use draft_video. Before publish (even while gated off), GET /v1/connections/:id/creator_info returns privacy/Duet/Stitch caps + direct_post_enabled.
# TikTok only — works even when TIKTOK_DIRECT_POST=false
curl -sS "$BASE/v1/connections/$CONN_ID/creator_info" \\
-H "Authorization: Bearer $KEY"
200 { network: "tiktok", connection_id, privacy_level_options, comment_disabled, duet_disabled, stitch_disabled, max_video_post_duration_sec, creator_username, direct_post_enabled }. Other networks → 422 unsupported_for_connection.
Credential Connect (not browser OAuth; Idempotency-Key required):
# Bluesky — handle/email + app password
curl -sS -X POST "$BASE/v1/connect/bluesky" \\
-H "Authorization: Bearer $KEY" \\
-H "Content-Type: application/json" \\
-H "Idempotency-Key: $(uuidgen)" \\
-d "{\"end_user_id\":\"$END_USER\",\"identifier\":\"you.bsky.social\",\"app_password\":\"…\"}"
# Telegram — bot token + chat_id (channels/groups ok)
curl -sS -X POST "$BASE/v1/connect/telegram" \\
-H "Authorization: Bearer $KEY" \\
-H "Content-Type: application/json" \\
-H "Idempotency-Key: $(uuidgen)" \\
-d "{\"end_user_id\":\"$END_USER\",\"bot_token\":\"…\",\"chat_id\":\"…\"}"
201 { "connection_id", "network", "remote_account_id" }.
BYTES=$(wc -c < ./clip.jpg)
curl -sS -X POST "$BASE/v1/media" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{\"end_user_id\":\"$END_USER\",\"filename\":\"clip.jpg\",\"content_type\":\"image/jpeg\",\"byte_size\":$BYTES}"
201 { "media_id": "med_…", "upload_url": "…", "headers": {…}, "expires_at": "…" } (15 min).
PUT the original (no Bearer; use headers from the 201):
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @./clip.jpg
curl -sS "$BASE/v1/media/$MEDIA_ID" -H "Authorization: Bearer $KEY"
200 public shape: { "id", "mime_type", "content_type", "byte_size", "width"?, "height"?, "duration_ms"?, "aspect_ratio"?, "status": "pending"|"ready"|"rejected", … }. mime_type aliases content_type. On PUT success we probe image dimensions (image-size) and optional video duration_ms (ffprobe when present). aspect_ratio is a decimal string from width/height (e.g. "1.777").
List for an end user (newest first, limit default 50 max 100):
curl -sS "$BASE/v1/media?end_user_id=$END_USER&limit=20" -H "Authorization: Bearer $KEY"
200 { "data": [MediaAsset…], "limit": 20 }.
Link preview (auth required; SSRF-blocked private IPs; http/https only):
curl -sS --get "$BASE/v1/link-preview" --data-urlencode "url=https://example.com" -H "Authorization: Bearer $KEY"
200 { "url", "title", "description"?, "image_url"?, "site_name"? }. Invalid/blocked URL → 400; fetch/resolve failure → 422.
Publish of unreadied media is allowed: Job waits on media.ready or fails media.rejected.
Meta/Threads (and other providers that pull by URL) fetch via short-lived GET /v1/uploads/:id/content?token=… (no Bearer; backed by R2 when configured, otherwise MEDIA_DIR). Set R2_PUBLIC_BASE_URL if providers should receive direct public object URLs instead.
Webhook: media.ready | media.rejected.
Variants required. Empty variants → 422 variants_required. Two variants on the same connection → 422 duplicate_connection. auto_adapt: true is the only one-blob + networks[] fanout (still persists N variants).
YouTube extra (snake_case preferred): title, privacy, tags (string[]), category_id, default_language, self_declared_made_for_kids (or made_for_kids), thumbnail_media_id (must be an image id also listed in that variant’s media_ids; thumbnail set failure fails the delivery).
curl -sS -X POST "$BASE/v1/posts" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{
\"end_user_id\": \"$END_USER\",
\"scheduled_at\": null,
\"variants\": [{
\"connection_id\": \"$CONN_ID\",
\"kind\": \"image\",
\"caption\": \"sandbox publish\",
\"media_ids\": [\"$MEDIA_ID\"]
}]
}"
202
{
"id": "pst_…",
"status": "accepted",
"variants": [{ "id": "var_…", "job_id": "job_…", "status": "queued" }]
}
Job: queued → rendering → uploading → publishing → polling → succeeded | failed | cancelled. Sandbox remote_id is deterministic: sb_<variant_id>. scheduled_at null = now; the web service runs worker ticks by default (RUN_WORKER_IN_API=true). If the posting-api-worker (node dist/worker.js / Render Background Worker in render.yaml) is Live, set RUN_WORKER_IN_API=false on the web service. first_comment is a follow-up Job after delivery.succeeded, not in the native payload.
curl -sS "$BASE/v1/posts/$POST_ID" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/v1/jobs/$JOB_ID" -H "Authorization: Bearer $KEY"
curl -sS "$BASE/v1/posts?end_user_id=$END_USER&limit=20" -H "Authorization: Bearer $KEY"
GET /v1/posts?end_user_id= lists tenant posts newest first. GET /v1/deliveries?end_user_id= or ?post_id=. Failed job: POST /v1/jobs/:id/retry.
Webhook: post.accepted, job.updated, then delivery.succeeded (or delivery.failed).
{
"id": "evt_…",
"type": "delivery.succeeded",
"created_at": "2026-09-01T16:01:02Z",
"data": {
"delivery_id": "dlv_…",
"job_id": "job_…",
"post_id": "pst_…",
"connection_id": "conn_…",
"network": "sandbox",
"remote_id": "sb_var_…",
"remote_url": "https://sandbox.invalid/p/sb_var_…"
}
}
Verify (Node):
const crypto = require("crypto");
const ts = req.headers["x-timestamp"];
const sig = req.headers["x-signature"]; // v1=<hex>
const expect = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
if (sig !== expect) throw new Error("bad signature");
Bootstrap still mints the first pair. After that, manage keys over the API (any valid tenant sk_*):
# list (no raw secrets)
curl -sS "$BASE/v1/keys" -H "Authorization: Bearer $KEY"
# create — raw `key` shown once
curl -sS -X POST "$BASE/v1/keys" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"mode":"test","scopes":["posts:read","posts:write","connections:read","connections:write","media:write","webhooks:write","usage:read"]}'
# soft-revoke
curl -sS -X DELETE "$BASE/v1/keys/$KEY_ID" \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)"
201 create { "id": "ek_…", "mode": "test"|"live", "scopes": […], "key": "sk_test_…", "created_at": "…" } — store key immediately; never returned again.
200 list { "data": [{ "id", "mode", "scopes", "created_at", "revoked_at": null }] } — no key / hash.
200 delete { "id": "ek_…", "status": "revoked" }. Cannot revoke the current request’s key when it is the tenant’s last active key → 422 last_active_key. Idempotency-Key required on POST/DELETE.
Console Keys UI should call these once Render is on 3973a79+.
App/console charts: real delivery aggregates + connection health. No impressions/likes/views/reach.
curl -sS "$BASE/v1/analytics?from=2026-09-01T00:00:00Z&to=2026-09-12T23:59:59Z&granularity=day" \
-H "Authorization: Bearer $KEY"
# optional: &end_user_id=$END_USER
200
{
"from": "2026-09-01T00:00:00.000Z",
"to": "2026-09-12T23:59:59.000Z",
"granularity": "day",
"totals": { "deliveries": 10, "succeeded": 7, "failed": 2, "pending": 1, "posts_sent": 4 },
"by_network": {
"instagram": { "succeeded": 4, "failed": 1, "pending": 0, "deliveries": 5, "success_rate": 0.8 }
},
"trend": [
{ "bucket": "2026-09-01", "succeeded": 2, "failed": 0, "deliveries": 2 }
],
"connections": { "total": 5, "active": 3, "needs_reconnect": 1, "revoked": 1 },
"recent_failures": [],
"engagement": {
"available": false,
"note": "Reach & engagement — coming when networks return it. We won’t invent numbers."
}
}
Defaults: from = now−30d, to = now, granularity=day. Status map: succeeded/failed as-is; processing + unknown → pending. posts_sent counts posts by created_at. success_rate is succeeded/deliveries or null. Scope: usage:read. Flat billing totals stay on GET /v1/usage.
Metricool-class connect: one-click Cursor + OAuth Live — mcp.json URL only https://mcp.variantpost.com/mcp (authorize/token on api.variantpost.com). api.*/mcp same handler. sk_ header = Advanced fallback only. Claude Connectors directory submit held until consent screenshots signed off. Full guide: mcp.md.
Static in-plan kinds + first-comment / media caps. No connection required. No engagement metrics.
curl -sS "$BASE/v1/networks" -H "Authorization: Bearer $KEY"
200 { "data": [{ "network": "instagram", "kinds": ["image","carousel","reel"], "supports_first_comment": true, "max_media": 10, … }, …] }. OpenAPI: GET /v1/networks.
Instagram, Mastodon, and Bluesky. Requires a live connection_id for that network. Leading # is stripped. TikTok / other networks → 422 unsupported_for_connection. IG permission gaps → 422 scopes_insufficient. Bluesky returns related tag names only (no media_count).
curl -sS --get "$BASE/v1/research/hashtags" \
--data-urlencode "network=instagram" \
--data-urlencode "q=cats" \
--data-urlencode "connection_id=$CONN" \
--data-urlencode "limit=10" \
-H "Authorization: Bearer $KEY"
200
{
"network": "instagram",
"query": "cats",
"data": [
{ "name": "cats", "media_count": 1200 },
{ "name": "catsofinstagram" }
]
}
Mastodon: network=mastodon. Bluesky: network=bluesky (same query shape; tags from search hit posts). OpenAPI: GET /v1/research/hashtags.
Persist custom sets server-side (App can drop localStorage / loadCustomHashtagSets). Scopes: posts:read list, posts:write create/patch/delete. Tags normalized with leading #, case-insensitive dedupe, max 30.
curl -sS "$BASE/v1/research/hashtag-sets?end_user_id=$END_USER" -H "Authorization: Bearer $KEY"
curl -sS -X POST "$BASE/v1/research/hashtag-sets" -H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"end_user_id":"'"$END_USER"'","label":"Travel","tags":["wanderlust","#Travel"]}'
# PATCH /v1/research/hashtag-sets/:id { "label"?, "tags"? }
# DELETE /v1/research/hashtag-sets/:id → 204
201/200 shape: { "id": "hs_…", "end_user_id", "label", "tags": ["#…"], "created_at", "updated_at" }. List: { "data": [ … ] }.
curl -sS "$BASE/v1/research/best-times?networks=instagram,x" -H "Authorization: Bearer $KEY"
Returns labeled soft hints (Often strong (hint, not a fact)) — not measured from analytics.
curl -sS "$BASE/v1/usage?from=2026-09-01T00:00:00Z&to=2026-09-30T23:59:59Z" \
-H "Authorization: Bearer $KEY"
200 (flat totals — no time series):
{
"from": "2026-09-01T00:00:00.000Z",
"to": "2026-09-30T23:59:59.000Z",
"deliveries_succeeded": 12,
"billed_deliveries": 12,
"transcode_seconds": 0,
"stored_gb_month": 0,
"by_network": { "instagram": 4, "linkedin": 3, "x": 5 }
}
from/to optional (default epoch → now). billed_deliveries is 0 for sk_test_. transcode_seconds and stored_gb_month are always 0 until metering is wired. Console charts by_network bars only.
Stripe Test mode Live on Render (API 82556ce+); not live charges yet. Connect is free (never billed). Live Free hard-stops at 50 billed deliveries/period with 402 plan_limit. Starter/Pro allow overage (metered).
# Current plan + period usage
curl -sS "$BASE/v1/billing/plan" -H "Authorization: Bearer $KEY"
# Upgrade via Stripe Checkout (subscription)
curl -sS -X POST "$BASE/v1/billing/checkout" \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"plan_id":"starter","success_url":"https://app.example/billing/ok","cancel_url":"https://app.example/billing/cancel"}'
# → { "url": "https://checkout.stripe.com/..." }
# Customer Portal
curl -sS -X POST "$BASE/v1/billing/portal" \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"return_url":"https://app.example/billing"}'
Webhook (Stripe → API, no auth): POST /v1/billing/webhook (raw body + Stripe-Signature).
{
"error": {
"code": "variants_required",
"message": "…",
"param": "variants",
"retryable": false,
"request_id": "req_…"
}
}
Always request_id. Honor Retry-After on 429. Never map 429/5xx to reconnect.
| HTTP | codes |
|---|---|
| 400 | idempotency_key_required, invalid_json, invalid_request |
| 401 | auth_invalid_key |
| 403 | auth_insufficient_scope, auth_test_key_on_live_route |
| 409 | idempotency_key_reuse, idempotency_in_progress, post_not_cancellable |
| 402 | plan_limit (live Free over included billed deliveries) |
| 422 | variants_required, variant_kind_unsupported, network_not_in_plan, connection_not_active, duplicate_connection, unknown_extra_key, … |
| 501 | network_oauth_not_configured (OAuth client env missing for that network) |
| 503 | /v1 and /legal/meta/* with no DATABASE_URL |
Same Idempotency-Key + same body replays original status, body, and request_id. Same key + different body → 409 idempotency_key_reuse.
curl -sS "$BASE/healthz" # {"ok":true,"db":true} on api.variantpost.com
curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/legal/privacy" # 200
curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/legal/terms" # 200
Prefer $BASE=https://api.variantpost.com for API curls. Use compose only for offline/local. Live Connect + publish: LinkedIn personal + linkedin_org / X / YouTube / TikTok draft_video (Direct kind=video when TIKTOK_DIRECT_POST=true) / Meta (IG/FB/Threads) when env+secrets set; plus Bluesky / Mastodon / Telegram when configured (see Connect). App users use https://app.variantpost.com (section B), not raw sk_* in the browser.