Home · OpenAPI · Privacy · Terms
Two products, one pipe:
| Surface | Who | Host |
|---|---|---|
| A. API | Developers / other apps | https://api.variantpost.com |
| B. 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 / 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. TikTok Direct Post / X credit buy / App Review out of scope here. Idempotency-Key required on every ★ write. Test and live keys do not share the idempotency namespace.
Open https://app.variantpost.com.
Ready now
end_user_id via App BFF (sk_* never in the browser)f66a28b+); prefer 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 only (no Direct Post). Out-of-plan nets → 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|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 / X / YouTube / TikTok / Meta (instagram|facebook|threads). Mastodon sessions need instance_url (https base). Missing OAuth client env → 501.
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 { "id": "med_…", "status": "pending"|"ready"|"rejected" }. 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).
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+.
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.
{
"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 |
| 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 / X / YouTube / TikTok draft_video / 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.