REST API · v1

Drive post mate from any script.

A small, predictable REST surface + HMAC-signed webhooks. Bearer auth, JSON-only, eight endpoints. Pair it with the MCP server for natural-language agents, or call it straight from cron.

  • Create, schedule, publish from any HTTP client
  • HMAC-signed webhooks for post.published / failed
  • Same data model as the dashboard — no shadow schema
  • Rate limit + scope guard surfaced in every response
01

What you get

A REST API scoped to the bearer-token owner — every read and write isolates to one tenant — and a webhook firehose for the rest of your stack.

JSON in, JSON out

Eight endpoints, every error a typed { error, message }. Same response shape from health to media upload.

Signed webhooks

Post lifecycle events arrive HMAC-SHA256 signed with a per-webhook secret + replay-protection timestamp.

Limits in the response

Every authenticated reply carries x-ratelimit-limit and x-ratelimit-remaining. No surprise 429.

Same model as the app

A post created via /v1/posts shows up in the dashboard with the same status, targets, and analytics as one made in the UI.

02

Get an API key

Three clicks. Treat it like a password — anyone with the key can read and post on your behalf.

  1. 1

    Enable the API add-on

    Open Settings → Billing and click Enable for $5/mo next to the API Access card. Billed alongside your main plan; cancel any time from the Paddle customer portal.
  2. 2

    Generate a key

    Head to Settings → API, click Generate new key, name it (e.g. "production worker"), and copy the value. Keys look like pmk_…; the plaintext is shown once, the database keeps only a SHA-256 hash.
  3. 3

    Store the base URL

    Same for every account:
    https://post-mate.com/api/v1

Lost the key? You can't recover it. Generate a new one and revoke the old one — the next request on the old key returns 401 immediately.

03

Make a request

Three identical recipes — pick your language, swap the key, you're done.

Base URL
https://post-mate.com/api/v1
Auth header
Authorization: Bearer pmk_…
Content type
application/json (or multipart/form-data on /v1/media)
Rate limit
60 requests / minute / key (sliding window)

List your scheduled posts

GET

Curl. Works from any shell.

list-posts.shsnippet
curl -sS "https://post-mate.com/api/v1/posts?limit=10&status=scheduled" \
  -H "Authorization: Bearer pmk_YOUR_KEY_HERE"

Schedule a post

POST

Same shape POST'd as JSON. Omit scheduled_at to publish immediately.

schedule.shsnippet
curl -sS "https://post-mate.com/api/v1/posts" \
  -H "Authorization: Bearer pmk_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "text",
    "caption": "Day 14 of #buildinpublic — we just shipped the API.",
    "social_account_ids": ["b2c5…", "f0a1…"],
    "scheduled_at": "2026-06-01T09:00:00Z"
  }'

Upload media first

multipart

Send multipart/form-data with the field name file. The reply gives you the key + url to drop into POST /v1/posts.

upload.shsnippet
curl -sS "https://post-mate.com/api/v1/media" \
  -H "Authorization: Bearer pmk_YOUR_KEY_HERE" \
  -F "file=@./hero.jpg"

From a Node script

JS / Node

Plain fetch — no SDK to install.

schedule.tssnippet
const res = await fetch("https://post-mate.com/api/v1/posts", {
  method: "POST",
  headers: {
    "authorization": "Bearer " + process.env.POSTMATE_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    type: "image",
    caption: "Shipped!",
    social_account_ids: [accountId],
    media: [{ key: mediaKey, mime_type: "image/jpeg", size_bytes: 124_800 }],
  }),
});
if (!res.ok) throw new Error(`post mate API: ${res.status}`);
const { id } = await res.json();

From Python

Python

requests handles both JSON and multipart in two calls.

schedule.pysnippet
import os, requests

headers = {"authorization": f"Bearer {os.environ['POSTMATE_KEY']}"}

# 1) Upload media first
with open("hero.jpg", "rb") as f:
    media = requests.post(
        "https://post-mate.com/api/v1/media",
        headers=headers,
        files={"file": ("hero.jpg", f, "image/jpeg")},
    ).json()

# 2) Reference it in the post
post = requests.post(
    "https://post-mate.com/api/v1/posts",
    headers={**headers, "content-type": "application/json"},
    json={
        "type": "image",
        "caption": "Shipped!",
        "social_account_ids": [account_id],
        "media": [{
            "key": media["key"],
            "mime_type": media["mime_type"],
            "size_bytes": media["size_bytes"],
        }],
    },
).json()
print(post["id"])
04

Endpoints reference

Eight routes cover the whole post lifecycle. Full schemas live in the OpenAPI 3.1 spec.

