Language-agnostic contract for the DIDComm relay — an optional store-and-forward mailbox for DIDComm Messaging v2 encrypted envelopes. Senders POST an encrypted envelope; a self-custody recipient proves control of its DID (a signed challenge) and fetches the queued envelopes to unpack locally. The relay never holds private keys and cannot read the envelopes it stores.
The canonical implementation is services/didcomm/server/.
Related. The envelope crypto, key publication, and the sender/recipient/mediator client behaviour are specified in the DIDComm design doc. This document covers only the relay’s HTTP surface, mailbox routing, auth, and storage. The matching client methods are
publishDidComm/sendDidComm/receiveDidComm/mediateDidCommon Keymaster (and the Python port + SDK).
The relay sits on the network edge (typically behind the
Drawbridge /didcomm reverse proxy) and has
three jobs:
POST a DIDComm encrypted
(JWE) envelope. The relay parses the recipient DID(s) from the JWE
recipient key ids and stores one copy in each recipient’s mailbox. It
does not (and cannot) decrypt.It carries no key material and has no admin surface. The only trust it places in the network is the Gatekeeper it resolves DIDs against.
The relay is transport only — it has no knowledge of the DIDComm
Forward/routing or coordinate-mediation protocols. A mediator is just
an ordinary recipient (a Keymaster running mediateDidComm) that fetches
Forward envelopes addressed to itself, unpacks them, and re-POSTs the
inner envelope back to /messages for the final recipient.
Binds to ${ARCHON_BIND_ADDRESS}:${ARCHON_DIDCOMM_PORT} (default
0.0.0.0:4236). All /api/v1 request/response bodies are JSON except
the inbound envelope, which is application/didcomm-encrypted+json
(plain text). CORS is permissive (cors() defaults).
/api/v1)| Method | Path | Notes |
|---|---|---|
GET |
/health |
{ "ready": true }. Used by the container healthcheck. |
| Method | Path | Notes |
|---|---|---|
POST |
/api/v1/messages |
Store an encrypted envelope for its recipient(s). |
Request body, accepted in three forms (in priority order):
Content-Type: application/didcomm-encrypted+json — the raw packed
JWE string (the normal path; this is what sendDidComm sends).{ "message": "<packed JWE string>" }.The relay reads the recipient DIDs from the JWE recipients[].header.kid
values (kid is <did>#<fragment>; the DID is the part before #),
de-duplicates them, and stores one copy per recipient DID with a fresh
id.
Response 200: { "ids": ["<uuid>", ...] } — one id per recipient
mailbox the envelope was filed into. 400 { "error": "..." } if the
body is not a DIDComm encrypted envelope (no recipient kids).
Inbound delivery is intentionally open: the envelope is already encrypted to the recipient, and the recipient authenticates only to read its mailbox. A deployment that wants to limit who can deliver should do so at the proxy layer (e.g. Drawbridge).
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/challenge |
{ "challenge": "<base64url, 32 random bytes>" }. |
Each challenge is single-use and expires after 5 minutes. The
server records it; fetch/remove consume it (atomically removing it to
prevent replay).
| Method | Path | Notes |
|---|---|---|
POST |
/api/v1/messages/fetch |
List the caller’s queued envelopes. |
POST |
/api/v1/messages/remove |
Acknowledge (delete) handled envelopes. |
Both take an auth triple in the JSON body (see §3):
{ "did": "did:cid:…", "challenge": "<from /challenge>", "signature": "<hex>" }
fetch returns { "messages": [ { "id", "message", "received" }, … ] }
where message is the stored packed envelope and received is an ISO
8601 timestamp.
remove additionally takes { "ids": ["<id>", …] } and returns
{ "removed": <count> }. 400 if ids is not an array.
Typical recipient loop (receiveDidComm): GET /challenge → sign →
POST /messages/fetch → unpack each locally → GET /challenge again →
POST /messages/remove with the ids that unpacked. (A second challenge
is fetched for the remove call because each challenge is single-use.)
200 — success.400 — malformed body / not an encrypted envelope / missing
did,challenge,signature / ids not an array.401 — challenge unknown/expired/already used, or signature
verification failed.The error envelope is application/json { "error": "<message>" }.
There is no /metrics endpoint and no admin API.
express.text (for application/didcomm-encrypted+json and text/*)
and express.json are both capped at ARCHON_DIDCOMM_UPLOAD_LIMIT
(default 5mb).
Reading a mailbox requires proving control of the recipient DID:
GET /api/v1/challenge and receives a random challenge.signature = sign(hashMessage(challenge),
didSigningKey) — Archon’s standard ES256K (secp256k1) signature over
the SHA-256 message hash, the same primitive used everywhere else in
the wallet.POSTs { did, challenge, signature } to fetch/remove.401), resolves did via the Gatekeeper, and verifies the signature
against the DID document’s first verification method
(verificationMethod[0], which MUST be an EC/secp256k1
publicKeyJwk). Failure → 401.The relay authenticates only the reader; it does not authenticate senders (the envelope is already encrypted to the recipient).
The relay’s only routing input is the JWE recipient key ids — it has no
keys and never decrypts. recipientDidsFromEnvelope(packed):
type: "encrypted" with a
non-empty recipients array (else 400).recipients[].header.kid to its DID (kid.split('#')[0]).A copy is stored per recipient DID, so a multi-recipient envelope is fetchable by each addressee independently.
The store is an async interface so it can be backed by memory (default) or Redis; a Mongo backend can be added the same way.
interface MailboxStore {
add(recipient, envelope, id): Promise<StoredMessage>;
list(recipient): Promise<StoredMessage[]>; // prunes expired
remove(recipient, ids): Promise<number>; // returns count removed
issueChallenge(challenge): Promise<void>;
consumeChallenge(challenge): Promise<boolean>; // single-use, replay-safe
}
interface StoredMessage { id; recipient; envelope; received; } // received = ISO 8601
TTLs: messages 7 days (ARCHON_DIDCOMM_MESSAGE_TTL_MS), challenges
5 minutes (fixed).
In-process maps. list() lazily prunes envelopes older than the message
TTL; consumeChallenge() deletes the challenge and returns true only
if it was present and unexpired. Suitable for a single relay instance;
state is lost on restart.
ARCHON_DIDCOMM_DB=redis)Native key expiry. Namespace didcomm::
| Key | Type | TTL | Contents |
|---|---|---|---|
didcomm:inbox:<recipient> |
SET | message TTL | Message ids for the recipient. |
didcomm:msg:<recipient>:<id> |
STRING | message TTL (EX) |
StoredMessage JSON. |
didcomm:challenge:<challenge> |
STRING | challenge TTL (PX) |
"1"; consumed with GETDEL (single-use). |
list() reads the inbox set, MGETs the bodies, and lazily SREMs ids
whose bodies have already expired. A new implementation MUST use this
schema if it shares a Redis instance with the reference service.
GatekeeperClient.create,
retry-until-ready) — used to resolve recipient DIDs for signature
verification.memory or redis).${ARCHON_BIND_ADDRESS}:${ARCHON_DIDCOMM_PORT}.On a fatal startup error the process logs and exits 1.
| Variable | Default | Meaning |
|---|---|---|
ARCHON_DIDCOMM_PORT |
4236 |
HTTP listen port. |
ARCHON_BIND_ADDRESS |
0.0.0.0 |
Listen address. |
ARCHON_GATEKEEPER_URL |
http://localhost:4224 |
Gatekeeper used to resolve recipient DIDs. |
ARCHON_DIDCOMM_DB |
memory |
Mailbox store backend: memory or redis. |
ARCHON_REDIS_URL |
redis://localhost:6379 |
Redis URL when ARCHON_DIDCOMM_DB=redis. |
ARCHON_DIDCOMM_UPLOAD_LIMIT |
5mb |
Max inbound body size. |
ARCHON_DIDCOMM_MESSAGE_TTL_MS |
604800000 (7 days) |
Undelivered-envelope retention. |
In Docker, the host-side bind is ${ARCHON_DIDCOMM_HOST_BIND:-127.0.0.1}
(see sample.env and
docker/compose/didcomm.yml).
SIGTERM/SIGINT closes the HTTP listener; the Redis backend disconnects on
disconnect().
didcomm compose profile
(COMPOSE_PROFILES=didcomm)./didcomm (ARCHON_DIDCOMM_URL, default
http://didcomm:4236), so a node can expose a single public endpoint.
A DID advertises its mailbox by publishing a DIDCommMessaging service
endpoint (via publishDidComm) pointing at that public URL; sendDidComm
posts to <endpoint>/api/v1/messages. publishDidComm with no explicit
endpoint auto-discovers the endpoint from the gateway
(GET /api/v1/didcomm-endpoint), the same way publishLightning learns its
public host: it uses <ARCHON_DRAWBRIDGE_PUBLIC_HOST>/didcomm, falling back to
http://<onion>:<port>/didcomm resolved from the Tor hidden-service hostname
fronting Drawbridge when no public host is set. Pass an endpoint explicitly to
override (standalone relay, a different proxy, etc.).ghcr.io/archetech/didcommmailbox.test.ts) and a full two-identity-over-HTTP e2e
(e2e.test.ts) covering delivery, signed-challenge fetch, the
Alice→mediator→Bob Forward path, coordinate-mediation enrollment, and
forged-fetch rejection.A conformant implementation MUST:
/health
shape the healthcheck depends on and the three accepted inbound body
forms.verificationMethod[0] (§3).