---
title: Async settlement
description: Why charges settle asynchronously — and the correlation chain (idempotency_key ↔ external_reference ↔ tool_call_id) that lets you poll or stream until terminal.
---

import { Callout } from "fumadocs-ui/components/callout";

# Async settlement

Pix settles in seconds. Cards capture in minutes. ACH and Boleto take days. None of these are fast enough to block the agent on. CodeSpar's `codespar_charge` and `codespar_pay` return immediately with a `tool_call_id`; the caller polls or streams until terminal.

This page explains the correlation chain that makes that work, the per-provider idempotency-key shapes you should know about, and how to choose between polling and SSE.

<Callout type="info">
The same pattern applies to KYC verification (`codespar_kyc`) — only the terminal states differ. See the [Verification sibling](#verification-kyc-sibling) section.
</Callout>

## Why async

A blocking `await session.execute("codespar_charge", ...)` that waits for settlement would force the agent to hold a request open for the full settlement window. For Pix that is bearable (seconds); for Boleto it is not (days). The agent would also have to handle a different timeout shape per rail.

Returning a `tool_call_id` immediately and exposing a separate status endpoint decouples three concerns: the agent's request lifecycle, the provider's settlement lifecycle, and the caller's UX (synchronous "show a spinner" vs background "fire a webhook later").

## The correlation chain

```
session.charge(args)                  ─┐
  │                                    │ returns immediately:
  ▼                                    │   { tool_call_id, idempotency_key }
backend writes tool_calls row          │
  │ idempotency_key = uuid             │
  ▼                                    │
backend forwards upstream              │
  │ idempotency_key copied into the    │
  │ provider's idempotency-or-         │
  │ external-reference field           │
  ▼                                    │
provider settles asynchronously       ─┘
  │
  ▼
provider POSTs webhook to
/v1/webhooks/<server_id>/<connection_id>
  │
  ▼
backend normalizes + writes events row
  │ keyed by external_reference ↔ idempotency_key
  ▼
session.paymentStatus(tool_call_id)
  │ scans events table
  ▼
returns { status: "succeeded", final_amount_minor, settled_at, ... }
```

Three identifiers do all the correlation work:

- `tool_call_id` — what the SDK gives you. Opaque.
- `idempotency_key` — UUID generated by the backend, written into the `tool_calls` row, and propagated upstream as the provider's idempotency or external-reference field.
- `external_reference` — what the provider echoes back in the webhook payload (or, for some providers, what we have to fetch separately — see Mercado Pago below).

The status endpoint matches `idempotency_key` against the `external_reference` on the events table. Until a matching row appears, the response is `{ status: "pending" }`.

## Per-provider idempotency-key shape

Each provider exposes idempotency or external-reference fields differently. The backend writes the same UUID into all of these — you do not need to know the per-provider shape unless you are debugging a webhook that did not correlate.

| Provider | Where the UUID lands |
|---|---|
| Asaas | `externalReference` field on the request body. Echoed back on every webhook. |
| Mercado Pago | `X-Idempotency-Key` request header **and** `external_reference` body field. (Webhook payload omits `external_reference`; see caveat below.) |
| Stripe | `Idempotency-Key` request header. Surfaces on the resulting object's metadata for downstream correlation. |
| iugu | `idempotency_key` body field. Echoed on webhooks. |
| Stone | `idempotency_key` body field. Echoed on webhooks. |

Adding a new provider to a rail means picking which of these shapes the provider supports and wiring it up in the catalog `to_canonical` transform. The `idempotency_key` propagates regardless; the channel just changes.

## Mercado Pago caveat

Mercado Pago's webhook payload does **not** include `external_reference` directly — only the `payment_id`. The backend handles this with `enrichMercadoPagoFromApi`: on receipt, it issues a synchronous `GET /v1/payments/:id` against the Mercado Pago API to pull the `external_reference` before normalizing.

This GET-back has a 5-second timeout and a graceful fallback. If MP is slow or unreachable, the webhook event still lands but with a null `external_reference` — the status endpoint will then return `pending` until you reconcile manually.

You do not need to do anything for this to work; it is normal MP behavior the backend already absorbs. Surfacing it here so that an operator who reads MP's webhook docs and wonders "where is the external_reference?" knows the answer.

## Polling vs streaming

Both shapes are available; pick by latency tolerance and infrastructure.

| | `paymentStatus` (poll) | `paymentStatusStream` (SSE) |
|---|---|---|
| Endpoint | `GET /v1/tool-calls/:id/payment-status` | `GET /v1/tool-calls/:id/payment-status/stream` |
| Latency to terminal | 1–N polling intervals after settlement | ~immediate (server pushes) |
| Connection model | one short request per poll | one long-lived stream |
| Right when... | one-off "is this done?" reads, batch reconciliation jobs, no SSE infra client-side | live UX, long settlement windows (boleto, ACH), latency budget under one second |

Both endpoints share the same response envelope and the same correlation path. Streaming is not a different feature — it is the same query, pushed.

The streaming details (event shape, heartbeat, cancellation, `EventSource` vs SDK wrapper) live in the dedicated [SSE streaming](/docs/concepts/sse-streaming) guide.

## Verification (KYC) sibling

`codespar_kyc` follows the exact same correlation chain. The endpoints are siblings:

- `GET /v1/tool-calls/:id/verification-status` — poll.
- `GET /v1/tool-calls/:id/verification-status/stream` — SSE.

The SDK wrappers are `session.verificationStatus(toolCallId)` and `session.verificationStatusStream(toolCallId, opts)`.

The terminal states are different from payments. For verification, in poll-priority order:

1. `approved` — KYC passed.
2. `rejected` — KYC failed.
3. `review` — manual review pending (provider human in the loop).
4. `expired` — verification window timed out.
5. `pending` — still processing.

The polling priority matters when multiple events have landed for the same `tool_call_id` (e.g. an initial `review` followed by `approved`). The endpoint returns the highest-priority terminal state; `pending` is only returned when nothing terminal has landed.

## Next steps

<NextStepsGrid items={[
  { label: "CONCEPT", title: "SSE streaming", description: "Event shape, heartbeats, cancellation, curl example.", href: "/docs/concepts/sse-streaming" },
  { label: "CONCEPT", title: "Tools & Meta-Tools", description: "codespar_charge, codespar_pay, codespar_kyc — the meta-tools whose calls land in tool_calls.", href: "/docs/concepts/tools" },
  { label: "CONCEPT", title: "Triggers", description: "Subscribe to settlement events at the account level, not per-tool-call.", href: "/docs/concepts/triggers" },
]} />
