Posta — wire protocol proposal 1
Posta is a wire protocol for cryptographically authenticated messaging between participants identified by HTTPS URLs. A participant publishes Ed25519 public keys at their URL and receives signed envelopes via HTTPS POST. The protocol provides per-message origin authentication; it does not provide payload encryption, multi-recipient envelopes, or delivery acknowledgement. Trust bootstraps from HTTPS; no additional PKI, directory service, or central infrastructure is defined.
1. Status
This document is proposal 1 of the Posta wire protocol. It specifies wire format version 1, the only currently defined format. Document revisions increment the proposal number (proposal 2, 3, …); the wire format version increments only on changes that are not backward-compatible (§10).
The Go code under pkg/posta is a reference implementation; this document is the source of truth. Test vectors under testdata/vectors/ form the conformance contract: an implementation that produces and accepts the byte sequences in those files, and applies the rejection rules listed here, conforms to wire format version 1.
2. Overview
A participant in Posta is a single URL. The URL serves three roles simultaneously:
- Identity. The URL is the participant's identifier. There is no separate username, key fingerprint, or directory entry.
- Inbox. Sending a message means HTTPS POST to the URL.
- Key publication. HTTPS GET on the URL returns an actor doc listing the URL's published public keys.
Trust bootstraps from HTTPS: an implementation that trusts HTTPS to a hostname can trust whatever public keys that hostname serves at that URL. There are no relays, no central directory, and no PKI beyond the Web PKI used by HTTPS.
3. Conventions
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as defined in RFC 2119. All times are encoded as RFC 3339 timestamps with a numeric offset or Z. All binary values are encoded as standard base64 (RFC 4648 §4, with padding) — not URL-safe base64. All JSON is UTF-8.
4. URLs
A URL identifying a participant MUST:
- Use the
httpsscheme. - Resolve via DNS to a host that is publicly reachable on the internet (or, for testing, on a network shared with all intended participants).
- Present a TLS certificate valid for the URL's host as anchored in the verifier's local trust store (typically the Web PKI).
- Be stable: a participant changing their URL is, for the purposes of this protocol, a new identity.
The URL's path MAY be anything the host chooses. https://alice.example/inbox, https://alice.example, and https://example.org/users/alice are all valid.
4.1 Canonical URL
The comparisons in §5.2.1 (actor-doc url) and §7.2 (envelope recipient) are performed against the canonical form of each URL. Canonicalization is a deterministic, total algorithm: every input string produces either a canonical URL string or one of the rejection categories listed below. The wire-level fields that carry participant URLs — actor-doc url (§5.2.1), envelope sender and recipient (§6.2.1) — MUST already be in canonical form when transmitted; a non-canonical value will fail downstream verification (a non-canonical recipient cannot match the receiver's canonical URL → §7.2 wrong-recipient; a non-canonical sender cannot be resolved to an actor doc → §7.3 bad-signature; a non-canonical actor-doc url triggers §5.2.1 rejection, which cascades through §7.3 to bad-signature).
A canonical Posta URL has the structure https://<host>[:port]<path> and satisfies all of the following:
-
Scheme. The input scheme MUST be
https(case-insensitive on input; the canonical form is lowercasehttps). Any other scheme → rejectnon-https-scheme. -
Userinfo. The input MUST NOT contain a userinfo component (
user@host,user:pass@host). Rejectuserinfo-present. -
Host.
- The host MUST be a DNS name. IP-address literals (IPv4 dotted-quad, IPv6 bracketed) MUST be rejected as
ip-literal-host. (Future wire format versions MAY relax this and specify IP-literal canonicalization.) - Host labels MUST be representable as A-labels per IDNA 2008 / [UTS #46], processing option nontransitional. U-labels in the input MUST be converted to A-labels before comparison and storage; mixed-form inputs are converted segment-by-segment.
- After A-label conversion the host MUST be compared and stored as lowercase ASCII.
- Empty host or syntactically invalid label → reject
malformed-host.
- The host MUST be a DNS name. IP-address literals (IPv4 dotted-quad, IPv6 bracketed) MUST be rejected as
-
Port. The default port
:443MUST be omitted from the canonical form. Any other port is preserved as:<decimal>with no leading zeros. Non-numeric, zero, or out-of-range ports (not 1–65535) → rejectmalformed-port. -
Path.
.and..segments MUST be resolved per RFC 3986 §5.2.4. The canonical form contains no dot-segments.- Any trailing
/MUST be removed; a path consisting solely of/becomes empty.https://example.com/inbox/,https://example.com/inbox, andhttps://example.com/inbox/./all canonicalize tohttps://example.com/inbox. - Percent-encoded sequences MUST be syntactically valid (
%XXwith two hex digits). Invalid sequences → rejectmalformed-path. - Hex digits in percent-encodings MUST be uppercase.
%2fis normalized to%2F. - Unreserved characters (RFC 3986 §2.3:
A-Z,a-z,0-9,-,.,_,~) MUST NOT appear percent-encoded; if present, they MUST be decoded.%7Eis normalized to~. - Path comparison is case-sensitive (RFC 3986 §6.2.2.1).
https://example.com/Aliceandhttps://example.com/aliceare distinct identities. Operators of multi-tenant hosts SHOULD avoid case-only-distinct paths in user-assigned URLs.
-
Query. The input MUST NOT contain a query component. An unencoded
?→ rejectquery-present. (A percent-encoded%3Fin the path is a literal?character and is not a query delimiter.) -
Fragment. The input MUST NOT contain a fragment. An unencoded
#→ rejectfragment-present. (%23in the path is a literal#.)
Conformance vectors for this algorithm live under testdata/vectors/url-canonical/ (§15). The vectors are normative: an implementation that reproduces every input → canonical mapping and every input → reject category in those files is canonicalization-conformant.
4.2 Display form
For human-readable rendering — TUI chrome, log lines, share strings, business-card-style identifiers — Posta defines a display form derived from the canonical URL by:
- Removing the
https://scheme prefix. - Leaving the empty trailing path empty (no slash).
| Canonical | Display form | U-label rendering (optional) |
|---|---|---|
https://alice.example |
alice.example |
— |
https://posta.no/u/arne |
posta.no/u/arne |
— |
https://posta.no:8443/u/arne |
posta.no:8443/u/arne |
— |
https://xn--caf-dma.example |
xn--caf-dma.example |
café.example |
The display form is bijective with the canonical form: any display string can be converted back by prepending https://, and any canonical URL has exactly one display string. The U-label rendering column is a separate, optional presentation layer described below — it is not the display form, and it MUST NOT appear in any wire field.
Clients MAY render U-labels (e.g., café.example for xn--caf-dma.example) in display contexts, provided storage and identity comparison remain on the A-label canonical form and the U-label rendering follows IDNA 2008 / UTS #46 conversion in the same direction. Mixed-script hosts SHOULD be flagged or punycode-displayed per the application's IDN spoofing policy; this is a UI concern, not a protocol one.
CLIs and other user-input surfaces SHOULD accept the display form and prepend https:// before canonicalization. The display form MUST NOT appear in sender, recipient, or actor-doc url — those fields carry the full canonical URL.
5. Discovery: GET on the URL
A participant URL MUST respond to HTTPS GET with their actor doc: a JSON object describing the participant.
5.1 Request
GET <url> HTTP/1.1
The response is application/posta+json. The Accept request header is ignored.
5.2 Response
200 OK with a JSON body of the following shape:
{
"url": "https://alice.example/inbox",
"name": "Alice",
"about": "free-form bio",
"avatar": "https://alice.example/me.jpg",
"keys": [
{
"id": "2026-05-a",
"algorithm": "ed25519",
"publicKey": "<base64>"
}
]
}
5.2.1 Required fields
-
url— the participant's canonical URL, in the form defined by §4.1. Verifiers MUST canonicalize bothurland the URL they fetched from, and MUST reject the actor doc if the two canonical strings are not byte-for-byte equal, or if either input is rejected by §4.1. This defends against actor-doc misconfiguration on multi-tenant hosts. -
keys— a non-empty array of key objects. Each key object MUST contain:id— a stable string up to 64 characters, unique across all keys ever published by this URL.publicKey— base64-encoded raw 32-byte Ed25519 public key.algorithm— OPTIONAL in wire format 1. If present, MUST be the string"ed25519"; if absent,"ed25519"is implied (v1 has no other algorithm). v1 verifiers MUST reject any key whosealgorithmis present and not"ed25519". Future wire format versions MAY define additional algorithms (signaled via the envelope'svfield, not this string).
Unknown fields inside a key object MUST be tolerated by verifiers, for the same forward-compatibility reason as unknown top-level fields.
5.2.2 Optional fields (display-only)
name— a display string (max 280 chars). Verifiers MUST NOT usenamefor identification, dedup, sorting-as-identity, or any trust decision. Identity is the URL.about— free-form bio (max 280 chars).avatar— a URL to an image. Clients MAY display.kind— a participant-class hint string (max 64 chars). Conventional values include"person","room","agent", but the field is an open enum: any string is permitted, and clients MUST tolerate unknown values. Verifiers MUST NOT usekindfor identification, dedup, trust decisions, rate limiting, reply routing, or any other protocol behavior. Likenameandavatar,kindis a hint to renderers — never a switch on the wire. Behavior that depends on participant class belongs in message-level conventions (e.g., a payloadkindfield on a specific message), not in the actor doc.
Unknown top-level fields MUST be tolerated by verifiers and MAY be preserved by clients.
5.3 Caching
Servers SHOULD set Cache-Control and ETag headers on actor docs to enable upstream caching (CDNs, proxies, browsers). A cached actor doc is stale when the time since its local fetch exceeds the timestamp window (§7.5); verifiers MUST NOT use a stale doc, and SHOULD use HTTP conditional requests (If-None-Match / If-Modified-Since) to refresh efficiently. Verifiers MUST also refetch when a received message references a keyId not present in the cached doc (see §7.3).
The timestamp window bounds both replay protection (§7.5) and key-management propagation (§9): one constant, one freshness guarantee.
6. Sending: POST a message
To send a message to URL R, the sender constructs an envelope and POSTs it to R.
6.1 Request
POST <recipient-url> HTTP/1.1
Content-Type: application/posta+json
Posta-Signature: <base64-ed25519-signature-over-raw-body-bytes>
<raw envelope bytes>
The Content-Type header MUST be application/posta+json. Senders SHOULD send no parameters; receivers MUST consider only the bare media type and MUST tolerate (and ignore) any parameters such as charset=utf-8.
6.2 Envelope
The body is a JSON object:
{
"v": 1,
"sender": "https://alice.example/inbox",
"recipient": "https://bob.example/inbox",
"timestamp": "2026-05-06T14:30:00Z",
"id": "01HZ7K3F8QR4MQM7QF2X3WX0V1",
"keyId": "2026-05-a",
"inReplyTo": "01HZ6XYZ...",
"payload": <any valid JSON value>
}
6.2.1 Required fields
v— integer, MUST be1for this version.sender— the sender's own URL.recipient— the URL the message is being POSTed to.timestamp— RFC 3339 timestamp, sender's wall clock at send time.id— a string up to 256 characters, unique per sender. Each sender independently namespaces their ownidvalues; the(sender, id)tuple is therefore globally unique. Senders SHOULD use ULID or UUIDv7 for natural ordering.keyId— theidof the sender's key being used to sign this message. MUST match an entry in the sender's actor doc at the time of sending.payload— any valid JSON value. Opaque to the protocol; application-defined.
6.2.2 Optional fields
inReplyTo— theidof a prior message this is a reply to. Receivers MUST NOT enforce that the referenced message exists.
Unknown fields MUST be tolerated by receivers and MUST be preserved if the message is ever forwarded or exported.
6.3 Signature
The value of the Posta-Signature header is the base64 encoding of the Ed25519 signature, computed using the sender's private key whose public half is published with keyId matching the envelope's keyId, over the raw body bytes of the HTTP request (the exact bytes the receiver will read from the request body).
There is no canonicalization. Implementations MUST NOT re-serialize the envelope before signing or verifying. Senders SHOULD use compact JSON (no extraneous whitespace) for predictability and for inclusion in test vectors.
7. Receiving: verification procedure
Upon receiving a POST, the receiver MUST perform the following checks in order. Failure at any step results in the corresponding HTTP status and error code (§8). Subsequent steps are skipped.
7.1 Parse and shape
- Verify the request
Content-Type(the bare media type, after stripping any parameters) equalsapplication/posta+json. Mismatch →415 unsupported-media-type. This check runs first so malformed transports are rejected before the body is read. - Read the request body, keeping the raw bytes. If the body exceeds the §11 cap, reject as
413 payload-too-large. Receivers SHOULD refuse to buffer further than the cap so an oversized POST cannot consume unbounded memory. - Parse as JSON. Failure →
400 malformed-envelope. - Verify all required fields (§6.2.1) are present and well-typed. Failure →
400 malformed-envelope. - Verify
v == 1. Failure →400 unsupported-version.
7.2 Recipient match
Canonicalize the envelope's recipient field and the receiver's own URL per §4.1. The two canonical strings MUST be byte-for-byte equal. A mismatch — or a recipient value rejected by §4.1 — fails this check → 421 wrong-recipient.
7.3 Key resolution
Look up the sender's actor doc in the local cache.
- If absent or stale per §5.3: fetch
GET <sender>. On HTTP failure or invalid actor doc →401 bad-signature. - If the
keyIdfrom the envelope is not present in the cached actor doc: fetch once. If still absent in the freshly-fetched doc →401 unknown-key.
The resolved key MUST be Ed25519: if the key entry carries an algorithm field (§5.2.1) it MUST equal "ed25519"; if absent, "ed25519" is implied.
7.4 Signature verification
Verify that the value of Posta-Signature is a valid Ed25519 signature, over the raw body bytes received in §7.1, against the public key resolved in §7.3. Failure → 401 bad-signature.
7.5 Timestamp window
Compute |now - timestamp|. If greater than 5 minutes (300 seconds), reject as 401 stale-timestamp. The window is fixed at 5 minutes; receivers that deviate are non-conformant.
7.6 Replay (deduplication)
Reject if a message with the same (sender, id) pair has been previously accepted by this receiver. Reject as 409 duplicate-id.
The receiver's message store MAY serve as the seen-set; explicit retention policy MUST keep each (sender, id) pair at least until 5 minutes past the envelope's timestamp — past that instant, no replay of the same bytes can pass §7.5. With ±5 min skew permitted in both directions, this is equivalent to retaining for twice the timestamp window from the moment of acceptance in the worst case. Receivers SHOULD keep (sender, id) pairs substantially longer (default: forever).
7.7 Persist and ack
If all checks above pass, the receiver MUST durably store the message (envelope, raw signed bytes, signature) before responding 204 No Content with empty body. A receiver MUST NOT return 2xx and then fail to persist.
The 2xx response is the delivery confirmation. v1 has no signed-receipt mechanism; applications that need cryptographic non-repudiation MAY implement an ack-message convention as a follow-up POST in the reverse direction.
8. Errors
Receiver error responses MUST use the HTTP status code listed below and MUST include a JSON body:
{ "error": "<stable-code>" }
A receiver MAY include an additional human-readable "message" field for diagnostics; senders MUST NOT depend on its content.
| Status | Code | Meaning |
|---|---|---|
| 400 | malformed-envelope |
JSON parse failure, missing/ill-typed required field. |
| 400 | unsupported-version |
v not recognized. |
| 401 | bad-signature |
Signature failed Ed25519 verification. |
| 401 | stale-timestamp |
Outside the timestamp skew window. |
| 401 | unknown-key |
keyId absent from sender's actor doc after one re-fetch. |
| 409 | duplicate-id |
(sender, id) already accepted. |
| 413 | payload-too-large |
Request body exceeds the §11 cap. |
| 415 | unsupported-media-type |
Request Content-Type is not application/posta+json (after stripping parameters). |
| 421 | wrong-recipient |
recipient does not match the receiver's URL. |
| 5xx | internal |
Receiver-side problem; sender SHOULD retry with backoff. |
Error code strings are stable. Future versions may extend the list; renaming is a breaking change.
9. Key management
A participant's actor doc lists the keys currently authoritative for the URL. Key management is editing that list:
- Add a key. Publish it in the
keysarray. Senders MAY immediately sign with the newkeyId; verifiers that don't have it cached trigger §7.3's single re-fetch on first receipt. - Stop honoring a key (planned rotation, device retirement, or compromise response). Remove it from the
keysarray. Verifiers stop accepting signatures from the removed key within the timestamp window (§7.5), because §5.3 caps verifier-side cache age at that window. Subsequent messages signed with the removedkeyIdare rejected asunknown-key.
A participant MAY keep multiple keys listed indefinitely — e.g., one per device in a multi-device fleet; signatures from any listed key are equally valid, and the protocol does not distinguish "current" from "legacy." There is no rotation announcement, no negotiation, no retain window, and no global cache invalidation. The actor doc is the source of truth, and the timestamp window bounds how stale a verifier's view of it can be.
10. Versioning
The envelope's v field is the wire format version. This document (proposal 1) specifies wire format version 1, the only currently defined format. Future revisions of this document — proposal 2, 3, … — may either refine wire format 1 in backward-compatible ways (clarifications, additional optional fields, new payload kinds) or introduce a new wire format version. Future wire formats MUST:
- Be served at the same URL (the URL has no version prefix).
- Be detectable by the
vfield of the envelope. - Either be a strict superset of wire format 1 (with v1 receivers tolerating unknown fields per §6.2.2) or use a new
vinteger (causing v1-only receivers to reject withunsupported-version).
The actor doc has no version field; it is forward-compatible by tolerating unknown fields.
11. Limits
-
POST body size: 64 KB hard cap. The signed body MUST NOT exceed 65,536 bytes (64 KiB). Senders MUST NOT POST larger envelopes; receivers MUST reject larger bodies with
413 payload-too-large(§8). The cap is deliberately small: it is enough for any plain text message, threading metadata, or small structured payload, and aggressively too small for inline binary content. Larger content — files, long-form text, anything beyond the cap — travels by reference, not by value. See §13.3 (posta.link/v1) for the canonical link payload kind.The cap applies uniformly to all envelopes, including room broadcast wrappers (§14). Because base64-encoding inflates
envelopeBytesby ~33%, the effective inner-envelope size that fits through a one-hop wrapper is approximately 46 KB. Senders targeting rooms SHOULD allow for this overhead or useposta.link/v1for anything that approaches the cap. -
Maximum actor doc size: implementations SHOULD accept at least 64 KB. Verifiers MAY enforce a smaller or larger limit; an actor-doc response exceeding the verifier's limit is treated as a fetch failure under §7.3 (cascading to
bad-signature). No specific error code is defined for this case. -
idlength: maximum 256 bytes. -
keyIdlength: maximum 64 bytes.
12. Security considerations
- Identity is the URL. Display fields (
name,about,avatar) are advisory. Implementations that surface them in UI MUST also surface the URL alongside, and MUST key all storage and trust decisions on the URL. - HTTPS is the trust anchor. The protocol's origin authentication relies entirely on HTTPS to authenticate the sender's hostname during actor-doc fetch. A compromise of the sender's TLS (stolen cert, hijacked DNS, malicious CA) is a compromise of Posta identity.
- Actor-doc URL self-attestation (§5.2.1). The byte-equality check between the actor doc's
urlfield and the URL the verifier fetched from is the only defense against a multi-tenant host serving a doc that claims another tenant's identity. A verifier that skips this check trusts whatever public keys the host serves at the requested URL, regardless of which tenant configured them. - Replay protection requires durable id-storage. Receivers that prune old messages must retain each
(sender, id)pair until at least 5 minutes past the envelope'stimestamp(see §7.6); ideally substantially longer. - Cross-recipient replay is prevented by including
recipientin the signed envelope. Implementations MUST NOT skip §7.2. - Key-removal latency. Removing a key from the actor doc takes effect within one timestamp window (§5.3): a verifier whose cache was fresh just before the removal MAY continue to accept signatures from that key for up to the timestamp window afterward. This bounds the compromise-response latency for a stolen key — a stronger response (e.g., abandoning the URL) is the only way to eliminate the residual window.
- Rate limiting is the receiver's responsibility. The protocol does not define abuse-mitigation primitives. Default reference behavior is per-sender rate limiting; stricter modes (
acl=contacts) are local policy. - Private keys never leave the daemon process. No protocol message includes private key material.
13. Payload conventions
The envelope's payload field (§6.2.1) is any valid JSON value, opaque to the protocol. This section defines an OPTIONAL convention for self-describing payload objects, and lists the well-known kinds defined by this specification.
The convention is opt-in: a payload that does not follow it is still a valid envelope. The convention exists so that two implementations written by different people can interoperate at the application layer without out-of-band coordination.
13.1 The kind discriminator
When a payload is a JSON object, it SHOULD carry a kind field — a string that identifies the payload's schema:
{
"kind": "posta.text/v1",
"body": "hello"
}
Receivers MAY route, render, or store payloads based on kind. Receivers MUST treat unknown kind values as opaque: the payload is still a valid envelope and MUST be persisted with the same durability as any other message (§7.7); the receiver simply MAY decline to render it.
13.1.1 Namespacing
The posta.* namespace is reserved for kinds defined by this specification. Three well-known kinds are defined in this proposal: posta.text/v1 (§13.2), posta.link/v1 (§13.3), and posta.room.broadcast/v1 (§14).
Community-defined kinds SHOULD use a namespace the publisher controls — a reverse-DNS prefix (com.example.app.event/v1), a project name (myapp.reaction/v1), or any other unambiguous identifier. There is no central registry; collisions are an application concern.
13.1.2 Versioning
Kinds SHOULD carry a version suffix (e.g., /v1, /v2). A new version is appropriate when an incompatible change is needed; backward-compatible additions (new optional fields) SHOULD reuse the existing version. Receivers that recognize an older version of a kind but not a newer one MUST treat the unknown version as opaque per §13.1.
13.1.3 Surfacing unknown kinds
User-facing clients SHOULD indicate to the user that a message with an unrecognized kind has arrived, rather than omitting it from view entirely. A minimal placeholder (for example, "message of kind X: no renderer available") satisfies this; the goal is that the user knows something is waiting and can read it in a different client, or after a future update.
13.2 posta.text/v1 — plain text messages
The well-known kind for human-to-human plain text messages:
{
"kind": "posta.text/v1",
"body": "hello, world"
}
13.2.1 Required fields
kind— MUST be the string"posta.text/v1".body— a UTF-8 string. The message text.
body is plain UTF-8 text with no inline formatting markup (no Markdown, no HTML). A renderer that displays body as plain text — preserving line breaks, escaping HTML if rendered in a web context — is conformant.
There is no length limit beyond the envelope's overall body limit (§11). Receivers MAY truncate for display.
Additional optional fields MAY be added in future revisions of this kind, under the same posta.text/v1 identifier, provided they remain backward-compatible per §13.1.2.
13.3 posta.link/v1 — references to external content
The well-known kind for messages whose primary content is a reference to fetchable content. posta.link/v1 is the canonical answer to the §11 body cap: files, long-form text, and any other content too large for the inline JSON envelope travels as a URL.
{
"kind": "posta.link/v1",
"url": "https://files.example/abc123.jpg",
"mediaType": "image/jpeg",
"name": "photo.jpg",
"size": 524288
}
13.3.1 Required fields
kind— MUST be the string"posta.link/v1".url— the URL to fetch. MUST use thehttpsscheme. Need not be a Posta participant URL; any HTTPS resource (CDN, blob store, web page) is valid. The URL is opaque content reference, not a Posta identity — receivers MUST NOT canonicalize it per §4.1.
13.3.2 Optional fields
mediaType— an IANA media type (RFC 6838) advising the receiver how to render or handle the content. Advisory only; the authoritativeContent-Typeis whatever the URL's server returns.name— a human-readable filename or label (max 280 chars).size— expected byte size of the resource, as a non-negative integer. Advisory; receivers MAY use it to gate auto-fetch decisions, but MUST NOT trust it for security or storage allocation.alt— accessibility/alt text (max 280 chars).
Receivers MUST tolerate unknown fields. Adding a new required field requires a new kind value per §13.1.2.
13.3.3 Fetch policy
Auto-fetch reveals to the host of url that the recipient saw the message — a side-channel the sender can exploit deliberately. Receivers SHOULD NOT auto-fetch for unknown senders. A privacy-respecting default is to fetch only on explicit user gesture, or to auto-fetch only when mediaType matches a narrow allow-list (e.g., image/* for chat clients) and the URL host is on a user-maintained trust list.
A verified envelope proves the sender wrote this URL; it does not prove the content at the URL is what the sender intended. The host can serve different bytes to different fetchers and change them over time. Senders that need stronger content-integrity guarantees can include a hash alongside the URL — this is application convention; posta.link/v1 does not standardize a hash field in this proposal.
14. Room broadcast wrapper
A room is a Posta participant whose application behavior is to re-broadcast received messages to a set of subscriber URLs. A room is a normal URL: it has an actor doc, a keypair, and an inbox; nothing in §§4–10 distinguishes a room from a person on the wire. This section specifies the wrapper payload convention that lets a recipient verify the origin of a message carried through a room — i.e., confirm that the room is delivering an unmodified message authored by some other URL. The wrapper is the well-known payload kind posta.room.broadcast/v1 under the convention defined in §13.
Allow-list management, invitation and leave UX, system-message vocabulary, backfill, and silent-acceptance of non-member POSTs are all local policy choices, not protocol. This section specifies only the wire shape required for cross-implementation interop on the verifiable-origin property.
14.1 The wrapper payload
When a room broadcasts a message originally authored by some other URL S, it constructs a normal envelope (per §6) with the room's URL as sender and the recipient member's URL as recipient, and the following object as payload:
{
"kind": "posta.room.broadcast/v1",
"envelopeBytes": "<base64 of S's exact original POST body bytes>",
"signature": "<base64 of S's Posta-Signature value>"
}
14.1.1 Required fields
kind— MUST be the string"posta.room.broadcast/v1". This discriminator lets receivers identify wrapped broadcasts among arbitrary application payloads (per §13.1).envelopeBytes— base64 encoding (RFC 4648 §4, with padding) of the exact bytes ofS's original HTTP POST body. The bytes MUST NOT be re-serialized, re-ordered, or canonicalized in any way. See §14.3 for why.signature— base64 encoding of the value ofS'sPosta-Signatureheader on the original POST.
Rooms MAY include additional fields in the wrapper payload object. Receivers MUST tolerate unknown fields. New required fields, if ever introduced, MUST be carried under a new kind value.
14.2 Verifying a wrapped message
A receiver of a wrapped broadcast performs two verifications:
- Outer envelope. Verify the room's envelope per §7 in full. This proves the room delivered the message and commits the room to the wrapped content as carried.
- Inner envelope. After §7 succeeds, the receiving application MAY additionally verify the original sender's signature:
- Base64-decode
payload.envelopeBytesto obtain the original POST body bytes. - Parse those bytes as an envelope (§6.2). Use the inner envelope's
senderandkeyIdto resolve the original sender's public key, following the §7.3 procedure with the innersenderas the URL to fetch. - Base64-decode
payload.signature. - Verify the signature is valid Ed25519 over the decoded
envelopeBytesagainst the resolved public key (per §7.4).
- Base64-decode
The inner envelope is not a Posta receive — it is application evidence. Receivers MUST NOT apply §7.2 (recipient match) to the inner envelope: its recipient is the room URL, not the receiver's own URL. Receivers SHOULD NOT apply §7.5 (timestamp skew) to the inner envelope either; rooms may legitimately deliver outside the inner timestamp's skew window, and the outer envelope's timestamp is what bounds replay.
14.3 Why bytes, not objects
envelopeBytes carries the exact bytes the original sender signed. Re-serializing the inner envelope as a JSON object inside the wrapper would silently break inner-signature verification: Posta signatures are over raw body bytes (§6.3), and JSON re-serialization may change whitespace, key ordering, escape choices, or number formatting — none of which encoding/json (or any other JSON library) is required to preserve. A wrapper that carries a parsed-and-re-serialized envelope CANNOT be verified by a downstream recipient.
This is the same reason §6.3 forbids re-serialization for primary envelopes. The rule extends recursively: anywhere a Posta envelope is forwarded for later verification, it MUST travel as raw bytes.
14.4 What the wrapper guarantees
A correctly verified wrapped broadcast (both outer and inner verifications passing) proves:
- Origin. The original sender authored the inner envelope. The room cannot forge content "from Alice" because the room does not hold Alice's private key.
- Delivery. The room performed this broadcast. The room's outer signature commits the room to having transmitted exactly these inner bytes to this recipient.
It does NOT prove:
- That the room broadcast the message to anyone other than this receiver. Receivers cannot cryptographically detect selective broadcasting from observed traffic alone.
- That all room members received the message. A malicious or buggy room operator may drop messages to specific recipients without observable trace.
- That the inner envelope was originally addressed to this receiver. The inner
recipientis the room URL.
Receivers that need stronger delivery guarantees MAY implement application-level acknowledgement messages (cf. §7.7).
14.5 Threading across the wrapper
When a member composes a reply to a wrapped broadcast, the reply's inReplyTo (§6.2.2) MUST reference the inner envelope's id, not the outer wrapper's id. The outer envelope's id is generated fresh per outbound POST — the room emits one wrapper per member, each with a distinct id — so wrapper ids are per-recipient delivery artifacts and differ across members; only the inner author id is stable across the room. Clients that surface wrapper ids in user-visible affordances (permalinks, deep links) MUST use the inner id.
15. Test vectors
Conformance is defined by the test vectors under testdata/vectors/. Each vector is a JSON file describing inputs and expected outcome. An implementation that:
- Produces the exact
Posta-Signatureheader value given the (envelope bytes, private key) inputs of every "produce" vector, AND - Returns the expected status code and
errorvalue for every "verify" vector, AND - For wrapper vectors (§14), preserves
envelopeBytesverbatim and verifies the inner signature against the inner public key, AND - For canonicalization vectors under
testdata/vectors/url-canonical/, produces the listedcanonicaloutput for accept vectors and the listedrejectcategory for reject vectors (§4.1),
is conformant with v1.
The vector files are normative. This document and the vectors must agree; in case of discrepancy, the vectors describe behavior and this document MUST be corrected to match (after which both are updated together).