Herald/Documentation
← All documentation
Reference

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_ref on POST /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_ids and/or media_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.