EndpointWhat it doesInputs
GET/v1/healthmeta
Public uptime probe. No auth required. Returns { status, timestamp }.
GET/v1/accountsread
Every social account connected to your workspace — id, platform, display name, group. No tokens.
GET/v1/postsread
Recent posts (max 100, newest first). Filter by status: draft, scheduled, publishing, posted, partial, failed.status?, limit?
POST/v1/postswrite
Create a post. Omit scheduled_at to publish immediately. type ∈ text/image/video/story; overrides[] sets per-account caption + platformConfig.type, caption?, social_account_ids[], overrides[]?, media[]?, scheduled_at?, timezone?
GET/v1/posts/{id}read
Hydrate a single post: caption, per-network targets, statuses, and media URLs.id (path)
DELETE/v1/posts/{id}write
Hard-delete a draft or scheduled post. Posts that already went live stay where they are (409).id (path)
GET/v1/analyticsmeta
Latest per-target metrics snapshot for the window (default 30 days, max 180).window_days?
POST/v1/mediamedia
Upload an image or video (multipart, field name "file"). Returns { key, url, mime_type, size_bytes } you can drop into media[].file (multipart)

Need the full request/response schemas? Grab the OpenAPI 3.1 document ↗ — drop it into Postman, Insomnia, or any code generator.

05

Webhooks

Get pushed whenever a post moves. HMAC-SHA256 signed, timestamped, retried up to four times with exponential backoff.

EventWhen it firesPayload (under data)
post.scheduledA future-dated post is saved.post_id, scheduled_at, type, caption, social_account_ids[]
post.publishedEvery target on a post reaches status="posted".post_id, status, caption, type, targets[]
post.failedA post hits a terminal failed or partial state — at least one target did not publish.post_id, status ("failed" / "partial"), caption, type, targets[]

Register a receiver

In Settings → API, click Add webhook, paste your HTTPS URL (e.g. https://example.com/post-mate/webhook), tick the events you care about, and save. The signing secret is shown once; rotate it any time without redeploying (the new secret kicks in on the next delivery).

Headers on every delivery

X-Postmate-Event
post.published / post.failed / post.scheduled
X-Postmate-Delivery
UUID — idempotency key, stable across retries
X-Postmate-Timestamp
unix seconds — reject if |now − ts| > 300
X-Postmate-Signature
sha256=hex(HMAC-SHA256(secret, "${ts}.${body}"))

What lands on your endpoint

Sample payload

Same envelope for every event; data shape depends on event.

post.published.jsonsnippet
{
  "event": "post.published",
  "timestamp": "2026-05-19T14:32:18.045Z",
  "data": {
    "post_id": "8b0c5e8a-…",
    "status": "posted",
    "caption": "Shipped!",
    "type": "image",
    "targets": [
      {
        "id": "…",
        "social_account_id": "…",
        "platform": "instagram",
        "account_name": "@yourhandle",
        "status": "posted",
        "platform_post_id": "C7…",
        "platform_post_url": "https://instagram.com/p/C7…",
        "error_message": null
      }
    ]
  }
}

Verify the signature

Node / Express

Keep the raw body — re-serialized JSON breaks the HMAC.

receiver.tssnippet
import crypto from "node:crypto";
import express from "express";

const app = express();
// IMPORTANT: keep the raw body — JSON.parse'd objects re-serialize
// differently and break the HMAC compare.
app.post("/post-mate/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.header("x-postmate-timestamp");
  const sig = req.header("x-postmate-signature");
  const body = req.body.toString("utf8");

  if (!ts || !sig || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.status(400).send("stale or missing timestamp");
  }
  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.POSTMATE_WEBHOOK_SECRET)
      .update(`${ts}.${body}`)
      .digest("hex");
  if (
    sig.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  ) {
    return res.status(401).send("bad signature");
  }

  const event = JSON.parse(body);
  console.log(event.event, event.data); // post.published / post.failed / post.scheduled
  res.json({ ok: true });
});

Same check, Python

Python / Flask

Use request.get_data() — request.json drops trailing whitespace and breaks the compare.

receiver.pysnippet
import os, hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["POSTMATE_WEBHOOK_SECRET"].encode()

@app.post("/post-mate/webhook")
def receive():
    ts = request.headers.get("X-Postmate-Timestamp", "")
    sig = request.headers.get("X-Postmate-Signature", "")
    body = request.get_data()  # raw bytes; do NOT use request.json
    if not ts or abs(time.time() - int(ts)) > 300:
        abort(400)
    mac = hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256)
    expected = "sha256=" + mac.hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    event = request.get_json()
    print(event["event"], event["data"])
    return {"ok": True}

Retry policy

Initial attempt is fired immediately after the post event. On any non-2xx (or network error / timeout / private-IP block) the delivery is re-queued with exponential backoff:

  • try #1 — 0s
  • try #2 — +1m
  • try #3 — +5m
  • try #4 — +25m

