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.6"# Default: bind to 127.0.0.1:8080 (localhost only)
http-relay
# Bind to all interfaces (for production/Docker)
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 |
--max-body-size <BYTES> |
Max request body size | 2048 (2KB) |
--max-entries <N> |
Max entries in waiting list | 10000 |
--persist-db <PATH> |
SQLite database path for persistence | (in-memory) |
-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
}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 memory for the TTL duration. 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 successfully503 Service Unavailable- Server at capacity
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)
Consumer acknowledges successful receipt. Clears the message from storage.
curl -X DELETE http://localhost:8080/inbox/my-channelResponses:
200 OK- Message acknowledged and cleared
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, or expired)
Producer blocks until consumer acknowledges the message.
curl http://localhost:8080/inbox/my-channel/awaitResponses:
200 OK- Consumer ACKed the message408 Request Timeout- No ACK received within timeout (default 25s)
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.