Consume your data
Every egress is a WebTransport feed or a webhook. Pick how your software receives it.
One URL per source
Dial one source directly and add ?mode=raw to get pure payload bytes: no envelope, no loss signal, just the bytes your device sent. The URL comes from the egress's Connect tab in the portal, under the Per-source door.
Raw mode still authenticates like every other dial: exchange your subscriber token for a session token, then write it as a Bearer <token> line before the hub admits the connection. There is no shortcut that skips this step.
const TOKEN = 'st_…'; // replace with your subscriber token
const EGRESS_ID = '00000000-0000-0000-0000-000000000000';
const authResp = await fetch('https://your-portal.example.com/auth/v1/subscriber-auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: TOKEN, egress_id: EGRESS_ID })
});
const { session_token, wt_subscriber_url, server_cert_hashes } = await authResp.json();
// server_cert_hashes is non-empty for self-signed hubs (dev/edge); pin them.
// Empty means a WebPKI cert: connect with normal trust.
const options = server_cert_hashes.length ? {
serverCertificateHashes: server_cert_hashes.map((hex) => ({
algorithm: 'sha-256',
value: Uint8Array.from(hex.match(/../g), (b) => parseInt(b, 16)).buffer,
})),
} : undefined;
const wt = new WebTransport(`${wt_subscriber_url}/v1/subscribe/${EGRESS_ID}/origins/<origin-id>?mode=raw`, options);
await wt.ready;
const auth = await wt.createUnidirectionalStream();
const writer = auth.getWriter();
await writer.write(new TextEncoder().encode(`Bearer ${session_token}`));
await writer.close();There is one thing raw mode cannot tell you: after any disconnect, a raw consumer has no way to know what it missed. Redial and treat the stream as new. Don't assume continuity across a reconnect.
const streams = wt.incomingUnidirectionalStreams.getReader();
while (true) {
const { value: stream, done } = await streams.read();
// session ended before any stream: the URL names a source this feed doesn't carry, or auth failed
if (done) break;
const reader = stream.getReader();
while (true) {
const { value, done: eof } = await reader.read();
if (eof) break;
ingest(value); // raw payload bytes only, no envelope, no loss signal
}
}A dial like this can come back refused right after you create it: native clients see close code 0x404, origin_not_in_scope. Browsers just see a generic connection error. If the system was just restarted, sources can take a couple of minutes to become dialable again, so retry with backoff. If it keeps failing past that, the URL names a source this egress does not carry.
One connection for everything
The SDK opens one connection for the whole egress, hands you one callback per source, reconnects on its own, and flags gaps in each source's sequence number so you know when something was dropped.
// @foxtether/sdk ships in the FoxTether repo under sdks/ts; published at launch.
import { subscribe } from "@foxtether/sdk";
const session = await subscribe({
authBase: "https://your-portal.example.com/auth",
egressId: "00000000-0000-0000-0000-000000000000",
token: "st_...", // your subscriber token
});
session.onOrigin((origin) => {
origin.onMessage((payload, seq) => ingest(origin.id, seq, payload));
origin.onGap((missed) => flagLoss(origin.id, missed));
});
const directory = await session.origins();// The foxtether crate ships in the FoxTether repo under sdks/rust; published at launch.
use foxtether::OriginEvent::{Gap, Message};
let target = foxtether::Target {
auth_base: "https://your-portal.example.com/auth".into(),
egress_id: "00000000-0000-0000-0000-000000000000".parse()?,
token: "st_...".into(), // your subscriber token
};
let mut session = foxtether::subscribe(target, Default::default()).await?;
while let Some(mut origin) = session.next_origin().await {
tokio::spawn(async move {
while let Some(event) = origin.next().await {
match event { Message { seq, payload } => ingest(origin.id(), seq, &payload),
Gap { missed } => flag_loss(origin.id(), missed), _ => {} }
} });
}The source directory
Call session.origins() (TypeScript) or Session::directory() (Rust) to list the sources currently in scope. Both fetch on demand, so call them when you need fresh names. The directory read shares its per-IP rate limit (60 requests per minute) with the token exchange, so don't put either one in a polling loop.
If the directory reports registryReady: false (TypeScript) or registry_ready: false (Rust, and on the wire), the registry just restarted and is still warming up. It does not mean the egress has no sources. Call origins() or directory() again: a not-ready snapshot is never cached, so the call re-fetches. Once the registry reports ready, that snapshot is cached until your next reconnect.
A source can start sending before the directory catches up to it. Tolerate an origin id in the feed that isn't in your last directory fetch: it isn't an error, it just hasn't been seen by the directory yet.
The wire contract, no library
This is what the SDK does for you. Every message on a delivery stream (muxed or single-origin, envelope mode) carries this 28-byte header before its payload:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 16 B | origin_id | UUID bytes, the data source |
| 16 | 8 B | origin_seq | u64, big-endian. The source's monotonic counter, the loss signal |
| 24 | 4 B | len | u32, big-endian. Payload length |
| 28 | len B | payload | opaque, byte-identical to what was sent at the edge |
readExact(reader, n) reads exactly n bytes from the reader before returning. Write your own or borrow the SDK source's.
while (true) {
const header = await readExact(reader, 28); // buffers reads to exactly 28 bytes
const view = new DataView(header.buffer, header.byteOffset, 28);
const originId = header.slice(0, 16);
const originSeq = view.getBigUint64(16, false); // big-endian
const len = view.getUint32(24, false); // big-endian
const payload = await readExact(reader, len);
ingest(originId, originSeq, payload);
}loop {
let mut header = [0u8; 28];
stream.read_exact(&mut header).await?;
let origin_id: [u8; 16] = header[0..16].try_into()?;
let origin_seq = u64::from_be_bytes(header[16..24].try_into()?);
let len = u32::from_be_bytes(header[24..28].try_into()?) as usize;
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await?;
ingest(origin_id, origin_seq, payload);
}import struct
HEADER = struct.Struct(">16sQI") # 16B origin id + u64 seq (BE) + u32 len (BE)
def read_exact(read_fn, n):
buf = b""
while len(buf) < n:
chunk = read_fn(n - len(buf)) # your stream's read function
if not chunk:
raise ConnectionError("stream closed")
buf += chunk
return buf
while True:
origin_id, origin_seq, length = HEADER.unpack(read_exact(stream.read, HEADER.size))
payload = read_exact(stream.read, length)
ingest(origin_id, origin_seq, payload)A per-source dial can come back refused right after you create it (native clients see close code 0x404, origin_not_in_scope, and browsers just see a generic connection error). If the system was just restarted, sources can take a couple of minutes to become dialable again, so retry briefly with backoff. If it keeps failing past that, the URL names a source this egress does not carry, and that's a configuration problem to fix, not something to keep retrying.
Messages from one source always arrive in that source's own order. Messages from different sources on the same connection carry no ordering guarantee against each other. Never build logic that assumes one source's message arrived before or after another source's.
We call you
Point an egress at your own HTTPS endpoint. Every message arrives as one POST: the body is the raw payload bytes, exactly as sent at the edge, with content-type: application/octet-stream.
Six headers carry identity: x-foxtether-signature (HMAC-SHA256 over the raw body, always present, but only meaningful to verify once you set a signing secret), x-foxtether-egress-id, x-foxtether-gateway-id, x-foxtether-origin-id, x-foxtether-origin-seq, and x-foxtether-timestamp.
Answer with any 2xx quickly. A 3xx or 4xx is treated as terminal: we won't retry it, so a redirect or a rejected request just drops that message. A 5xx or a network failure retries at 1 second, then 4 seconds, then 16 seconds, then gives up.
const crypto = require("crypto");
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
const digest = crypto.createHmac("sha256", secret).update(req.body).digest("hex");
const expected = `sha256=${digest}`;
const received = req.header("x-foxtether-signature") || "";
const valid = received.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!valid) return res.status(401).end();
const originId = req.header("x-foxtether-origin-id");
const originSeq = req.header("x-foxtether-origin-seq");
ingest(originId, originSeq, req.body); // raw bytes, never re-parsed or re-stringified
res.status(200).end();
});Let the app set itself up
An app can take one integration key instead of a connect string per feed. Mint the key under Settings, Integrations (or let the app ask for it through the portal's Connect page), and the app lists your sources, creates its own feed, and mints that feed's token. Everything it makes shows up under Egresses like a feed you made by hand, and the key can never touch one you made by hand.
import { IntegrationClient, parseFoxtetherString, subscribe } from "@foxtether/sdk";
const parsed = parseFoxtetherString(pasted); // an integrate string from Settings, Integrations
if (parsed.kind === "integrate") {
const app = new IntegrationClient(parsed.credentials);
const inventory = await app.listOrigins();
const feed = await app.createEgress({
name: "My app on this device", scope: { kind: "allGateways" },
});
const minted = await app.mintToken(feed.egressId, "this device");
const session = await subscribe(minted.credentials);
}The TypeScript and Rust SDKs live in this repo under sdks/. They haven't published to npm or crates.io yet. Your egress's Connect tab in the portal carries your real URLs and tokens, and nothing on this page dials anywhere.