---
title: SSE streaming
description: How paymentStatusStream and verificationStatusStream push state changes — event shape, heartbeats, cancellation, TypeScript and Python usage, plus a curl example.
---

import { Callout } from "fumadocs-ui/components/callout";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";

# SSE streaming

<VersionBadge pkg="@codespar/sdk" />

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](/docs/concepts/async-settlement).

## Event shape

Three named event types flow over the stream:

| Event name | When it fires | Payload |
|---|---|---|
| `snapshot` | Once, immediately on connect. | The current state of the tool call — `{ status, ... }`. Often `pending`. |
| `update` | Every time the state changes after connect. | The new state envelope. May fire multiple times (e.g. `pending` → `review` → `approved`). |
| `done` | Once, 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

<Tabs items={["Await on terminal", "onUpdate callback", "Cancellation"]}>
<Tab value="Await on terminal">

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

```typescript
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
```

</Tab>
<Tab value="onUpdate callback">

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

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

</Tab>
<Tab value="Cancellation">

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

```typescript
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;
  }
}
```

</Tab>
</Tabs>

## Python usage

<Tabs items={["Async with on_update", "Cancellation"]}>
<Tab value="Async with on_update">

```python
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.

</Tab>
<Tab value="Cancellation">

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

```python
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
```

</Tab>
</Tabs>

## 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_id`s on a schedule, and for environments where opening a long-lived stream is awkward (some serverless platforms, some CI runners).

```typescript
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:

```bash
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`.

<Callout type="warn">
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.
</Callout>

## Next steps

<NextStepsGrid items={[
  { label: "CONCEPT", title: "Async settlement", description: "Why async, the correlation chain, and per-provider idempotency-key shapes.", href: "/docs/concepts/async-settlement" },
  { label: "CONCEPT", title: "Sessions", description: "Where charge / pay / kyc tool calls originate and how tool_call_id is returned.", href: "/docs/concepts/sessions" },
  { label: "CONCEPT", title: "Triggers", description: "Subscribe to settlement events at the account level if a stream per tool call is too granular.", href: "/docs/concepts/triggers" },
]} />
