An HTTP relay for reliable asynchronous message passing between producers and consumers, with store-and-forward semantics and explicit acknowledgment.
Built primarily for Pubky applications, but usable as a general-purpose relay.
An HTTP relay enables decoupled communication between distributed services. Producers POST messages to a channel; consumers GET them. The relay handles the coordination, storage, and delivery confirmation.
Use cases:
- Mobile apps that need reliable message delivery despite OS backgrounding
- Services that can't communicate directly (NAT traversal, firewall bypass)
- Decoupled microservices with delivery confirmation requirements
- Store-and-forward - Messages persist until explicitly acknowledged
- At-least-once delivery - Consumers can retry; message stays available until ACKed
- Delivery confirmation - Producers can block until consumer ACKs, or check status
- Mobile-friendly timeouts - 25s default stays under typical proxy limits (nginx, Cloudflare)
- Content-Type preservation - Forwards producer's Content-Type to consumer
- Legacy compatibility -
/link/{id}endpoint for existing integrations. See old codebase
cargo install http-relayOr add as a dependency:
[dependencies]
http-relay = "0.7"Cargo features (all on by default):
persist: SQLite persistence (--persist-db). Without it, entries live in memory only and the flag doesn't exist.link-compat: the deprecated/link/{id}endpoints.cli: thehttp-relaybinary.
The server feature has no effect; the server is always built.
# Default: bind to 127.0.0.1:8080 (localhost only)
http-relay
# Bind to all interfaces (for Docker, behind a reverse proxy)
http-relay --bind 0.0.0.0
# Custom configuration
http-relay --bind 0.0.0.0 --port 15412 --inbox-cache-ttl 300 --inbox-timeout 25 -vvOptions:
| Flag | Description | Default |
|---|---|---|
--bind <ADDR> |
Bind address | 127.0.0.1 |
--port <PORT> |
HTTP port (0 = random) | 8080 |
--inbox-cache-ttl <SECS> |
Message TTL for inbox | 300 |
--inbox-timeout <SECS> |
Inbox long-poll timeout | 25 |
--link-timeout <SECS> |
Link endpoint timeout | 600 |
--max-body-size <BYTES> |
Max request body size | 2048 (2KB) |
--max-entries <N> |
Max stored entries, and max IDs with waiting requests. When the store is full, expired and ACKed entries make room; if there are none, POST to a new ID returns 503 |
10000 |
--max-entries-per-client <N> |
Max unACKed messages one client can have stored; further POSTs return 429. A client is an IPv4 address or an IPv6 /56 network |
100 |
--persist-db <PATH> |
SQLite database path for persistence (persist feature) |
(in-memory) |
--cors-allow-all |
Enable permissive CORS (Access-Control-Allow-Origin: *) |
off |
--trusted-proxy <CIDR> |
Identify clients behind this proxy by X-Forwarded-For. Repeatable. See DEPLOY.md |
off (TCP peer) |
-v |
Verbosity (repeat for more) | warn |
-q, --quiet |
Silence output | - |
use http_relay::HttpRelayBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let relay = HttpRelayBuilder::default()
.http_port(15412)
.run()
.await?;
println!("Running at {}", relay.local_link_url());
tokio::signal::ctrl_c().await?;
relay.shutdown().await
}Production deployments require a reverse proxy that parses HTTP. The proxy terminates TLS and protects the relay from slow clients, which the relay doesn't handle itself. See DEPLOY.md for the requirements, an Nginx setup, and how the relay identifies clients behind a proxy.
The primary API. Store-and-forward with explicit acknowledgment.
| Method | Endpoint | Description |
|---|---|---|
POST |
/inbox/{id} |
Store message (returns 200 immediately) |
GET |
/inbox/{id} |
Retrieve message (long-poll, waits up to 25s) |
DELETE |
/inbox/{id} |
ACK - confirms delivery |
GET |
/inbox/{id}/ack |
Returns true or false (was message ACKed?) |
GET |
/inbox/{id}/await |
Block until ACKed (25s default timeout) |
Inbox IDs act as shared secrets. Anyone who knows an ID can read/write/ACK
that inbox. IDs should be cryptographically random (e.g., 128-bit UUIDs).
Predictable IDs allow attackers to intercept or acknowledge messages.
Messages persist in plaintext until they're ACKed or expire. With
--persist-db, deleted bodies can remain on disk after that. Do not relay
sensitive one-time credentials unless encryption is applied at the
application layer.
Producer stores a message. Returns immediately without waiting for a consumer. New value overrides the old value if it already exists.
curl -X POST http://localhost:8080/inbox/my-channel \
-H "Content-Type: application/json" \
-d '{"hello": "world"}'Responses:
200 OK- Message stored successfully429 Too Many Requests- This client already has the maximum number of unACKed messages stored (--max-entries-per-client). Replacing one of them is still allowed. Retry after one is ACKed or expires; theRetry-Afterheader gives the seconds until the first one expires.503 Service Unavailable- Server at capacity: every stored entry still holds an undelivered message. Existing messages are kept; retry later.
Consumer retrieves the stored message. If no message is available, waits up to 25 seconds (configurable) for one to arrive.
curl http://localhost:8080/inbox/my-channelResponses:
200 OK- Returns message with original Content-Type408 Request Timeout- No message arrived within timeout (25s default)503 Service Unavailable- Too many requests already wait for this ID, or too many IDs have waiting requests
Consumer acknowledges successful receipt. Clears the message from storage.
curl -X DELETE http://localhost:8080/inbox/my-channelResponses:
200 OK- Message acknowledged and cleared404 Not Found- No message exists (not posted yet, or expired)
Producer checks if message was acknowledged.
curl http://localhost:8080/inbox/my-channel/ackResponses:
200 OK- Body containstrue(ACKed) orfalse(pending)404 Not Found- No message exists: not posted yet, expired, or deleted after the ACK to make room in a full store./awaitdoesn't have this problem for an ACK that happens while it waits.
Producer blocks until consumer acknowledges the message.
curl http://localhost:8080/inbox/my-channel/awaitResponses:
200 OK- Consumer ACKed the message404 Not Found- No message exists, or it expired or was replaced while waiting408 Request Timeout- No ACK received within timeout (default 25s)503 Service Unavailable- Too many requests already wait for this ACK
All endpoints answer 400 Bad Request with ID too long if the ID is longer
than 256 bytes, and 500 Internal Server Error with Storage error if the
relay can't read or write its storage. Error bodies are short text/plain
messages.
sequenceDiagram
participant P as Producer
participant R as Relay
participant C as Consumer
P->>R: POST /inbox/{id}
activate R
Note right of R: Store in SQLite<br/>acked = false
R-->>P: 200 OK
deactivate R
P->>R: GET /inbox/{id}/await
activate R
Note right of R: Subscribe to ACK<br/>(blocks up to 25s)
C->>R: GET /inbox/{id}
R-->>C: 200 OK + message
C->>R: DELETE /inbox/{id}
activate R
Note right of R: Set acked = true<br/>Clear message body<br/>Notify ACK waiters
R-->>C: 200 OK
deactivate R
R-->>P: 200 OK (ACKed)
deactivate R
Step by step:
- Producer POSTs message to
/inbox/{id}— returns 200 immediately - Producer calls GET
/inbox/{id}/await— blocks waiting for ACK - Consumer GETs message from
/inbox/{id}— receives payload (or waits up to 25s if not yet available) - Consumer DELETEs
/inbox/{id}— acknowledges receipt - Producer's
/awaitcall returns 200 — delivery confirmed
The consumer can call GET before the producer posts—it will long-poll up to 25s for the message to arrive. No polling loop needed.
Implements the standard HTTP Relay spec. Maintained for backwards compatibility but not recommended for new integrations.
| Method | Endpoint | Description |
|---|---|---|
POST |
/link/{id} |
Send message, block until consumer retrieves (10 min timeout) |
GET |
/link/{id} |
Retrieve message, block until producer sends (10 min timeout) |
Why prefer /inbox: The /link endpoint has no ACK mechanism—if the consumer
disconnects after receiving data, the producer still gets 200 OK. The 10-minute
timeout also exceeds typical proxy limits.
async function produceToRelay(channelId, data) {
// Store the message (returns immediately)
while (true) {
try {
const storeResponse = await fetch(`http://relay.example.com/inbox/${channelId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (storeResponse.status === 200) break;
throw new Error(`Failed to store: ${storeResponse.status}`);
} catch (error) {
// Network error - retry after brief delay
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
}
// Wait for consumer to ACK (blocks up to 25s per call)
while (true) {
try {
const awaitResponse = await fetch(
`http://relay.example.com/inbox/${channelId}/await`
);
if (awaitResponse.status === 200) {
return; // Consumer ACKed - delivery confirmed
}
if (awaitResponse.status === 408) {
continue; // Timeout - keep waiting
}
throw new Error(`Unexpected status: ${awaitResponse.status}`);
} catch (error) {
// Network error - retry after brief delay
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
}
}async function consumeFromRelay(channelId) {
// Long-poll until message is available (waits up to 25s per call)
while (true) {
try {
const response = await fetch(`http://relay.example.com/inbox/${channelId}`);
if (response.status === 200) {
const data = await response.text();
// ACK the message (critical - producer is waiting for this)
// Retry ACK on network error - message won't be re-delivered after success
while (true) {
try {
await fetch(`http://relay.example.com/inbox/${channelId}`, {
method: 'DELETE',
});
break;
} catch (error) {
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
}
return data;
}
if (response.status === 408) {
continue; // Timeout - no message yet, retry
}
throw new Error(`Unexpected status: ${response.status}`);
} catch (error) {
// Network error (app backgrounded, connection dropped, etc.)
// Wait briefly then retry - message is still safe on the relay
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
}
}- Network errors are recoverable: Because messages persist until ACKed, both producer and consumer can safely retry on connection drops.
- Consumer must ACK: Call DELETE after processing. Until then, the message remains available (at-least-once delivery).
- Producer can verify delivery: Use /await to block until ACK, or /ack to check status without blocking.
- Message TTL: Unacknowledged messages expire after 5 minutes (configurable).
When a consumer's network disappears suddenly (Wi-Fi off, tunnel, app killed), the relay cannot detect this immediately. TCP acknowledgments can take 30+ seconds to fail when packets vanish.
This is why /inbox uses explicit ACKs: the producer only knows delivery
succeeded when the consumer calls DELETE. If the consumer crashes before ACKing,
the message remains available for retry.
# Run tests
cargo test
# Run with debug logging
RUST_LOG=debug cargo runSee RELEASING.md for how to publish a new version.