Herald Public API (v1)
Herald is a multi-tenant social-media publishing API. Connect your Instagram and Facebook accounts once, then publish to them from one authenticated request.
Base URL
https://api.herald.leiritech.com
All endpoints below are prefixed with /v1. JSON in, JSON out, UTC ISO-8601
timestamps.
Postman — import the ready-made collection:
/herald.postman_collection.json. Set the
api_key and base_url collection variables and every request is ready to run.
FastAPI also serves live OpenAPI at /openapi.json and /docs.
Authentication
Every request carries your tenant API key as a bearer token:
Authorization: Bearer hld_xxxxxxxxxxxxxxxxxxxxxxxx
The key resolves to exactly one tenant; every response is scoped to that tenant — there is no cross-tenant access. Create and revoke keys in the dashboard (Developer → API keys).
401 unauthorized— missing/invalid/revoked key.
Errors
Failures return an appropriate 4xx/5xx with:
{ "error": { "code": "validation_error", "message": "human-readable text" } }
| HTTP | code | When |
|---|---|---|
| 401 | unauthorized | missing/invalid/revoked API key |
| 404 | not_found | no such account/post for this tenant |
| 409 | conflict | wrong state (e.g. editing a published post) or network not configured |
| 422 | validation_error | bad body / unknown network / bad media / per-network rule |
Per-target publish failures never fail the whole request (partial success is
first-class). Each failed target carries one of these error_codes:
rate_limited, invalid_media, reauth_required, policy, exchange_failed.
Idempotency
Send external_ref on POST /v1/posts. A repeat with the same
(tenant, external_ref) returns the original post instead of publishing twice.
Pagination
List endpoints (GET /v1/accounts, GET /v1/posts) are cursor-paginated:
?limit— page size, default25, max100.?cursor— pass thenext_cursorfrom the previous response to get the next page.
Each response includes "next_cursor" — an opaque string, or null on the last
page. A malformed cursor returns 422.
Accounts
A connected account = one social account linked to your tenant, identified by a Herald UUID. You target that UUID when publishing.
POST /v1/accounts/connect
Start connecting a social account. OAuth needs the end-user's browser (providers
block iframes), so this returns a hosted connect_url — redirect the user there
(full-page or popup). They authorize (for Facebook, a Page picker runs); the
account is then bound to your tenant and appears in GET /v1/accounts.
Request
| field | req? | notes |
|---|---|---|
network | required | instagram | facebook | linkedin | threads | tiktok | youtube |
redirect_url | optional | https:// URL to return the user to afterwards. On success Herald appends ?connected=<network>. |
{ "network": "facebook", "redirect_url": "https://your-app.example/connected" }
200
{
"connect_url": "https://herald.leiritech.com/api/oauth/connect/eyJ0…",
"expires_at": "2026-08-19T14:20:00Z"
}
409 conflictif that network isn't configured on this Herald instance.- The
connect_urlis single-flow and expires in 15 minutes. - WordPress is different — it has no OAuth. See
POST /v1/accounts/wordpressbelow.
POST /v1/accounts/wordpress
Connect a self-hosted WordPress site with an Application Password (WP admin → Users → Profile → Application Passwords). No OAuth, no browser step.
{ "site_url": "https://blog.example.com", "username": "admin", "app_password": "xxxx xxxx xxxx xxxx" }
201 returns the connected account (same shape as a list entry, network: "wordpress"). 422 if the site/REST API is unreachable or the credentials are wrong.
The credential is stored encrypted and never returned. wordpress.com-hosted blogs are
not supported (they use a different API).
POST /v1/accounts/bluesky
Connect a Bluesky account with an App Password (Bluesky → Settings → App Passwords — not your main password). No OAuth.
{ "identifier": "name.bsky.social", "app_password": "xxxx-xxxx-xxxx-xxxx" }
201 → the connected account (network: "bluesky"). 422 on bad credentials.
POST /v1/accounts/mastodon
Connect any Mastodon instance with an access token (Preferences → Development →
your app; scopes write:statuses + write:media). No OAuth.
{ "instance_url": "https://mastodon.social", "access_token": "your-access-token" }
201 → the connected account (network: "mastodon"). 422 if the instance is
unreachable or the token is rejected.
GET /v1/accounts
List the tenant's connected accounts.
200
{
"accounts": [
{ "id": "e10ed871-…", "network": "instagram", "display_name": "@crafterby",
"avatar_url": "https://…", "page_id": null, "status": "active" },
{ "id": "f283ef85-…", "network": "facebook", "display_name": "Mostardela España",
"avatar_url": "https://…", "page_id": "1017…", "status": "active" }
],
"next_cursor": null
}
Cursor-paginated (?limit, ?cursor). status: active | expired | revoked | pending | error. An expired/revoked
account fails a publish with reauth_required — reconnect it.
Every account also carries status_reason (why it is in its current status — e.g. the
platform's error when it expired; null while healthy), status_source (what caused it —
same values as source in GET /v1/accounts/{id}/events below; user means you
disconnected it yourself) and status_changed_at (ISO timestamp of that change, or null).
GET /v1/accounts/{id}
One account (same object shape as a list entry). 404 if it isn't yours.
GET /v1/accounts/{id}/events
The account's status history, newest first — answers "why does this account keep
disconnecting?". 404 if it isn't yours.
{
"events": [
{ "status": "expired", "source": "metrics", "reason_code": "190/460",
"reason": "Error validating access token: The session has been invalidated…",
"occurred_at": "2026-10-05T12:27:53Z" },
{ "status": "reconnected", "source": "reconnect", "reason_code": null,
"reason": null, "occurred_at": "2026-09-04T00:11:17Z" }
]
}
status: expired | reconnected | revoked. source — what detected it: metrics
(periodic stats check), publish, token_refresh, token_lapsed (expiry date
passed), platform_deauthorize (Herald removed on the platform side), user
(disconnected in Herald), reconnect.
GET /v1/accounts/{id}/stats
Latest stored metric snapshot (collected periodically; not a live call).
200
{
"account_id": "f283ef85-…",
"network": "facebook",
"stats": {
"followers_count": 31, "follows_count": null, "media_count": 4,
"reach": null, "profile_views": null,
"captured_at": "2026-08-19T12:00:00Z"
}
}
stats is null if nothing has been collected yet. follows_count is null for
Facebook Pages. reach/profile_views populate only when the insights permission
is granted (Instagram).
POST /v1/accounts/{id}/disconnect
Mark the account revoked (keeps history). 204, no body.
DELETE /v1/accounts/{id}
Permanently delete the account (its publish records cascade). 204, no body.
Media
POST /v1/media
Upload an image so it has a public HTTPS URL (Instagram requires a hosted URL).
multipart/form-data with a file field.
201
{ "id": "b1c2…", "public_url": "https://cdn.herald…/tenant/…/img.jpg", "bytes": 84213 }
Reference the returned id in media_ids when creating a post.
Multiple images (carousels)
Pass several image ids in media_ids (in slide order) to publish a multi-image post:
Instagram → carousel (2–10), Facebook → multi-photo post (≤10), LinkedIn → multi-image post (2–20),
Bluesky/Mastodon ≤4, Threads 1. A post whose image count exceeds ANY target's limit is rejected
with 422 (e.g. "Instagram accepts at most 10 images per post; got 11.") — Herald never
silently drops images. WordPress uses the first image
as the featured image.
POST /v1/media/from-url
Already have the image/video at a URL? Skip the upload — Herald fetches it and hosts
it for you. JSON body with a url field. Returns the same shape as POST /v1/media,
so use the returned id in media_ids exactly the same way.
{ "url": "https://cdn.yourapp.com/render/abc.jpg" }
201
{ "id": "b1c2…", "public_url": "https://cdn.herald…/tenant/…/img.jpg", "bytes": 84213, "kind": "image" }
Both endpoints coexist — upload bytes with POST /v1/media, or pass a URL here.
Herald always re-hosts the media and passes its own URL to the platform; the source
URL is never handed to Instagram/etc.
Requirements for the URL:
- Must be
httpsand resolve to a public address (no localhost / private IPs — SSRF-guarded). - Must respond 200 directly (no redirects), with an image (
image/jpeg,image/png,image/webp) or video (video/mp4,video/quicktime)Content-Type. - Size caps match uploads: 15 MB image, 300 MB video.
- On any failure (bad URL, wrong type, too large, unreachable) → 422
invalid_mediawith a clear message.
Posts
A post is one compose action fanned out to one or more target accounts, each with its own per-target status. Publish mode is explicit.
POST /v1/posts
Create a post as a draft, a scheduled post, or publish immediately.
Request
| field | req? | notes |
|---|---|---|
targets | required | array of connected-account UUIDs (≥1) |
text | optional | caption/commentary — for WordPress, the post body (HTML allowed) |
title | required for WordPress targets | blog-post title; ignored by social networks |
media_ids | optional | array of media UUIDs from POST /v1/media |
mode | optional | now (default) | schedule | draft |
scheduled_at | required if mode=schedule | UTC ISO-8601, in the future |
external_ref | optional | idempotency key |
Per-network payload rules (validated):
| network | media | text / title |
|---|---|---|
instagram | exactly 1 image required | caption; no text-only posts |
facebook | optional (1 image → photo post, else feed) | optional |
linkedin | optional | required |
wordpress | optional (first image → featured image) | text = HTML body; title required |
bluesky | optional (up to 4 images) | caption; title ignored |
mastodon | optional | text or media required; title ignored |
threads | optional (1 image) | text required for text posts; title ignored |
tiktok | 1 video required | text = caption |
youtube | 1 video required | title (or first line of text) = video title; text = description |
Upload video via POST /v1/media (MP4/MOV) just like an image; reference its id in
media_ids. Video targets (tiktok, youtube) require a video attachment.
{
"targets": ["f283ef85-…"],
"text": "Launch day! ✨",
"media_ids": ["b1c2…"],
"mode": "now"
}
201 — the PostOut object:
{
"id": "1a005368-…",
"status": "published",
"text": "Launch day! ✨",
"media": ["https://cdn.herald…/img.jpg"],
"scheduled_at": null,
"external_ref": null,
"created_at": "2026-08-19T13:00:00Z",
"targets": [
{
"id": "9f…", "network": "facebook", "status": "published",
"permalink": "https://www.facebook.com/1017…_999",
"platform_post_id": "1017…_999",
"error_code": null, "error_message": null,
"metrics": null
}
]
}
Post status: draft | scheduled | publishing | published | partial | failed | cancelled | unpublished. Target status: pending | publishing | published | failed | unpublished. partial = some targets published, some failed.
422 validation_error— unknown/foreign target, missing image for an IG target, inactive account,schedulewithoutscheduled_at, etc.
GET /v1/posts
List recent posts (newest first), cursor-paginated (?limit, ?cursor):
{ "posts": [ { "id": "…", "status": "published", "…": "…" } ], "next_cursor": "eyJ…"|null }
Each item is a PostOut (same shape as GET /v1/posts/{id}).
GET /v1/posts/{id}
Fetch one post — poll this for status + permalinks. Returns PostOut. 404 if not
yours.
GET /v1/posts/{id}/stats
Per-target engagement (latest stored snapshot).
200
{
"post_id": "1a005368-…",
"targets": [
{ "id": "9f…", "network": "facebook",
"stats": { "like_count": 42, "comments_count": 7, "shares": 3,
"reach": null, "views": null, "saved": null,
"captured_at": "2026-08-19T13:30:00Z" } }
]
}
stats is null until collected. reach/views/saved need the insights
permission (Instagram).
PATCH /v1/posts/{id}
Edit an unpublished post (draft/scheduled). Published posts are immutable —
the networks don't allow editing a live post. All fields optional; omitted =
unchanged; media_ids: [] clears media.
| field | notes |
|---|---|
text | new caption |
media_ids | replace media ([] to clear) |
scheduled_at | only valid on a scheduled post |
Returns PostOut. 409 conflict if the post is already published/publishing.
POST /v1/posts/{id}/publish
Publish a draft now (or bring a scheduled post forward). Returns PostOut.
409 if the post is not draft/scheduled.
POST /v1/posts/{id}/cancel
Cancel a scheduled post before it fires (→ cancelled). Returns PostOut.
409 if the post isn't scheduled.
DELETE /v1/posts/{id}
Delete a not-yet-published post (draft/scheduled/failed/cancelled) and
its targets. 204. 409 for a published post — use unpublish instead.
POST /v1/posts/{id}/unpublish
Delete the live post at the network for this post's published targets.
Per-network: Facebook ✅, LinkedIn ✅, Instagram ❌ (no API to delete a
published post → returns unsupported for IG targets). One target failing never
blocks the others.
Request (optional)
{ "targets": ["9f…"] } // omit to unpublish all live targets
200
{
"post_id": "1a005368-…",
"results": [
{ "target_id": "9f…", "network": "facebook", "ok": true },
{ "target_id": "3c…", "network": "instagram", "ok": false,
"code": "unsupported", "message": "Instagram's API can't delete a published post." }
]
}
Successfully unpublished targets become unpublished (kept for audit; permalink
cleared).
POST /v1/posts/{id}/retry
Re-run only the failed targets of a post (e.g. after reconnecting an account).
Returns PostOut.
Webhooks
Subscribe an endpoint to events so you're notified when a post settles or an account changes — no polling. Deliveries are HMAC-signed so you can verify authenticity, and retried with backoff by Herald's worker if your endpoint is briefly down.
Events: post.published, post.failed, post.partial, post.unpublished,
account.connected, account.revoked, account.expired.
account.expired fires the moment Herald detects that the platform ended an account's
saved login (so you can prompt a reconnect before posts fail). Payload:
{account_id, network, display_name, status: "expired", source, reason_code, reason, expired_at} — same source values as GET /v1/accounts/{id}/events.
POST /v1/webhooks
| field | req? | notes |
|---|---|---|
url | required | https:// endpoint to POST events to |
events | required | array from the events list above |
201 — the secret is shown once; store it to verify signatures.
{
"id": "c3d4…", "url": "https://your-app.example/herald/hooks",
"events": ["post.published", "post.failed"], "status": "active",
"created_at": "2026-08-19T15:00:00Z",
"secret": "whsec_A1b2C3…"
}
GET /v1/webhooks
List your subscriptions (secret never returned). DELETE /v1/webhooks/{id} removes one.
Receiving a delivery
Herald POSTs JSON to your url with headers:
| header | value |
|---|---|
X-Herald-Event | the event name |
X-Herald-Delivery | unique delivery id |
X-Herald-Signature | sha256=<hex> — HMAC-SHA256 of the raw body with your secret |
Body
{
"id": "d5e6…",
"event": "post.published",
"created_at": "2026-08-19T15:01:00Z",
"data": {
"post_id": "1a005368-…",
"status": "published",
"external_ref": null,
"targets": [
{ "id": "9f…", "network": "facebook", "status": "published",
"permalink": "https://www.facebook.com/…", "platform_post_id": "…", "error_code": null }
]
}
}
account.* events carry data: { account_id, network, display_name, status }.
Verify the signature (Python):
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
assert hmac.compare_digest(expected, request.headers["X-Herald-Signature"])
Retries: respond 2xx to acknowledge. Non-2xx / timeouts are retried with
exponential backoff (2, 4, 8… minutes, capped) up to 6 attempts, then marked failed.
Utility
GET /v1/me
Returns { "tenant_id": "…", "tenant_name": "…" } — a quick key/identity check.
GET /v1/usage
Per-network publish/failure counts over a window. ?days = 1–365 (default 30).
{
"days": 30,
"networks": [
{ "network": "facebook", "published": 12, "failed": 1, "unpublished": 0,
"pending": 0, "publishing": 0, "total": 13 }
]
}
Versioning
The public surface is /v1. Changes within v1 are additive only; breaking changes
go to /v2. Platform API versions (Graph API, LinkedIn-Version) are pinned
server-side and never exposed to consumers.