API reference

Base URL https://postpipe.dev. Everything is JSON over HTTPS and scoped to one workspace. The dashboard uses these same endpoints — there is no private API behind it.

Authentication

Two interchangeable mechanisms.

API key, for programs: Authorization: Bearer pp_live_…. Create one in the dashboard or via POST /v1/api-keys. Scopes are read (GETs), write (posts and media, includes read) and admin (keys, webhooks, connections; includes write). Limited to 240 requests per minute per key.

Session cookie, for the dashboard: POST /auth/signup, POST /auth/login, POST /auth/logout, GET /auth/me. Sessions carry admin scope.

Only a SHA-256 digest of an API key is ever stored, so a leaked database does not yield working keys. The secret is shown once, at creation.

Errors

{ "error": { "code": "validation_failed", "message": "…", "details": { } } }
StatusCodeMeaning
400bad_requestMalformed request
401unauthorizedMissing or invalid credentials
403forbiddenAuthenticated, but the scope doesn't allow it
404not_foundUnknown id — also returned for ids in another workspace, so existence never leaks
409conflictState conflict, e.g. deleting a post mid-publish
422validation_failedWell-formed but invalid; details says why
429rate_limitedSlow down

Idempotency

Send Idempotency-Key on POST /v1/posts (header, or idempotency_key in the body). A retransmission returns the original post rather than publishing twice. Reusing a key with a different body is rejected with 422 — that combination almost always means a bug rather than a retry.

Platforms

GET /v1/platforms                        → [{platform, display_name, configured}]
GET /v1/platforms/:platform/capabilities → text limits, media counts, types, sizes, features
GET /v1/capabilities                     → every capability record at once

Capability records exist so you don't hardcode platform rules. Character limits, accepted MIME types, maximum file sizes, video duration bounds and carousel counts are all reported here and change when the platform changes.

Connections and social accounts

POST   /v1/connections/:platform   {redirect_to?}  → 201 {authorization_url, platform}
GET    /v1/connections                            → tokens are never included
POST   /v1/connections/:id/refresh
DELETE /v1/connections/:id                        → revokes; tokens deleted immediately
GET    /v1/social-accounts
GET    /v1/social-accounts/:id

Request an authorization_url, send the person through it, and the platform redirects back to Postpipe. Accounts then appear under /v1/social-accounts. One authorization can expose several publishable accounts.

Media

POST /v1/media/uploads  {filename, mime_type, size_bytes}
  → 201 {media, upload: {method, url, headers, expires_in}, complete_url}
PUT  <upload.url>                     raw bytes
POST /v1/media/uploads/:id/complete   → validates, extracts dimensions/duration
GET  /v1/media        GET /v1/media/:id        DELETE /v1/media/:id

Accepted: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/quicktime, video/webm. On completion the file is checked by its actual magic bytes rather than its extension, and width, height and duration are read from the file itself. Per-platform limits are enforced later, per destination.

Posts

POST   /v1/posts
GET    /v1/posts?status=&limit=&cursor=
GET    /v1/posts/:id?include=resolved
PATCH  /v1/posts/:id                              edit, or {action:"publish"|"cancel"}
DELETE /v1/posts/:id
GET    /v1/posts/:id/destinations
GET    /v1/posts/:id/destinations/:destinationId  full attempt history
POST   /v1/posts/:id/retry  {destination_ids?}
{
  "text": "New spot in Lisbon",
  "link_url": "https://example.com",
  "media": ["media_…"],
  "destinations": ["acct_…", "acct_…"],
  "scheduled_at": "2026-09-01T09:00:00Z",
  "timezone": "Europe/Lisbon",
  "overrides":         { "bluesky": { "text": "…" } },
  "account_overrides": { "acct_…":  { "text": "…" } },
  "idempotency_key": "launch-day-1"
}

Override semantics

Resolved field by field, account beating platform beating the default. An omitted field inherits; "text": "" means empty text; "media": [] means no media; "link_url": "" removes the link.

Status

A post's status is a rollup — draft, scheduled, in_progress, published, partial, failed, canceled — derived from its destinations, which are the source of truth. Each destination moves through queued, validating, uploading, processing, publishing, published, or retrying / failed / canceled.

Failure codes

Every platform failure is normalized to one vocabulary, with the platform's own response preserved alongside it.

ClassificationCodesWhat happens
retryableRATE_LIMITED PLATFORM_UNAVAILABLE NETWORK_ERROR INTERNALRetried automatically with backoff, up to 5 attempts
non_retryableINVALID_MEDIA MEDIA_TOO_LARGE UNSUPPORTED_MEDIA INVALID_CONTENT PLATFORM_REJECTEDStops; the content has to change
requires_user_actionAUTH_EXPIRED AUTH_REVOKED INSUFFICIENT_SCOPE AMBIGUOUS_RESULTWaits for you. AMBIGUOUS_RESULT means the platform may already have published — check before retrying

API keys

POST   /v1/api-keys  {name, scopes, expires_at?}  → 201 {api_key, secret}
GET    /v1/api-keys                               → prefix and last_used_at only
DELETE /v1/api-keys/:id                           → revoke
POST   /v1/api-keys/:id/rotate                    → new secret; the old key dies immediately

secret is returned exactly once, at creation or rotation. There is no way to recover it afterwards — rotate instead.

Webhooks

POST   /v1/webhooks  {url, events, description?}  → 201 {webhook, secret}
GET    /v1/webhooks    PATCH /v1/webhooks/:id    DELETE /v1/webhooks/:id
GET    /v1/webhooks/:id/deliveries
POST   /v1/webhooks/:id/test

Events: post.created, post.scheduled, post.completed, destination.queued, destination.processing, destination.published, destination.failed.

Each delivery carries Postpipe-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Verify it, reject anything more than 300 seconds old, and deduplicate on the payload's id — it stays identical across retries and across endpoints. Respond 2xx within 15 seconds; failures retry with backoff up to 8 attempts.

{
  "id": "evt_…",
  "type": "destination.published",
  "created_at": "…",
  "workspace_id": "ws_…",
  "data": {
    "destination_id": "dest_…", "post_id": "post_…",
    "platform": "bluesky", "status": "published",
    "remote_post_id": "…", "remote_url": "…"
  }
}