Live streams
Tail events and deliveries as they happen over Server-Sent Events, and resume cleanly after a reconnect.
Two endpoints let you react to activity without polling: GET /v1/events/stream and GET /v1/deliveries/stream. Both return text/event-stream.
Both are forward-only. A stream starts at the moment you connect and emits nothing historical. For history, use GET /v1/events and GET /v1/deliveries. Both close after 30 minutes, so treat the close as a reconnect signal, not an error.
How it works
Each message carries an SSE id, and its data is the same JSON shape the matching list endpoint returns. Reconnect with a Last-Event-ID header and the server resumes from exactly that point, including rows that share the same timestamp. You do not keep a cursor of your own. A Last-Event-ID takes precedence over after.
The two streams differ in what counts as activity, and that changes how you consume them.
/v1/events/stream | /v1/deliveries/stream | |
|---|---|---|
| Anchored on | createdAt | Most recent attempt, falling back to createdAt |
| Same id emitted again | No. Events are immutable, so each is emitted once | Yes. Once on creation, then again on every attempt |
| What to do with a repeat | Nothing to do | Replace by id, do not append |
| Scope parameters | sourceId | eventId, status |
| Permission | event: read | delivery: read |
For a delivery, a manual retry re-enters the stream when its next attempt starts, not when you call retry.
Parameters
| Parameter | Streams | Meaning |
|---|---|---|
after | both | Start from this time instead of now. Clamped to the last 24 hours. For deliveries it is matched against activity time. |
sourceId | events | Only events for this source. |
eventId | deliveries | Only deliveries for this event. |
status | deliveries | Only deliveries in this status. |
after is a start-from filter for a fresh stream, not a resume mechanism. A value older than 24 hours is ignored, and the stream starts at the moment of connection. These streams are not a history export.
Authenticate
The stream endpoints take the same API key as the rest of /v1, sent as Authorization: Bearer $SDHK_KEY. They are project-scoped, so X-Project-Id applies as it does elsewhere.
Browser EventSource cannot connect directly
The browser EventSource API cannot set an Authorization header, and the stream does not accept the key any other way. Do not put an API key in a query string. Connect from a server, or from a script that can set headers, and relay what you need to a browser yourself.
Tail from the command line
curl -N "$API/v1/events/stream?sourceId=src_..." \
-H "authorization: Bearer $SDHK_KEY" \
-H 'accept: text/event-stream'-N turns off curl's buffering so messages print as they arrive. To resume after a drop, add the last id you saw:
curl -N "$API/v1/events/stream" \
-H "authorization: Bearer $SDHK_KEY" \
-H 'accept: text/event-stream' \
-H 'last-event-id: <id from the last message>'Treat the id as opaque. Pass it back unchanged.
Consume from Node
This reads the stream with fetch, remembers the last id, and reconnects when the 30-minute close arrives. It keeps the latest copy of each delivery by id.
const API = process.env.API ?? 'https://api.solidhooks.com';
const headers = {
authorization: `Bearer ${process.env.SDHK_KEY}`,
accept: 'text/event-stream',
};
const deliveries = new Map<string, unknown>();
let lastEventId: string | undefined;
async function tail(): Promise<void> {
const res = await fetch(`${API}/v1/deliveries/stream?status=failed`, {
headers: lastEventId ? { ...headers, 'last-event-id': lastEventId } : headers,
});
if (!res.ok || !res.body) throw new Error(`stream refused: ${res.status}`);
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of res.body) {
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
const messages = buffer.split('\n\n');
buffer = messages.pop() ?? '';
for (const message of messages) {
let id: string | undefined;
const data: string[] = [];
for (const line of message.split('\n')) {
if (line.startsWith('id:')) id = line.slice(3).trim();
if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
if (data.length === 0) continue;
const delivery = JSON.parse(data.join('\n')) as { id: string };
deliveries.set(delivery.id, delivery);
if (id) lastEventId = id;
}
}
}
for (;;) {
try {
await tail();
} catch (error) {
console.error(error);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}The loop reconnects whenever the stream ends, which covers the 30-minute close. The last-event-id header makes each reconnect continue where the last one stopped.
Next steps
- Events — what an event row contains
- Deliveries — statuses and attempts
- Replay and retry — act on what you see in the stream
- API keys — scope a key to read events and deliveries only