Herald/Documentation
← All documentation
Reference

Herald — Product & Architecture Spec

Date: 2026-08-16 Status: Design (ready to build) Codename: Herald

1. Product summary

Herald is a multi-tenant social-media publishing service. A tenant connects their social accounts once (OAuth), then any authorized consumer publishes or schedules posts to those accounts through a single REST API. Herald owns the platform apps and stores the OAuth tokens, so consumers never touch platform credentials or OAuth flows.

First consumer: tars (leiritech marketing brain). Design intent: additional apps and paying customers.

2. Core concepts (the domain)

  • Tenant — a customer of Herald (an org/account). Everything is scoped to a tenant. tars is one tenant.
  • User — a human who logs into the Herald dashboard; belongs to a tenant.
  • API key — a per-tenant secret a program uses to call the Herald API (hld_live_…). How tars authenticates. Revocable; multiple per tenant.
  • Connected account — a social account (Instagram/LinkedIn/…) the tenant authorized. Herald stores its OAuth tokens + platform identifiers. Belongs to a tenant.
  • Post — a publish request: content (text/caption + media) + one or more target connected accounts + timing (now / scheduled / draft). Fans out to post targets, one per account, each with its own status + permalink.
  • Media asset — an uploaded image/video, stored in Herald's object store, exposed at a public URL (required by IG).
  • Platform app — Herald's single registered app per network (Meta, LinkedIn…), app-reviewed once, holding the client id/secret + scopes.

3. What a consumer does (happy path)

  1. Tenant connects accounts in the dashboard (OAuth) → connected_accounts rows.
  2. Tenant creates an API key.
  3. Consumer calls POST /v1/posts with the API key: text, media URLs (or pre-uploaded media ids), target account ids, optional scheduled_at.
  4. Herald validates, fans out to one post target per account.
  5. For "now": the publish engine runs each target immediately. For "scheduled": the worker picks it up at the due time.
  6. Each target: refresh token if needed → (upload media to public URL if needed) → call the platform API → capture the permalink → mark published (or failed with an error).
  7. Consumer polls GET /v1/posts/{id} (or a webhook, later) for status + permalinks.

4. Architecture

4.1 Processes (Railway)

  • web — FastAPI: the public /v1 API, the dashboard BFF routes, and the OAuth connect/callback endpoints. Stateless; scales horizontally.
  • worker — a loop that (a) publishes due scheduled targets and (b) proactively refreshes tokens nearing expiry, flipping accounts to expired on failure. Single-writer semantics via a SELECT … FOR UPDATE SKIP LOCKED claim on due targets so multiple workers are safe.

4.2 Connector abstraction

connectors/base.py defines the interface every platform implements:

class Connector(Protocol):
    network: str
    def authorize_url(self, state: str, redirect_uri: str) -> str: ...
    async def exchange_code(self, code: str, redirect_uri: str) -> TokenBundle: ...
    async def refresh(self, account: ConnectedAccount) -> TokenBundle: ...
    async def resolve_identity(self, tokens: TokenBundle) -> AccountIdentity: ...
    async def publish(self, account: ConnectedAccount, payload: PublishPayload) -> PublishResult: ...
  • TokenBundle = access_token, refresh_token?, expires_at, scopes.
  • AccountIdentity = platform_account_id, display_name, avatar_url, page_id? (IG's linked FB Page id / LinkedIn org URN).
  • PublishPayload = text, media (list of public URLs after upload), options.
  • PublishResult = platform_post_id, permalink.

Adding a network = one new connectors/<network>.py implementing the Protocol. v1 ships instagram.py and linkedin.py.

4.3 Instagram connector (Graph API)

  • OAuth via Facebook Login; the token is a Page-scoped token; the IG user id is discovered from the linked FB Page (/me/accounts → page → instagram_business_account).
  • Publish is 2-step: POST /{ig-user-id}/media with image_url + captioncreation_id; then POST /{ig-user-id}/media_publish with creation_id → media id; then GET /{media-id}?fields=permalink.
  • Constraints: caption ≤ 2200 chars, ≤ 30 hashtags, 25 published posts / 24h per account. Reject personal (non-Business) accounts at connect time.

4.4 LinkedIn connector (Posts API, company Pages)

  • OAuth with w_organization_social (Community Management). Author URN = the organization URN.
  • Media: initialize image upload (/rest/images?action=initializeUpload), PUT bytes, get the image URN; then POST /rest/posts with author URN, commentary, and the media reference. Pin the LinkedIn-Version header.
  • Derive the permalink from the returned post URN.

4.5 Media pipeline

  • POST /v1/media accepts a file (or a source URL to fetch) → stores it in the object store under tenant/<id>/… → returns { media_id, url } with a public HTTPS URL. POST /v1/posts accepts either media_urls (already public) or media_ids (pre-uploaded). Before an IG publish, any non-public URL is uploaded to the store first.

4.6 Scheduling

  • scheduled_at (UTC) on a post → targets are created pending; the worker claims due targets and publishes. null = publish immediately (web enqueues to the worker or runs inline for low latency — inline for v1).
  • Optional later: "next free slot" per account (Blotato-style recurring slots). Out of scope for v1; leave the column/seam.

5. Multi-tenancy & auth

  • Dashboard: email+password (or magic link) session auth for users; a user belongs to one tenant in v1 (org switching later).
  • API: Authorization: Bearer hld_…. The key resolves to a tenant; all queries are tenant-scoped. Rate-limit per key. Keys are shown once at creation (store only a hash).
  • Isolation: a tenant_id filter is applied in the data layer for every tenant-owned entity; add a test that a key for tenant A cannot read tenant B's accounts/posts.

6. Security

  • Encrypt tokens at rest (Fernet/AES-GCM with a key from TOKEN_ENC_KEY).
  • OAuth state is signed (HMAC) and single-use, carrying tenant + network + nonce; verify on callback.
  • Never return or log tokens. Redact secrets in any surfaced text.
  • Scope the media bucket's public access to the media prefix only.
  • CORS: dashboard origin only for BFF routes; the /v1 API is server-to-server (no browser CORS needed).

7. Error handling

  • Per-target status: pending | publishing | published | failed, with error_code + error_message on failure. A post's overall status is derived (all published / partial / failed).
  • Expired/revoked token → account status=expired, targets fail fast with a reauth_required code; the dashboard prompts reconnect.
  • Rate-limit / transient platform errors → bounded retry with backoff in the worker; permanent errors (bad media, policy) → fail with a clear code.

8. Observability

  • Structured logs per publish attempt (tenant, account, network, target id, outcome — never tokens). /health on both processes. A minimal admin metrics endpoint (counts by status) for ops.

9. Explicitly out of scope for v1

  • Networks beyond Instagram + LinkedIn (seam left via the Connector Protocol).
  • Personal IG posting (platform-impossible), analytics read-back, comment/DM management, billing/subscriptions, "next free slot" scheduling, team/RBAC beyond one-user-one-tenant, webhooks (poll for status in v1).

10. Success criteria (v1 "done")

  • A tenant can connect a real IG Business account and a real LinkedIn company Page via the dashboard.
  • POST /v1/posts with an API key publishes to both, returns target ids, and GET /v1/posts/{id} shows the live permalinks (opened in a browser as proof).
  • A scheduled post publishes at its due time via the worker.
  • Tokens auto-refresh; an expired account surfaces a reconnect prompt.
  • tars, as a tenant, publishes an approved draft through Herald end to end (see INTEGRATION-tars.md).