Skip to main content

SSE streaming

How paymentStatusStream and verificationStatusStream push state changes — event shape, heartbeats, cancellation, TypeScript and Python usage, plus a curl example.

1 min read · updated

SSE streaming

@codespar/sdkv0.9.0

Two SDK methods push async settlement state over Server-Sent Events:

  • session.paymentStatusStream(toolCallId, opts) — for codespar_charge / codespar_pay.
  • session.verificationStatusStream(toolCallId, opts) — for codespar_kyc.

Both share the same wire shape: a single HTTP GET to /v1/tool-calls/:id/<status>/stream that opens a long-lived response with Content-Type: text/event-stream. The server pushes named events as the underlying state changes; the SDK wrapper parses them, fires callbacks, and resolves the promise on terminal.

For the correlation chain that produces these events, see Async settlement.

Event shape

Three named event types flow over the stream:

Event nameWhen it firesPayload
snapshotOnce, immediately on connect.The current state of the tool call — { status, ... }. Often pending.
updateEvery time the state changes after connect.The new state envelope. May fire multiple times (e.g. pendingreviewapproved).
doneOnce, 5 seconds after the state goes terminal. The connection auto-closes.The final envelope (same as the last update).

snapshot is guaranteed first. done is guaranteed last. Between them, zero or more update frames.

A typical SSE frame on the wire:

event: update
data: {"status":"pending","tool_call_id":"tc_abc123","external_reference":"5f4...","observed_at":"2026-05-04T14:30:00Z"}

Heartbeats

Every 15 seconds, the server emits a comment frame:

: heartbeat 1714838400000

These keep the connection alive through proxies that idle-close silent streams. They are not events — clients that walk lines looking for event: prefixes will skip them naturally. The SDK wrappers (TypeScript and Python) filter them explicitly so the onUpdate callback is never invoked with a heartbeat.

TypeScript usage

The simplest form. Resolves with the final envelope when the state goes terminal.

import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
const session = await cs.create("user_123", { servers: ["asaas"] });

const charge = await session.charge({
  method: "pix",
  amount: 9990,
  currency: "BRL",
});

const final = await session.paymentStatusStream(charge.tool_call_id);
console.log(final.status);              // "succeeded"
console.log(final.final_amount_minor);  // 9990

Get every state change as it lands. The promise still resolves on terminal.

const final = await session.paymentStatusStream(charge.tool_call_id, {
  onUpdate: (env) => {
    console.log(`[${env.observed_at}] ${env.status}`);
  },
});
console.log("terminal:", final.status);

Pass an AbortSignal and call .abort() to drop the stream cleanly.

const ac = new AbortController();
setTimeout(() => ac.abort(), 60_000); // give up after 60s

try {
  const final = await session.paymentStatusStream(charge.tool_call_id, {
    signal: ac.signal,
  });
  console.log(final.status);
} catch (err) {
  if (err.name === "AbortError") {
    // user canceled or timed out — status not yet terminal
  } else {
    throw err;
  }
}

Python usage

from codespar import AsyncCodespar

async with AsyncCodespar(api_key=...) as cs:
    session = await cs.create("user_123", servers=["asaas"])

    charge = await session.charge(
        method="pix", amount=9990, currency="BRL",
    )

    def on_update(env):
        print(env["observed_at"], env["status"])

    final = await session.payment_status_stream(
        charge["tool_call_id"],
        on_update=on_update,
    )
    print(final["status"])

A synchronous wrapper exists on Session for non-async callers — session.payment_status_stream(...) blocks the calling thread until terminal.

Wrap the stream call in an asyncio.Task and cancel it.

import asyncio

task = asyncio.create_task(
    session.payment_status_stream(charge["tool_call_id"]),
)
try:
    final = await asyncio.wait_for(task, timeout=60)
except asyncio.TimeoutError:
    task.cancel()
    # status not yet terminal

When to poll instead

The polling sibling — session.paymentStatus(toolCallId) — is the right pick for one-off "is this done yet?" reads, for batch reconciliation jobs that walk many tool_call_ids on a schedule, and for environments where opening a long-lived stream is awkward (some serverless platforms, some CI runners).

const status = await session.paymentStatus(charge.tool_call_id);
if (status.status === "pending") {
  // try again later
}

Polling and streaming hit the same backend tables, so they always agree. Streaming wins on latency to terminal; polling wins on operational simplicity.

Direct curl

For a quick smoke test of the stream without the SDK:

curl -N \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  https://api.codespar.dev/v1/tool-calls/tc_abc123/payment-status/stream

The -N flag disables curl's output buffering so frames print as they arrive. A typical session looks like:

event: snapshot
data: {"status":"pending","tool_call_id":"tc_abc123","observed_at":"2026-05-04T14:30:00Z"}

: heartbeat 1714838415000

event: update
data: {"status":"succeeded","tool_call_id":"tc_abc123","final_amount_minor":9990,"settled_at":"2026-05-04T14:30:18Z","observed_at":"2026-05-04T14:30:18Z"}

event: done
data: {"status":"succeeded","tool_call_id":"tc_abc123","final_amount_minor":9990,"settled_at":"2026-05-04T14:30:18Z","observed_at":"2026-05-04T14:30:23Z"}

The blank line between frames is the SSE separator — every event ends with \n\n.

The verification stream uses the same shape with a different terminal-state vocabulary (approved, rejected, review, expired, pending). Swap payment-status for verification-status in the URL and use verificationStatusStream from the SDK.

Next steps

Edit on GitHub

Last updated on