Build your own app
Antiphony is the infrastructure, not the app. It gives you the call-and-response building blocks — audio posts, replies, per-request actor/DID attribution, transcription — and you build the experience, including accounts and profiles. Vox Pop is one app built on it; this repo ships another (apps/reference). Nothing stops you from building a third — a mobile client, a custom embed, a Slack bot, a static site that lists a creator’s prompts, a call-in voicemail wall.
This page covers what the core gives you and the getting-started path for building against it. For a worked example, see the reference app walkthrough.
What the open core gives you
Section titled “What the open core gives you”| Capability | Where it lives | What you call |
|---|---|---|
| Audio upload & playback | apps/core-api audio routes |
POST /api/v1/audio/upload (content-addressed — returns a blob ref, not a URL) and the audio resolver GET /api/v1/audio?url=… (302s to a short-lived signed URL). |
| Audio posts (call & response) | posts service |
A creator publishes a prompt (an audio.post with no reply); the audience records replies (audio.posts with a reply StrongRef). POST /api/v1/posts, GET /api/v1/posts, GET /api/v1/posts/{postId}, GET /api/v1/posts/{postId}/replies. |
| Audio enrichment | platform processing | Opt-in, out-of-band processing over a post’s audio: transcription (dev.antiphony.audio.transcript), denoising, silence trimming, and waveform generation. Request stages in the processing opt-in on POST /api/v1/posts (at create) or PATCH /api/v1/posts/{postId} (post-hoc); results are lifted into the embed’s view at read time — no extra call. Enrichment runs out of band, so you can also opt into a push when a stage settles (see Receiving enrichment webhooks) instead of polling. See Configuration for wiring providers. |
| Public REST API | apps/core-api |
The full /api/v1/* JSON surface with a consistent auth + envelope contract. See the API reference. |
| AT Protocol lexicons | lexicons/ + packages/shared |
MIT-licensed dev.antiphony.* records — audio.post, embed.audio, audio.transcript, actor.profile — plus the Zod schemas that mirror them. See The Antiphony lexicons. |
| Capture primitives | apps/reference/src/capture |
A neutral mic recorder, waveform helper, and audio player — the seed for a shared capture kit once a second client needs them. |
Start from the example app you can actually run
Section titled “Start from the example app you can actually run”The example to learn from lives right here in this repo: apps/reference, a small Vite + React SPA that records audio, uploads it, creates a post, and renders the hydrated view — with a ~40-line BFF holding the service token so the browser never sees it. It is deliberately unbranded — the point is to prove the protocol is usable by a client carrying no product’s design language. It depends on nothing a hosted product keeps private: same public endpoints, same @antiphony/shared types.
If you can build apps/reference, you can build your own surface. The reference app walkthrough reads it top to bottom.
Getting-started path
Section titled “Getting-started path”-
Run the core locally. Follow the quick start to get
/api/v1/*serving onlocalhost:8787. -
Authenticate. Your app presents its own service token and asserts which of its users is acting. That is the only path — there is no end-user sign-in against Antiphony. See Authentication.
Because the token authenticates the application, anyone holding it can act as any user in your tenancy, so it must stay server-side: calls to Antiphony come from your backend, never from a browser. That’s the shape
apps/referencedemonstrates. -
Upload audio, then create a post. Max 25 MB, and the MIME type must be one core-api accepts — see Limits.
Terminal window AUTH=(-H "Authorization: Bearer $TOKEN" -H "X-Antiphony-Acting-Actor: $YOUR_USER_ID")# upload hashes the bytes and returns a canonical blob refcurl -X POST $BASE/api/v1/audio/upload "${AUTH[@]}" -F "file=@clip.wav;type=audio/wav"# → { "success": true, "data": { "blob": { "$type": "blob", "ref": { "$link": "<cid>" }, "mimeType": "audio/wav", "size": 12345 } } }curl -X POST $BASE/api/v1/posts "${AUTH[@]}" \-H "Content-Type: application/json" \-d '{ "text": "Ask me anything", "embed": { "$type": "dev.antiphony.embed.audio", "audio": <blob from above> } }' -
Read it back and render it.
GET /api/v1/posts/{postId}returns a hydrated view — the post, an opaqueauthorId(your own user id, for you to join display identity onto), the audio embed with a signed playback URL, the lifted transcript (when ready), and per-viewer state. Drop that payload into your own UI. -
Thread replies. A reply is a post whose
reply.root/reply.parentpoint at the prompt; list them withGET /api/v1/posts/{postId}/replies. Replies are gated to a participant-only sub-thread — checkviewer.canReplyon the view before showing a reply control (see reply gating).
Receiving enrichment webhooks
Section titled “Receiving enrichment webhooks”Enrichment runs out of band: a create or PATCH returns as soon as the work is queued, and the stages settle later on the worker’s clock. You have two ways to learn a result landed:
- Pull — re-read
GET /api/v1/posts/{postId}and diff itsprocessingstate. Always available, but laggy and wasteful (most reads find nothing changed). - Push — have the core POST you a small signed webhook the moment each stage reaches a terminal state (
ready/failed/skipped). No polling; you act on the push and only fetch the artifact when you actually want it.
The webhook is an accelerator, not a source of truth. The authoritative record is always the post’s processing state in the view; the push just makes the common case fast. Delivery is best-effort, so your receiver must still be able to reconcile from the view — a dropped webhook is a latency regression, never lost data.
Wiring is per tenant and done by the operator, not through an API call: they set your receiver URL and a shared secret (see webhook configuration). A tenant with none configured simply gets no webhooks.
The payload
Section titled “The payload”One POST per stage that settles, Content-Type: application/json:
{ "postId": "3kb2…", "originAppId": "voxpop", "stage": "transcribe", "status": "ready", "occurredAt": "2026-07-19T14:03:11.204Z"}stage∈denoise | trim | transcribe | waveform;status∈ready | failed | skipped(pendingnever fires — it isn’t a settle).occurredAtis the server settle time, for ordering and replay detection.- The artifact itself (transcript text, signed URL, waveform peaks) is deliberately not included. The status tells you whether it’s worth fetching; the hydrated view (
GET /api/v1/posts/{postId}) is where you fetch it. Awaveform: skippedneeds no follow-up at all; atranscribe: readyinvites one, on your terms.
Verify the signature
Section titled “Verify the signature”Every request carries X-Antiphony-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body keyed by your webhook secret. Recompute it over the exact bytes you received and compare in constant time — this is what lets you trust the payload without a callback to the core.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string | undefined, secret: string): boolean { if (!header) return false; // no signature → reject (unsigned probe, missing header) const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);}Sign and compare over the raw body, before any JSON parse and re-serialize — a reparsed body’s bytes (key order, whitespace) won’t match. Also reject a request whose occurredAt is far from now to blunt replay.
Dedupe by “latest wins”, not “seen before”
Section titled “Dedupe by “latest wins”, not “seen before””The same stage’s webhook can arrive more than once, and “already saw this” is the wrong test: a byte-mutating stage re-running legitimately settles a derived stage (transcribe / waveform) a second time — ready → pending → ready — and that second ready describes the recomputed artifact, a new correct event, not a duplicate to suppress. Treat each event as latest-wins for (postId, stage), using occurredAt as the tiebreaker, rather than ignoring anything you’ve seen before.
Where next?
Section titled “Where next?”- Reference app — the worked
apps/referencewalkthrough. - The Antiphony lexicons — the records behind every payload above.
- API reference — auth, envelope, and the full endpoint surface.
- Architecture — the route → service → dependency seams you’d extend.