← All documentation
Getting started

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" } }
HTTPcodeWhen
401unauthorizedmissing/invalid/revoked API key
404not_foundno such account/post for this tenant
409conflictwrong state (e.g. editing a published post) or network not configured
422validation_errorbad 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, default 25, max 100.
  • ?cursor — pass the next_cursor from 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

fieldreq?notes
networkrequiredinstagram | facebook | linkedin | threads | tiktok | youtube
redirect_urloptionalhttps:// 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 conflict if that network isn't configured on this Herald instance.
  • The connect_url is single-flow and expires in 15 minutes.
  • WordPress is different — it has no OAuth. See POST /v1/accounts/wordpress below.

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 https and 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_media with 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

fieldreq?notes
targetsrequiredarray of connected-account UUIDs (≥1)
textoptionalcaption/commentary — for WordPress, the post body (HTML allowed)
titlerequired for WordPress targetsblog-post title; ignored by social networks
media_idsoptionalarray of media UUIDs from POST /v1/media
modeoptionalnow (default) | schedule | draft
scheduled_atrequired if mode=scheduleUTC ISO-8601, in the future
external_refoptionalidempotency key

Per-network payload rules (validated):

networkmediatext / title
instagramexactly 1 image requiredcaption; no text-only posts
facebookoptional (1 image → photo post, else feed)optional
linkedinoptionalrequired
wordpressoptional (first image → featured image)text = HTML body; title required
blueskyoptional (up to 4 images)caption; title ignored
mastodonoptionaltext or media required; title ignored
threadsoptional (1 image)text required for text posts; title ignored
tiktok1 video requiredtext = caption
youtube1 video requiredtitle (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, schedule without scheduled_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.

fieldnotes
textnew caption
media_idsreplace media ([] to clear)
scheduled_atonly 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

fieldreq?notes
urlrequiredhttps:// endpoint to POST events to
eventsrequiredarray 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:

headervalue
X-Herald-Eventthe event name
X-Herald-Deliveryunique delivery id
X-Herald-Signaturesha256=<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.