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.
Get an API key
Three clicks. Treat it like a password — anyone with the key can read and post on your behalf.
- 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
Generate a key
Head to Settings → API, click Generate new key, name it (e.g. "production worker"), and copy the value. Keys look likepmk_…; the plaintext is shown once, the database keeps only a SHA-256 hash. - 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.
Make a request
Three identical recipes — pick your language, swap the key, you're done.
https://post-mate.com/api/v1Authorization: Bearer pmk_…application/json (or multipart/form-data on /v1/media)60 requests / minute / key (sliding window)List your scheduled posts
GETCurl. Works from any shell.
curl -sS "https://post-mate.com/api/v1/posts?limit=10&status=scheduled" \ -H "Authorization: Bearer pmk_YOUR_KEY_HERE"
Schedule a post
POSTSame shape POST'd as JSON. Omit scheduled_at to publish immediately.
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
multipartSend multipart/form-data with the field name file. The reply gives you the key + url to drop into POST /v1/posts.
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 / NodePlain fetch — no SDK to install.
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
Pythonrequests handles both JSON and multipart in two calls.
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"])Endpoints reference
Eight routes cover the whole post lifecycle. Full schemas live in the OpenAPI 3.1 spec.
| Endpoint | What it does | Inputs |
|---|---|---|
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.
Webhooks
Get pushed whenever a post moves. HMAC-SHA256 signed, timestamped, retried up to four times with exponential backoff.
| Event | When it fires | Payload (under data) |
|---|---|---|
post.scheduled | A future-dated post is saved. | post_id, scheduled_at, type, caption, social_account_ids[] |
post.published | Every target on a post reaches status="posted". | post_id, status, caption, type, targets[] |
post.failed | A 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 payloadSame envelope for every event; data shape depends on event.
{
"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 / ExpressKeep the raw body — re-serialized JSON breaks the HMAC.
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 / FlaskUse request.get_data() — request.json drops trailing whitespace and breaks the compare.
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.
Errors & rate limits
Every error response is a typed JSON object: { error: 'code', message: 'human-readable' }. Never an HTML page, never a stack trace.
| HTTP | error code | What it means |
|---|---|---|
| 400 | invalid_body / invalid_query | Body or query failed Zod validation. message has the field path. |
| 401 | unauthorized | Missing, malformed, or revoked Bearer token. |
| 402 | subscription_required | Trial expired or subscription is past_due / canceled. |
| 402 | api_addon_required | Your plan does not include API access. Enable the $5/mo addon under Settings → Billing. |
| 403 | insufficient_scope | The key has scope="mcp". Generate a scope="full" key for /v1/*. |
| 404 | not_found | No such post / id, or it belongs to another user. |
| 409 | invalid_state | Can't delete a post that already touched a social network. Only draft / scheduled. |
| 413 | body_too_large / file_too_large | Body > 1 MB or upload > 50 MB. |
| 415 | unsupported_media_type / unsupported_mime | Send multipart/form-data on /v1/media; allowed mime listed under Limits. |
| 429 | rate_limited | You 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
429only — seconds to wait before the next attempt.
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
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.
Troubleshooting
The handful of things that bite people in their first hour.
"unauthorized" / 401
Authorization header. Generate a fresh one in Settings → API and re-paste."insufficient_scope" / 403
/v1/*. Generate a key under Settings → API — that one has scope="full"."api_addon_required" / 402
"rate_limited" / 429
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
"${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
ngrok or cloudflared."Host X resolves to a private IPv4" on media upload
My scheduled post fired, but no webhook
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.