postpipe

Home · OpenAPI · Privacy · Terms · Marketing · Console · App

Postpipe + VariantPost dual-surface quickstart

Two products, one pipe:

SurfaceWhoHost
A. Postpipe (API)Developers / other appshttps://api.variantpost.com
B. VariantPost (App)People / 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. Postpipe 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 / 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.

B. VariantPost 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 / 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" }.


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


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

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

Analytics (charts)

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.

Postpipe MCP (Cursor / Claude)

Metricool-class connect: one-click Cursor + OAuth Livemcp.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.

Networks (capability catalog)

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.

Research (hashtags)

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.

Hashtag sets (Compose tracker)

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": [ … ] }.

Best times (static hints)

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.

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.

Billing (Stripe)

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

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
402plan_limit (live Free over included billed deliveries)
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 + 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.