Herald — Public API Contract (v1)
Base URL: https://<herald-backend> (Railway). All /v1 endpoints are
server-to-server and authenticated with a tenant API key.
Auth
Authorization: Bearer hld_live_xxxxxxxxxxxxxxxxxxxx
The key resolves to exactly one tenant. Every response is scoped to that tenant.
401 if missing/invalid, 403 if the key is revoked.
Conventions
- JSON in/out. UTC ISO-8601 timestamps.
- Errors:
{ "error": { "code": "string", "message": "human text" } }with an appropriate 4xx/5xx status. - Idempotency: send
external_refonPOST /v1/posts; a repeat with the same(tenant, external_ref)returns the original post instead of duplicating.
GET /v1/accounts
List the tenant's connected accounts (what you can publish to).
200
{
"accounts": [
{
"id": "9d2c…",
"network": "instagram",
"display_name": "@crafterby",
"avatar_url": "https://…",
"status": "active",
"page_id": "1784…"
},
{
"id": "4a71…",
"network": "linkedin",
"display_name": "CrafterBy (Company Page)",
"status": "active",
"page_id": "urn:li:organization:1234"
}
]
}
status may be active | expired | revoked. An expired account will fail a
publish with reauth_required — the tenant must reconnect in the dashboard.
POST /v1/media
Upload media so it has a public URL (needed for Instagram). Two forms:
- multipart file upload, or
{ "source_url": "https://…" }to have Herald fetch + rehost.
201
{ "media_id": "m_8f…", "url": "https://cdn.herald…/tenant/…/img.jpg", "kind": "image" }
You may skip this and pass already-public media_urls directly to POST /v1/posts.
POST /v1/posts
Create a post and fan it out to one or more connected accounts. Publishes
immediately unless scheduled_at is set.
Request
{
"text": "Launch day! ✨ #crafterby",
"media_ids": ["m_8f…"],
"media_urls": [],
"account_ids": ["9d2c…", "4a71…"],
"scheduled_at": null,
"external_ref": "tars-draft-4821"
}
text— caption/commentary. Per-network limits enforced (IG ≤2200, ≤30 tags).media_idsand/ormedia_urls— at least one image required for IG targets.account_ids— targets; must belong to the tenant. Mixed networks allowed.scheduled_at— UTC; omit/null = publish now.external_ref— optional idempotency key.
202 (accepted; publishing async or scheduled)
{
"post": {
"id": "p_51…",
"status": "publishing",
"scheduled_at": null,
"targets": [
{ "id": "t_a1…", "account_id": "9d2c…", "network": "instagram", "status": "publishing" },
{ "id": "t_b2…", "account_id": "4a71…", "network": "linkedin", "status": "publishing" }
]
}
}
Validation errors (422) — unknown/foreign account id, missing media for an
IG target, caption too long, expired account, etc., each with a code.
GET /v1/posts/{id}
Poll for status + permalinks.
200
{
"post": {
"id": "p_51…",
"status": "partial",
"scheduled_at": null,
"created_at": "2026-08-16T14:00:00Z",
"targets": [
{
"id": "t_a1…", "account_id": "9d2c…", "network": "instagram",
"status": "published",
"permalink": "https://www.instagram.com/p/Cxyz…/",
"published_at": "2026-08-16T14:00:07Z"
},
{
"id": "t_b2…", "account_id": "4a71…", "network": "linkedin",
"status": "failed", "error_code": "reauth_required",
"error_message": "LinkedIn token expired; reconnect the account."
}
]
}
}
Overall status: published (all ok), partial (some failed), failed (all
failed), scheduled (waiting), publishing (in flight).
GET /v1/posts
List recent posts (paginated: ?limit=&cursor=). Returns the same post shape,
summarized.
POST /v1/posts/{id}/retry
Retry failed targets of a post (e.g. after the tenant reconnected an account).
Re-runs only failed targets.
Dashboard-only routes (session auth, not API-key)
Not part of the public contract; used by the Next.js frontend:
GET /api/oauth/{network}/connect?next=…→ redirect to the platform.GET /api/oauth/{network}/callback→ finish connect, upsert account.POST /api/accounts/{id}/disconnect.GET/POST/DELETE /api/keys→ manage API keys.GET /api/accounts,GET /api/posts→ dashboard reads.
Versioning
The public surface is /v1. Additive changes only within v1; breaking changes
go to /v2. Platform API versions (Graph, LinkedIn-Version) are pinned
server-side and not exposed to consumers.