SolidHooksSolidHooks/ Docs

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 oncreatedAtMost recent attempt, falling back to createdAt
Same id emitted againNo. Events are immutable, so each is emitted onceYes. Once on creation, then again on every attempt
What to do with a repeatNothing to doReplace by id, do not append
Scope parameterssourceIdeventId, status
Permissionevent: readdelivery: read

For a delivery, a manual retry re-enters the stream when its next attempt starts, not when you call retry.

Parameters

ParameterStreamsMeaning
afterbothStart from this time instead of now. Clamped to the last 24 hours. For deliveries it is matched against activity time.
sourceIdeventsOnly events for this source.
eventIddeliveriesOnly deliveries for this event.
statusdeliveriesOnly 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