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.
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 thetool_callsrow, 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 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:
approved— KYC passed.rejected— KYC failed.review— manual review pending (provider human in the loop).expired— verification window timed out.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
Last updated on
Router candidates triage
Operator walkthrough for /dashboard/router-candidates — frontend-only triage UI for the LLM-classified rail-candidates report. Filter, claim, defer, reject, export.
SSE streaming
How paymentStatusStream and verificationStatusStream push state changes — event shape, heartbeats, cancellation, TypeScript and Python usage, plus a curl example.