Internal Transfers Guide
This guide explains how to perform internal transfers between accounts at the same bank, covering the three available methods and their use cases.
Overview
Internal transfers move funds between accounts within the same bank, without going through the PIX network. They are processed instantly.
There are three ways to identify the destination account:
| Method | Endpoint | Destination requirement | Use case |
|---|---|---|---|
| By Account ID | /transfers/internal | Must have API access enabled | Transfers between your own accounts |
| By Document | /transfers/internal/by-document | Any account at the bank | Transfers to any account holder |
| By Branch/Account | /transfers/internal/by-bank-account | Must have API access enabled | When you have branch and account number |
1. Transfer by Account ID
The most performant method. Use when both source and destination accounts are registered in the API.
Endpoint: POST /v1/accounts/{accountId}/transfers/internal
curl -X POST "https://tenant.api.corpx.com/v1/accounts/{accountId}/transfers/internal" \
-H "Authorization: Bearer {token}" \
-H "X-Tenant-Id: tenant-yourcompany" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: int-001" \
-d '{
"destinationAccountId": "773107de-139e-48d1-9462-f4e88f251891",
"value": 1500.00,
"description": "Transfer between branches",
"identifier": "branch-transfer-001"
}'
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
destinationAccountId | string (UUID) | Yes | Destination account UUID (API internal ID) |
value | number | Yes | Amount in BRL (max 2 decimal places) |
description | string | No | Transfer description (max 140 characters) |
identifier | string | No | Unique identifier for tracking. Auto-generated when omitted |
Response (200):
{
"paymentId": "pay_2f4a0f88-2147-49f2-a4e2-4f7b9f6c0f7a",
"workflowId": "internal-out-{accountId}-branch-transfer-001-int-001",
"runId": "b7c1f0e2-...",
"transactionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"endToEndId": "E50871921202603181530000000001",
"destinationAccountId": "773107de-139e-48d1-9462-f4e88f251891",
"status": "COMPLETED",
"value": 1500.00,
"description": "Transfer between branches",
"identifier": "branch-transfer-001",
"idempotencyKey": "int-001",
"completedAt": "2026-03-18T15:30:00Z"
}
When the transfer is refused, the same body comes back with 422,
status: "FAILED" and the errorCode/errorReason fields filled in.
2. Transfer by Document (CPF/CNPJ)
Use when the destination account is not registered in the API. Works for any account in the banking ecosystem.
Endpoint: POST /v1/accounts/{accountId}/transfers/internal/by-document
curl -X POST "https://tenant.api.corpx.com/v1/accounts/{accountId}/transfers/internal/by-document" \
-H "Authorization: Bearer {token}" \
-H "X-Tenant-Id: tenant-yourcompany" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: int-002" \
-d '{
"document": "12345678000190",
"value": 500.00,
"description": "Supplier payment",
"identifier": "supplier-payment-002"
}'
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
document | string | Yes | CPF or CNPJ of the destination account holder (numbers only) |
value | number | Yes | Amount in BRL |
description | string | No | Transfer description (max 140 characters) |
identifier | string | No | Unique identifier for tracking. Auto-generated when omitted |
When the destination document has more than one active account,
addressing by document is ambiguous and the request is rejected with
409 multiple_destination_accounts. In that case, use
/transfers/internal/by-bank-account (branch + account number) or
/transfers/internal (destinationAccountId).
3. Transfer by Branch and Account Number
Use when you have the branch and account number. Both accounts must be registered in the API.
Endpoint: POST /v1/accounts/{accountId}/transfers/internal/by-bank-account
curl -X POST "https://tenant.api.corpx.com/v1/accounts/{accountId}/transfers/internal/by-bank-account" \
-H "Authorization: Bearer {token}" \
-H "X-Tenant-Id: tenant-yourcompany" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: int-003" \
-d '{
"branch": "0001",
"accountNumber": "200038274",
"holderDocument": "12345678000190",
"holderName": "DESTINATION COMPANY LTDA",
"value": 250.00,
"description": "Refund",
"identifier": "refund-003"
}'
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
branch | string | Yes | Destination account branch |
accountNumber | string | Yes | Destination account number |
holderDocument | string | Yes | CPF/CNPJ of the destination account holder — the settlement bank requires it for routing |
holderName | string | No | Destination holder name |
value | number | Yes | Amount in BRL |
description | string | No | Transfer description (max 140 characters) |
identifier | string | No | Unique identifier for tracking. Auto-generated when omitted |
Which method to choose?
Does the destination account have API access?
├── YES → Do you have the Account ID (UUID)?
│ ├── YES → Use /transfers/internal (fastest)
│ └── NO → Use /transfers/internal/by-bank-account
└── NO → Use /transfers/internal/by-document
Practical examples:
- Moving funds between your company's branches →
/transfers/internal(by Account ID) - Paying a supplier at the bank →
/transfers/internal/by-document(by CPF/CNPJ) - Transferring to a known account by branch number →
/transfers/internal/by-bank-account
Webhooks
After an internal transfer, both parties receive a webhook:
- Sender:
transfer.internal.out - Receiver:
transfer.internal.in
{
"id": "transfer-internal-out-pay_2f4a0f88-2147-49f2-a4e2-4f7b9f6c0f7a",
"type": "transfer.internal.out",
"occurredAt": "2026-03-18T15:30:00.000000000Z",
"schemaVersion": "1.0",
"environment": "production",
"tenantId": "tenant-yourcompany",
"accountId": "a1b2c3d4-aaaa-bbbb-cccc-111122223333",
"data": {
"paymentId": "pay_2f4a0f88-2147-49f2-a4e2-4f7b9f6c0f7a",
"transactionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"endToEnd": "E50871921202603181530000000001",
"accountId": "a1b2c3d4-aaaa-bbbb-cccc-111122223333",
"direction": "OUT",
"sourceAccountId": "a1b2c3d4-aaaa-bbbb-cccc-111122223333",
"sourceTenantId": "tenant-yourcompany",
"destinationAccountId": "773107de-139e-48d1-9462-f4e88f251891",
"destinationTenantId": "tenant-yourcompany",
"amount": 1500.00,
"description": "Transfer to SP branch",
"identifier": "branch-transfer-001",
"status": "SUCCESS"
}
}
The data block is flat: there are no nested source/destination
objects with name and tax ID — the counterparties show up as
sourceAccountId / destinationAccountId. The transfer.internal.in leg
carries the same payload with the destination accountId and
direction: "IN".
Because settlement is atomic and synchronous, the webhook always arrives with data.status: "SUCCESS" — it is the only possible value for transfer.internal.*. Failures (insufficient funds, non-existent destination account, blocked account, etc.) are signalled in the HTTP response of the POST /transfers/internal* call itself and never produce a webhook.
The webhook sent to the payer (transfer.internal.out) carries the same identifier and description you sent in the request body (POST /transfers/internal*), together with the paymentId and transactionId that are also in the synchronous response. That removes the need for extra statement lookups to close the transfer on your side.
The transfer.internal.in leg is only emitted when the destination account is also managed at CorpX; it repeats the same fields (including the identifier set by the payer), changing only accountId and direction.
To receive these webhooks, include transfer.internal.in and/or transfer.internal.out in your subscription event types.
Common Errors
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | missing_fields | Missing required field (value, description, destination) |
| 400 | invalid_field | More than 2 decimal places, invalid branch, malformed description |
| 400 | invalid_identifier | identifier outside the [A-Za-z0-9._-] charset or longer than 38 characters |
| 404 | beneficiary_not_found | Destination account does not exist (unknown destinationAccountId at CorpX, or a document with no account at the settlement bank) |
| 422 | beneficiary_not_active_at_partner | Account exists at CorpX, but the settlement bank has no holder yet (accreditation pending) |
| 422 | beneficiary_incomplete | Settlement bank returned a holder without branch/account |
| 422 | recipient_account_not_found | Settlement bank could not find the destination account provided |
| 409 | multiple_destination_accounts | Document has more than one active account (use by-bank-account) |
| 409 | identifier_conflict | A transfer with this identifier already exists for this source account |
| 422 | insufficient_funds | Available balance does not cover the amount |
| 422 | balance_reservation_failed | Balance exists but could not be reserved (hold, concurrency) |
| 422 | partner_account_disabled | Source account disabled at the bank |
| 422 | limit_exceeded_transaction / limit_exceeded_daily | Transfer limit exceeded |
| 422 | partner_rejected | Refused by the bank's internal policy. The partner block carries the stated reason |
| 502 | partner_error / partner_unavailable | Error or outage at the bank |
422 is an outcome, 202 is a doubtA refused transfer answers 422 with status: "FAILED", errorCode and
errorReason — the money did not move and no webhook is coming. A 202
with status: "PENDING" means something else: the call went out and
confirmation did not arrive within the synchronous window. In that case check the
statement before re-issuing — repeating the POST with the same
Idempotency-Key reuses the in-flight operation, but a fresh key may transfer
twice.