After four failed attempts the delivery is parked as status="failed" with the last response code and error stored for debugging. A daily cron sweeps anything still pending in case a hot deploy killed the in-flight attempt.

06

Errors & rate limits

Every error response is a typed JSON object: { error: 'code', message: 'human-readable' }. Never an HTML page, never a stack trace.

HTTPerror codeWhat it means
400invalid_body / invalid_queryBody or query failed Zod validation. message has the field path.
401unauthorizedMissing, malformed, or revoked Bearer token.
402subscription_requiredTrial expired or subscription is past_due / canceled.
402api_addon_requiredYour plan does not include API access. Enable the $5/mo addon under Settings → Billing.
403insufficient_scopeThe key has scope="mcp". Generate a scope="full" key for /v1/*.
404not_foundNo such post / id, or it belongs to another user.
409invalid_stateCan't delete a post that already touched a social network. Only draft / scheduled.
413body_too_large / file_too_largeBody > 1 MB or upload > 50 MB.
415unsupported_media_type / unsupported_mimeSend multipart/form-data on /v1/media; allowed mime listed under Limits.
429rate_limitedYou blew through 60 req/min. Inspect Retry-After and x-ratelimit-* headers.

Rate-limit headers

Every authenticated response (including 429) carries:

x-ratelimit-limit
Cap per minute (currently 60).
x-ratelimit-remaining
Requests left in the current window after this call.
retry-after
On 429 only — seconds to wait before the next attempt.
07

Limits & types

Hard-coded server-side; the API returns a typed error well before any social network's own limit kicks in.

Rate limit per key
60 / min
JSON body max size
1 MB
Media upload max size
50 MB
Image types
jpeg, png, webp, gif
Video types
mp4, mov, webm
Max accounts per post
50
Max media items per post
20
Caption length
10 000 chars
Webhook retries
4 attempts
Webhook timestamp window
±5 min
08

Security model

The assumption is that a key or secret may eventually leak — every primitive narrows the blast radius.

Hashed at rest

Keys carry 256 bits of entropy (base64url). We persist only the SHA-256 hash, so a database dump cannot replay a key.

Scoped & tenant-isolated

Bearer auth resolves to one user, full scope only. The MCP-only scope cannot hit /v1/*. No call can read or mutate another tenant's data.

SSRF-hardened

Media upload and outbound webhook delivery resolve the hostname, reject private IPv4/IPv6 and cloud metadata aliases, and re-validate each redirect hop.

Signed + replay-protected webhooks

Outbound deliveries are HMAC-SHA256 signed. Receivers reject |now − ts| > 5 min, killing replay attacks; secret rotation revokes old signatures immediately.

Per-key rate limit

Sliding-window counter in Postgres caps each key at 60 req/min — one noisy script can't starve the others on your account.

Instant revoke

Revoke a key in Settings; the very next request returns 401. No propagation, no cache.

09

Troubleshooting

The handful of things that bite people in their first hour.

"unauthorized" / 401
Wrong key, revoked key, or missing Authorization header. Generate a fresh one in Settings → API and re-paste.
"insufficient_scope" / 403
You sent an MCP-scoped key (the ones from Settings → MCP) to /v1/*. Generate a key under Settings → API — that one has scope="full".
"api_addon_required" / 402
Your plan doesn't include API access. Enable the $5/mo add-on on Settings → Billing — it's a separate Paddle subscription, billed monthly, cancel any time from the portal.
"rate_limited" / 429
You blew through 60 req/min for this key. Inspect Retry-Afterand back off. If you genuinely need more, split traffic across multiple keys or write us — we'll bump the cap per-key.
Signature mismatch on my webhook receiver
Three usual causes: (1) you re-serialized the JSON body before hashing — always sign the raw bytes; (2) you forgot the "${ts}." prefix in the HMAC input; (3) you rotated the secret in Settings → API but the receiver still has the old one. Rotate again or update the env.
Webhook never arrives
Inspect the deliveries log under the webhook row in Settings → API— it shows the last status code and error. Private/loopback URLs are blocked outright (you'll see "Private IPv4 blocked"). For local dev, tunnel via ngrok or cloudflared.
"Host X resolves to a private IPv4" on media upload
The URL you supplied resolves to a local or private address. Blocked on purpose — host the media on any public HTTPS URL.
My scheduled post fired, but no webhook
Only terminal states fire (posted / failed / partial); the transient publishingstate doesn't emit. If even the terminal one is missing, the receiver probably returned non-2xx — check the deliveries log.

Treating the API as the source of truth? Subscribe to post.scheduled and post.published rather than polling GET /v1/postson a tight loop — the rate limit kicks in fast and you'll miss exactly the events you care about.

Stuck on something, or hitting an API edge case?

Email support@post-mate.com — we read every message.