Transfers
Move funds between wallets of the same customer: create a transfer, confirm it with a verification code, resend the code, cancel it, and list or fetch transfers.
Move funds from one wallet to another wallet of the same customer in two steps: create the transfer to put it in PENDING, then confirm it with the verification code your backend receives by webhook. The ledger moves the funds the moment you confirm, so a transfer settles at once. Every call returns the same transfer object.
Authentication: every endpoint on this page accepts your Owner API Key (x-api-key) or a user token (Authorization: Bearer). A user token only reaches its own user's wallets; any other wallet or transfer answers 404, so ids can't be probed.
Authorization: Bearer USER_TOKENConventions
Responses use the standard envelope and error format. Amounts are decimal strings and dates are UTC without milliseconds. Send an x-idempotency-key on every POST so retries never create a second transfer.
The transfer object
Create, confirm, resend, cancel, get and list all return this object.
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | Transfer id. Use it in every path on this page. |
| object | string | Always transfer. |
| status | string | PENDING, COMPLETED, FAILED, CANCELLED, EXPIRED or BLOCKED. See Status lifecycle. |
| status_reason | object | null | { code, message } for FAILED, CANCELLED, EXPIRED and BLOCKED. null otherwise. |
| type | string | PAYIN or PAYOUT, as sent. |
| purpose | string | Purpose, as sent. |
| description | string | Description, as sent. |
| user_id | string (uuid) | Owner of the source wallet. |
| customer_id | string | null | Your platform customer id. |
| wallet_id | string (uuid) | Source wallet. |
| destination_wallet_id | string (uuid) | Destination wallet. |
| currency | string | Asset code in uppercase, e.g. USDC. |
| amount | string | Amount at the asset's scale, e.g. "25000.000000". |
| fee | object | null | Always null: an internal transfer has no network fee. |
| network | null | Always null: no on-chain leg. |
| tx_hash | null | Always null: no on-chain leg. |
| idempotency_key | string | null | The x-idempotency-key you sent on create. |
| created_at | string | Creation time (UTC). |
| updated_at | string | Last change (UTC). |
| completed_at | string | null | Settlement time (UTC). null until COMPLETED. |
| created_by | string | null | Credential or user token that created it. |
| updated_by | string | null | Credential or user token that last changed it. |
| verification | object | Only on create and resend: { expires_at, attempts_remaining }. The code itself is never in the response. |
Status lifecycle
| Status | Meaning | status_reason.code |
|---|---|---|
PENDING | Created. A verification code went out and waits for confirmation. | — |
COMPLETED | Confirmed. The funds moved between the wallets. | — |
FAILED | The ledger couldn't move the funds; nothing was debited. | INSUFFICIENT_BALANCE or LEDGER_ERROR |
BLOCKED | Three wrong codes. Only cancel or expiry follow. | VERIFICATION_BLOCKED |
EXPIRED | The code lapsed before confirmation. It reads EXPIRED as soon as expires_at passes. | VERIFICATION_CODE_EXPIRED |
CANCELLED | Cancelled before confirmation. | CANCELLED_BY_USER |
A transfer never shows PROCESSING: confirm moves the funds synchronously. Any change outside this diagram answers 422 INVALID_STATE_TRANSITION.
Verification code delivery
The verification code is a 6-digit number, valid for 5 minutes, with 3 attempts. It's never returned by the API: Sovera delivers it to your backend in the user.transfer.verification_code_issued webhook event, on create and on resend. Configure your endpoint in Customer webhooks.
| Event field | Type | Description |
|---|---|---|
| code | string | The 6-digit verification code. |
| transfer_id | string (uuid) | Transfer id. |
| user_id | string (uuid) | Owner of the source wallet. |
| expires_at | string | When the code stops working (UTC). |
| trigger | string | issued on create, resent on resend. |
| attempts_remaining | integer | Attempts left with this code (3). |
Create Transfer
Create a PENDING transfer from wallet_id and issue a verification code. Both wallets must be active, belong to the same customer and be different, and the source must hold enough available balance. With a user token the source must be one of that user's wallets; the destination can be any active wallet of the same customer, including another user's. Nothing moves until you confirm.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| wallet_id | string (uuid) | Yes | Source wallet. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| destination_wallet_id | string (uuid) | Yes | Wallet that receives the funds. |
| type | string | Yes | PAYIN or PAYOUT. |
| purpose | string | Yes | Reason for the transfer (max 255). |
| description | string | Yes | Human-readable description (max 500). |
| currency | string | Yes | Asset code, e.g. USDC (max 10). |
| amount | string | Yes | Positive decimal string, e.g. "25000.000000". A JSON number still works but is deprecated. |
{
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"currency": "USDC",
"amount": "25000.000000"
}Success Response (201 Created):
{
"success": true,
"data": {
"id": "7c9e6f2a-1b3d-4e8f-9a2c-5d6e7f8a9b0c",
"object": "transfer",
"status": "PENDING",
"status_reason": null,
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"customer_id": "corp-redundant-firewall",
"wallet_id": "23c2bada-5134-5b83-848e-4bae9c3d4a8f",
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"currency": "USDC",
"amount": "25000.000000",
"fee": null,
"network": null,
"tx_hash": null,
"idempotency_key": "6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81",
"created_at": "2026-10-12T14:03:11Z",
"updated_at": "2026-10-12T14:03:11Z",
"completed_at": null,
"created_by": "12015942-4d29-5733-a774-14ee16e31da3",
"updated_by": "12015942-4d29-5733-a774-14ee16e31da3",
"verification": {
"expires_at": "2026-10-12T14:08:11Z",
"attempts_remaining": 3
}
},
"meta": {
"timestamp": "2026-10-12T14:03:11Z",
"version": "v1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}curl -X POST "https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer USER_TOKEN" \
-H "x-idempotency-key: 6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81" \
-d '{
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"currency": "USDC",
"amount": "25000.000000"
}'Errors: 400 INSUFFICIENT_BALANCE · 400 SAME_WALLET_TRANSFER source and destination are the same · 400 CROSS_CUSTOMER_TRANSFER wallets of different customers · 401 UNAUTHORIZED · 404 WALLET_NOT_FOUND source or destination missing, inactive or not yours · 409 IDEMPOTENCY_IN_PROGRESS · 422 VALIDATION_ERROR missing or invalid field (source.pointer names it) · 422 IDEMPOTENCY_KEY_REUSED · 429 RATE_LIMIT_EXCEEDED.
Confirm Transfer
Confirm a PENDING transfer with its verification code. On a valid code the funds move at once and the transfer comes back COMPLETED. If the ledger can't move the funds, the transfer turns FAILED and the call answers an error (400 INSUFFICIENT_BALANCE, or 500 INTERNAL_SERVER_ERROR with status_reason LEDGER_ERROR).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| wallet_id | string (uuid) | Yes | Source wallet. |
| transfer_id | string (uuid) | Yes | Transfer id. Ids with the old txn- prefix are accepted too. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | The 6-digit code from user.transfer.verification_code_issued. |
| otp | string | No | Deprecated alias of code. Send one or the other, never both. |
{
"code": "602391"
}Success Response (200 OK):
{
"success": true,
"data": {
"id": "7c9e6f2a-1b3d-4e8f-9a2c-5d6e7f8a9b0c",
"object": "transfer",
"status": "COMPLETED",
"status_reason": null,
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"customer_id": "corp-redundant-firewall",
"wallet_id": "23c2bada-5134-5b83-848e-4bae9c3d4a8f",
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"currency": "USDC",
"amount": "25000.000000",
"fee": null,
"network": null,
"tx_hash": null,
"idempotency_key": "6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81",
"created_at": "2026-10-12T14:03:11Z",
"updated_at": "2026-10-12T14:04:30Z",
"completed_at": "2026-10-12T14:04:30Z",
"created_by": "12015942-4d29-5733-a774-14ee16e31da3",
"updated_by": "12015942-4d29-5733-a774-14ee16e31da3"
},
"meta": {
"timestamp": "2026-10-12T14:04:30Z",
"version": "v1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}curl -X POST "https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}/confirm" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer USER_TOKEN" \
-H "x-idempotency-key: 2d7b9f1e-4c6a-4b8d-a3e5-7f1c9b2d4e60" \
-d '{
"code": "602391"
}'Errors: 400 INVALID_VERIFICATION_CODE wrong code (the message says how many attempts remain) · 400 VERIFICATION_CODE_EXPIRED · 400 INSUFFICIENT_BALANCE the balance dropped before confirm; the transfer is now FAILED · 401 UNAUTHORIZED · 403 VERIFICATION_BLOCKED too many wrong codes · 403 ACCOUNT_FROZEN transfers are disabled while the account is frozen; the transfer stays PENDING (the code is checked first, and a ledger.transfer.failed event with account_frozen is sent) · 404 WALLET_NOT_FOUND · 404 TRANSFER_NOT_FOUND · 422 VALIDATION_ERROR no code, or both code and otp · 422 INVALID_STATE_TRANSITION the transfer isn't PENDING · 429 RATE_LIMIT_EXCEEDED.
Resend Verification Code
Issue a new verification code for a PENDING transfer. The previous code stops working, the attempts reset to 3 and a new user.transfer.verification_code_issued event goes out with trigger: "resent". Send no body. A BLOCKED, EXPIRED or final transfer can't get a new code.
POST https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}/verification-codesSuccess Response (200 OK):
{
"success": true,
"data": {
"id": "7c9e6f2a-1b3d-4e8f-9a2c-5d6e7f8a9b0c",
"object": "transfer",
"status": "PENDING",
"status_reason": null,
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"customer_id": "corp-redundant-firewall",
"wallet_id": "23c2bada-5134-5b83-848e-4bae9c3d4a8f",
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"currency": "USDC",
"amount": "25000.000000",
"fee": null,
"network": null,
"tx_hash": null,
"idempotency_key": "6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81",
"created_at": "2026-10-12T14:03:11Z",
"updated_at": "2026-10-12T14:06:40Z",
"completed_at": null,
"created_by": "12015942-4d29-5733-a774-14ee16e31da3",
"updated_by": "12015942-4d29-5733-a774-14ee16e31da3",
"verification": {
"expires_at": "2026-10-12T14:11:40Z",
"attempts_remaining": 3
}
},
"meta": {
"timestamp": "2026-10-12T14:06:40Z",
"version": "v1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}curl -X POST "https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}/verification-codes" \
-H "Authorization: Bearer USER_TOKEN"Errors: 401 UNAUTHORIZED · 404 WALLET_NOT_FOUND · 404 TRANSFER_NOT_FOUND · 422 INVALID_STATE_TRANSITION the transfer isn't PENDING · 429 RATE_LIMIT_EXCEEDED.
Deprecated
POST …/transfers/:transfer_id/verification/codes still works with the same behavior, and a type field in the body is tolerated and ignored. Both are removed in the next release: move to …/verification-codes with no body.
Cancel Transfer
Cancel a PENDING, BLOCKED or EXPIRED transfer. Nothing has moved for any of them. The transfer turns CANCELLED with status_reason.code CANCELLED_BY_USER. Send no body.
POST https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}/cancelSuccess Response (200 OK):
{
"success": true,
"data": {
"id": "7c9e6f2a-1b3d-4e8f-9a2c-5d6e7f8a9b0c",
"object": "transfer",
"status": "CANCELLED",
"status_reason": {
"code": "CANCELLED_BY_USER",
"message": "The transfer was cancelled before confirmation."
},
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"customer_id": "corp-redundant-firewall",
"wallet_id": "23c2bada-5134-5b83-848e-4bae9c3d4a8f",
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"currency": "USDC",
"amount": "25000.000000",
"fee": null,
"network": null,
"tx_hash": null,
"idempotency_key": "6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81",
"created_at": "2026-10-12T14:03:11Z",
"updated_at": "2026-10-12T14:05:27Z",
"completed_at": null,
"created_by": "12015942-4d29-5733-a774-14ee16e31da3",
"updated_by": "12015942-4d29-5733-a774-14ee16e31da3"
},
"meta": {
"timestamp": "2026-10-12T14:05:27Z",
"version": "v1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}curl -X POST "https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}/cancel" \
-H "Authorization: Bearer USER_TOKEN"Errors: 401 UNAUTHORIZED · 404 WALLET_NOT_FOUND · 404 TRANSFER_NOT_FOUND · 422 INVALID_STATE_TRANSITION the transfer is COMPLETED, FAILED or already CANCELLED, or a confirm is in progress · 429 RATE_LIMIT_EXCEEDED.
Deprecated
DELETE …/transfers/:transfer_id still cancels and answers 204 No Content with no body. It's removed in the next release: move to POST …/cancel.
Get Transfer
Return one transfer when wallet_id is its source or its destination, so both sides can read it.
GET https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}Success Response (200 OK):
{
"success": true,
"data": {
"id": "7c9e6f2a-1b3d-4e8f-9a2c-5d6e7f8a9b0c",
"object": "transfer",
"status": "COMPLETED",
"status_reason": null,
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"customer_id": "corp-redundant-firewall",
"wallet_id": "23c2bada-5134-5b83-848e-4bae9c3d4a8f",
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"currency": "USDC",
"amount": "25000.000000",
"fee": null,
"network": null,
"tx_hash": null,
"idempotency_key": "6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81",
"created_at": "2026-10-12T14:03:11Z",
"updated_at": "2026-10-12T14:04:30Z",
"completed_at": "2026-10-12T14:04:30Z",
"created_by": "12015942-4d29-5733-a774-14ee16e31da3",
"updated_by": "12015942-4d29-5733-a774-14ee16e31da3"
},
"meta": {
"timestamp": "2026-10-12T14:30:02Z",
"version": "v1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
}curl -X GET "https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers/{{transferID}}" \
-H "Authorization: Bearer USER_TOKEN"Errors: 401 UNAUTHORIZED · 404 WALLET_NOT_FOUND · 404 TRANSFER_NOT_FOUND · 429 RATE_LIMIT_EXCEEDED.
List Transfers
List a wallet's transfers, newest first, with cursor pagination. By default you get the transfers the wallet sent; pass direction INCOMING for the ones it received. An empty result is 200 with data: [].
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| filter[limit] | integer | No | Page size, 1 to 100 (default 25). |
| cursor | string | No | Opaque token from meta.pagination.navigation. Echo it back unchanged. |
| filter[order] | string | No | created_at or updated_at, plus ASC or DESC. Default created_at DESC; a field without a direction sorts ASC. |
| filter[where][direction][eq] | string | No | OUTGOING (default: wallet_id is the source) or INCOMING (wallet_id is the destination). |
| filter[where][status][eq|inq] | string | No | Any transfer status. |
| filter[where][currency][eq|inq] | string | No | Asset code. |
| filter[where][tx_hash][eq] | string | No | Accepted for parity with other lists; internal transfers have no hash. |
| filter[where][idempotency_key][eq] | string | No | Key sent on create. Finds a transfer after a timeout. |
| filter[where][created_at][gte|lte|between] | string | No | UTC date-time ending in Z. |
Up to 6 conditions per request. Any other field, operator or filter[skip] answers 422 with source.parameter.
GET https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers?filter[limit]=25&filter[where][direction][eq]=OUTGOINGSuccess Response (200 OK):
{
"success": true,
"data": [
{
"id": "7c9e6f2a-1b3d-4e8f-9a2c-5d6e7f8a9b0c",
"object": "transfer",
"status": "COMPLETED",
"status_reason": null,
"type": "PAYOUT",
"purpose": "Payment goods",
"description": "Transfer to wallet 1",
"user_id": "5299e81c-3a8c-5b64-a112-b93bf43de74b",
"customer_id": "corp-redundant-firewall",
"wallet_id": "23c2bada-5134-5b83-848e-4bae9c3d4a8f",
"destination_wallet_id": "fbe38fa1-7794-565d-953f-73bbb8c51979",
"currency": "USDC",
"amount": "25000.000000",
"fee": null,
"network": null,
"tx_hash": null,
"idempotency_key": "6f1c2a9e-8b4d-4e3a-b7c5-0d9e2f4a6b81",
"created_at": "2026-10-12T14:03:11Z",
"updated_at": "2026-10-12T14:04:30Z",
"completed_at": "2026-10-12T14:04:30Z",
"created_by": "12015942-4d29-5733-a774-14ee16e31da3",
"updated_by": "12015942-4d29-5733-a774-14ee16e31da3"
}
],
"meta": {
"timestamp": "2026-10-12T14:30:02Z",
"version": "v1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"pagination": {
"records": { "limit": 25, "has_next": true, "has_previous": false },
"navigation": {
"next": "/v1/wallets/23c2bada-5134-5b83-848e-4bae9c3d4a8f/transfers?filter[limit]=25&filter[where][direction][eq]=OUTGOING&cursor=UvbIo6YDi8JMM-JsfQXbOHFs",
"previous": null
}
}
}
}curl -G "https://api.sandbox.sovera.io/sovx/v1/wallets/{{walletID}}/transfers" \
-H "Authorization: Bearer USER_TOKEN" \
--data-urlencode "filter[limit]=25" \
--data-urlencode "filter[where][direction][eq]=OUTGOING"Errors: 401 UNAUTHORIZED · 404 WALLET_NOT_FOUND · 422 VALIDATION_ERROR unsupported filter, order, limit or cursor (source.parameter names it) · 429 RATE_LIMIT_EXCEEDED.