Multi-Currency Exchange
Cross-ledger currency exchange using atomically linked transfers.
Multi-Currency Exchange
When your platform handles multiple currencies, Beetles models each currency as its own ledger. Exchanges move money across ledgers using atomically linked transfers — if one leg fails, both are rolled back.
Architecture
The exchange flow uses Identities to resolve destination accounts automatically. An Identity groups accounts across ledgers under one entity (e.g. "Acme Corp" has a BRL account and a USD account).
The two transfers are atomically linked at the engine level — they succeed or fail together. There is no intermediate state where one side has moved but the other hasn't.
Entity Setup
Before executing exchanges, you need ledgers per currency and accounts linked via an Identity:
If the source account doesn't have an Identity yet, one is auto-created using the account name as label. If the destination account doesn't exist, it's also auto-created in the target ledger.
API Flow
Create Ledgers for Each Currency
curl -X POST http://localhost:8080/v1/ledgers \
-H "Authorization: Bearer $TOKEN" \
-H "Beetles-Workspace-Id: $WS_ID" \
-H "Content-Type: application/json" \
-d '{ "name": "BRL Operations" }'curl -X POST http://localhost:8080/v1/ledgers \
-H "Authorization: Bearer $TOKEN" \
-H "Beetles-Workspace-Id: $WS_ID" \
-H "Content-Type: application/json" \
-d '{ "name": "USD Treasury" }'Create an Identity and Link to an Account
curl -X POST http://localhost:8080/v1/identities \
-H "Authorization: Bearer $TOKEN" \
-H "Beetles-Workspace-Id: $WS_ID" \
-H "Content-Type: application/json" \
-d '{ "label": "Acme Corp" }'curl -X PUT http://localhost:8080/v1/identities/$IDENTITY_ID/accounts/$BRL_ACCOUNT_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Beetles-Workspace-Id: $WS_ID"Execute the Exchange
curl -X POST http://localhost:8080/v1/exchanges \
-H "Authorization: Bearer $TOKEN" \
-H "Beetles-Workspace-Id: $WS_ID" \
-H "Content-Type: application/json" \
-d '{
"source_account_id": "BRL_ACCOUNT_ID",
"destination_ledger_id": "USD_LEDGER_ID",
"amount": 542000,
"exchange_rate": 5.42,
"code": 1
}'{
"source_transfer": {
"id": "01969...",
"debit_account_id": "BRL_ACCOUNT_ID",
"credit_account_id": "WORLD_BRL_ID",
"amount": 542000,
"status": "success"
},
"destination_transfer": {
"id": "01969...",
"debit_account_id": "WORLD_USD_ID",
"credit_account_id": "USD_ACCOUNT_ID",
"amount": 100000,
"status": "success"
},
"destination_account_id": "USD_ACCOUNT_ID",
"exchange_rate": 5.42,
"source_amount": 542000,
"destination_amount": 100000
}The amount is in the source currency's smallest unit. With exchange_rate: 5.42, the destination receives amount / rate = 542000 / 5.42 = 100000 (i.e. $1,000.00).
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
source_account_id | UUID | ✅ | Account to debit in the source ledger |
destination_ledger_id | UUID | ✅ | Target ledger (must differ from source) |
amount | integer | ✅ | Amount in the source currency's smallest unit |
exchange_rate | float | ✅ | Units of source currency per 1 unit of destination |
code | integer | ✅ | Transfer code for categorization |
Validation Rules
- Source and destination must be different ledgers (same-ledger →
422) exchange_ratemust be> 0(zero or negative →400)- Source account must have sufficient balance (overdraft → engine-level rejection)
- If no Identity exists for the source account, one is auto-created using the account name
Cross-Reference
Both transfers include a reference field that links them together:
- Source transfer:
reference: "exchange:<destination_transfer_id>" - Destination transfer:
reference: "exchange:<source_transfer_id>"
This allows you to trace both legs of an exchange from any single transfer.
How It Works Under the Hood
- Validate source account + destination ledger (workspace-scoped)
- Resolve Identity from the source account
- Find or create a destination account for that Identity in the target ledger
- Create 2 atomically linked transfers in the ledger engine:
- Leg 1:
source_account → @world(source ledger) — debits the source - Leg 2:
@world → destination_account(dest ledger) — credits the destination
- Leg 1:
- Persist both transfers
The linked flag ensures atomicity — if either leg fails (e.g. insufficient balance), both are rejected. No partial state is ever possible.