Home · OpenAPI · Privacy · Terms

VariantPost dual-surface quickstart

Two products, one pipe:

SurfaceWhoHost
A. APIDevelopers / other appshttps://api.variantpost.com
B. AppPeople / orgshttps://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.

A. API customer (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.

B. App user (people / orgs)

Open https://app.variantpost.com.

Ready now

Honest limits


Auth on every /v1 call except the hosted Connect GET and the media PUT:

Authorization: Bearer $KEY

0. Webhook first

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.


1. Connect sandbox

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):

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


2. Media

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.


3. POST /v1/posts → 202

Variants required. Empty variants422 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: queuedrenderinguploadingpublishingpollingsucceeded | 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).


4. Webhook body (delivery.succeeded)

{
  "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");



API keys (HTTP)

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

Usage

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.

Errors

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

HTTPcodes
400idempotency_key_required, invalid_json, invalid_request
401auth_invalid_key
403auth_insufficient_scope, auth_test_key_on_live_route
409idempotency_key_reuse, idempotency_in_progress, post_not_cancellable
422variants_required, variant_kind_unsupported, network_not_in_plan, connection_not_active, duplicate_connection, unknown_extra_key, …
501network_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.


Live host vs compose

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.