Skip to main content

Async settlement

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

1 min read · updated

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.

The same pattern applies to KYC verification (codespar_kyc) — only the terminal states differ. See the Verification sibling section.

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.

ProviderWhere the UUID lands
AsaasexternalReference field on the request body. Echoed back on every webhook.
Mercado PagoX-Idempotency-Key request header and external_reference body field. (Webhook payload omits external_reference; see caveat below.)
StripeIdempotency-Key request header. Surfaces on the resulting object's metadata for downstream correlation.
iuguidempotency_key body field. Echoed on webhooks.
Stoneidempotency_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)
EndpointGET /v1/tool-calls/:id/payment-statusGET /v1/tool-calls/:id/payment-status/stream
Latency to terminal1–N polling intervals after settlement~immediate (server pushes)
Connection modelone short request per pollone long-lived stream
Right when...one-off "is this done?" reads, batch reconciliation jobs, no SSE infra client-sidelive 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 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

Edit on GitHub

Last updated on