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)
- Tenant connects accounts in the dashboard (OAuth) →
connected_accountsrows. - Tenant creates an API key.
- Consumer calls
POST /v1/postswith the API key: text, media URLs (or pre-uploaded media ids), target account ids, optionalscheduled_at. - Herald validates, fans out to one post target per account.
- For "now": the publish engine runs each target immediately. For "scheduled": the worker picks it up at the due time.
- Each target: refresh token if needed → (upload media to public URL if needed)
→ call the platform API → capture the permalink → mark
published(orfailedwith an error). - Consumer polls
GET /v1/posts/{id}(or a webhook, later) for status + permalinks.
4. Architecture
4.1 Processes (Railway)
- web — FastAPI: the public
/v1API, 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
expiredon failure. Single-writer semantics via aSELECT … FOR UPDATE SKIP LOCKEDclaim 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}/mediawithimage_url+caption→creation_id; thenPOST /{ig-user-id}/media_publishwithcreation_id→ media id; thenGET /{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; thenPOST /rest/postswith author URN, commentary, and the media reference. Pin theLinkedIn-Versionheader. - Derive the permalink from the returned post URN.
4.5 Media pipeline
POST /v1/mediaaccepts a file (or a source URL to fetch) → stores it in the object store undertenant/<id>/…→ returns{ media_id, url }with a public HTTPS URL.POST /v1/postsaccepts eithermedia_urls(already public) ormedia_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 createdpending; 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_idfilter 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
stateis 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
/v1API is server-to-server (no browser CORS needed).
7. Error handling
- Per-target status:
pending | publishing | published | failed, witherror_code+error_messageon failure. A post's overall status is derived (all published/partial/failed). - Expired/revoked token → account
status=expired, targets fail fast with areauth_requiredcode; 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).
/healthon 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/postswith an API key publishes to both, returns target ids, andGET /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).