Transaction lifecycle
From quote to settlement — every state a BlendFi transaction can be in, the legal transitions between them, and how to react to each.
Transaction lifecycle
A BlendFi transaction moves money in three steps: your end user pays BRL via Pix, BlendFi buys the equivalent USDT on a regulated exchange, and the USDT is settled on-chain to the wallet you specified. This guide shows every state the transaction passes through, when money is irreversible, and what to do at each stop.
The happy path
quote ──► transaction created ──► pending_payment
│
▼ (Pix received)
payment_received
│
▼ (exchange filled)
buying_crypto
│
▼ (on-chain submit)
sending_crypto
│
▼ (mined)
completedEach arrow is a deterministic event. There is no polling required from your side — webhook callbacks announce every transition. In sandbox, you drive these events yourself with the test helpers.
Three concepts
Quotes are short-lived
A quote pins a BRL→USDT rate for ~30 seconds. You convert it into a transaction with `POST /v1/transactions`. Once consumed, it's done.
Transactions are irreversible after `buying_crypto`
Up to `payment_received`, we can refund. Once we've bought crypto, we settle it — refunds become operational, not automatic.
Failures are explicit
Every terminal failure has a `code` telling you why. There is no ambiguous `unknown` state.
State catalogue
| State | Meaning | Money state | Next legal states |
|---|---|---|---|
pending_payment | Transaction created; awaiting Pix from the end user | No money moved | payment_received, cancelled, expired, failed |
payment_received | Pix arrived in our settlement account | BRL with us, no USDT yet | buying_crypto, failed |
buying_crypto | We're executing the BRL→USDT order on the exchange | BRL committed, USDT in flight | sending_crypto, failed, refunded |
sending_crypto | USDT bought; on-chain transfer submitted to the destination wallet | USDT in flight | completed, failed |
completed | On-chain transfer confirmed | USDT delivered | (terminal) |
expired | The end user did not pay within the Pix TTL | No money moved | (terminal) |
cancelled | You cancelled before payment arrived | No money moved | (terminal) |
refunded | We refunded BRL to the end user (after partial progress) | BRL returned | (terminal) |
failed | Something broke at one of the upstream providers | See failure_reason | (terminal) |
Money safety boundaries
Where the no-return point lives
Up to and including payment_received, BlendFi can refund the end user automatically. Once the transaction enters buying_crypto, the BRL has been committed to the exchange — refunds are still possible (state refunded) but they are operational, not automatic, and require your support team to coordinate with us. After sending_crypto the USDT is on-chain; "refund" means returning USDT to a sender wallet, which is a separate transaction your end user has to execute.
Walking the happy path
1. Get a quote
Quotes pin the rate for a short window. Default TTL is 30 seconds.
curl -X POST $BLENDFI_BASE/v1/quotes \
-H "Authorization: Bearer $BLENDFI_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{
"user_id": "01J...",
"source_amount": "100.00",
"source_currency": "BRL",
"target_currency": "USDT",
"destination_wallet_address": "0xabc...",
"destination_wallet_network": "polygon"
}'Response:
{
"id": "01JQ...",
"rate": "5.42",
"source_amount": "100.00",
"target_amount": "18.4502",
"expires_at": "2026-04-29T13:00:30Z"
}If you wait too long, the quote returns 409 quote_expired when you try to consume it. Get a fresh one.
2. Create the transaction
Convert the quote into a transaction. After this call, the transaction is in pending_payment and we're waiting for the end user's Pix.
curl -X POST $BLENDFI_BASE/v1/transactions \
-H "Authorization: Bearer $BLENDFI_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"quote_id": "01JQ..."}'Response:
{
"id": "01JT...",
"status": "pending_payment",
"pix_qr_code": "00020126...",
"pix_copy_paste": "...",
"expires_at": "2026-04-29T13:30:00Z"
}Show the Pix QR code or copy-paste string to your end user. They have until expires_at (typically 30 minutes) to pay.
3. End user pays via Pix
When the Pix arrives, BlendFi transitions the transaction to payment_received. You hear about this in two ways:
- Webhook (recommended): we POST to your registered webhook URL with the new state.
- Polling (fallback):
GET /v1/transactions/{id}shows the current state.
{
"id": "01JT...",
"status": "payment_received",
"paid_at": "2026-04-29T13:04:12Z",
...
}4. BlendFi buys USDT and settles on-chain
This is automatic. The transaction moves through buying_crypto (exchange order) → sending_crypto (on-chain submit) → completed (mined). Each transition is delivered as a webhook.
The terminal completed payload includes the on-chain transaction hash you can show your end user:
{
"id": "01JT...",
"status": "completed",
"usdt_tx_hash": "0xabc123...",
"completed_at": "2026-04-29T13:08:05Z"
}Failure modes
Every terminal failure is announced with a failure_reason. The four you'll actually encounter:
failure_reason | At which state | What happened | Your action |
|---|---|---|---|
payment_timeout | pending_payment → expired | End user did not pay before the Pix expiration | Tell the user to retry with a fresh quote |
exchange_rejected | buying_crypto → failed | The exchange could not fill the BRL→USDT order at the locked rate | Refund initiated automatically; we contact you |
chain_revert | sending_crypto → failed | The on-chain transaction reverted (gas, RPC, network issue) | Operational refund — file a ticket with the request_id |
kyc_blocked | pending_payment → failed | The end user's KYC status changed mid-flow (revoked, expired) | Handle in your UI; user must re-verify |
If you ever see a failed state without a failure_reason, treat it as internal_error — file a ticket with the transaction ID.
Driving the state machine in sandbox
Sandbox doesn't actually accept Pix payments or talk to a real exchange. Instead, it gives you test_helpers endpoints that map 1:1 to real state transitions, so your integration sees the same event sequence it will see in production.
TXID=01JT...
H='-H "Authorization: Bearer $BLENDFI_KEY"'
I='-H "Idempotency-Key: $(uuidgen)"'
# Drive the happy path
curl -X POST $BLENDFI_BASE/v1/test_helpers/transactions/$TXID/pay $H $I
curl -X POST $BLENDFI_BASE/v1/test_helpers/transactions/$TXID/fill-hedge $H $I
curl -X POST $BLENDFI_BASE/v1/test_helpers/transactions/$TXID/send-crypto $H $I
curl -X POST $BLENDFI_BASE/v1/test_helpers/transactions/$TXID/confirm-crypto $H $IFailure variants you can simulate:
| Endpoint | Effect |
|---|---|
POST /v1/test_helpers/transactions/{id}/expire | Skips the Pix wait → expired |
POST /v1/test_helpers/transactions/{id}/fail-hedge | Simulates exchange_rejected → failed |
POST /v1/test_helpers/transactions/{id}/fail-crypto | Simulates chain_revert → failed |
Use these to validate your retry, error display, and refund handling before you touch production.
Webhooks vs polling
For partner-grade reliability, register a webhook URL. We POST every state transition to it with the full transaction body and a signed header you can verify.
If a webhook is unreachable, BlendFi retries with exponential backoff (1m → 1h → 6h → 24h). You can also call GET /v1/transactions/{id} at any time to read the canonical state — there is no rate-limit penalty for "is it done yet?" polling within reasonable bounds.
Polling cadence
Polling once per second per transaction is fine. Polling every 100ms across thousands of transactions is not — you'll trip rate_limit_exceeded. If you find yourself wanting tighter polling, that's a webhook signal.
What to read next
Errors and retries
What `invalid_transaction_state` means in detail, and which lifecycle errors are safe to retry.
Idempotency
Every state-driving call (`POST /v1/transactions`, every test helper) requires an idempotency key.
API reference
The exact request and response schemas for the transactions and quotes endpoints.
KYC flow
A user must complete KYC before any transaction can be created. The lifecycle starts there.
FAQ
How long do quotes last?
Default TTL is 30 seconds. The exact expires_at is in the quote response — don't hard-code 30s, read the field.
What if the end user pays late, after the Pix expiration?
The transaction is already expired. The Pix bank reverses the late payment. We do not auto-create a new transaction.
Can I cancel a pending_payment transaction before the user pays?
Yes. POST /v1/transactions/{id}/cancel (with an idempotency key) moves it to cancelled. After the Pix arrives, cancellation requires a refund flow.
What happens if the on-chain network is congested and the transfer takes hours?
The state stays sending_crypto. We retry with bumped gas if the original transaction is stuck. We don't time out into failed from chain congestion alone — only from explicit revert or RPC error.
Are partial fills possible on the exchange?
No. We only accept all-or-nothing fills. A partial fill is treated as a rejection and the transaction moves to failed with failure_reason: exchange_rejected.
Why is there a separate payment_received and buying_crypto instead of going straight from one to the other?
The two events have different latency profiles. Pix usually settles in under 10 seconds; the exchange order can take up to a minute on busy days. Exposing them separately lets your UI show "received your payment, now converting…" — partners say their end users prefer this over a single ambiguous spinner.
