diff --git a/api-reference/fx-payment/operations/create-transfer.mdx b/api-reference/fx-payment/operations/create-transfer.mdx new file mode 100644 index 0000000..f726a56 --- /dev/null +++ b/api-reference/fx-payment/operations/create-transfer.mdx @@ -0,0 +1,5 @@ +--- +title: "Create a transfer" +description: "Moves funds between two accounts of the same customer using a locked quote." +openapi: "apis/fx-payment/openapi.yml POST /api/operations/transfer" +--- diff --git a/apis/fx-payment/openapi.yml b/apis/fx-payment/openapi.yml index 09f1f39..93b6a00 100644 --- a/apis/fx-payment/openapi.yml +++ b/apis/fx-payment/openapi.yml @@ -973,6 +973,49 @@ components: example: "PIX_KEY" required: - rail + CreateTransferRequest: + type: object + description: > + Same-asset transfer between two accounts of the same customer. The asset on `amount` + must be enabled on both accounts. No FX is performed — `sourceAmount` and `targetAmount` + on the response are equal to `amount`. The system generates a 1:1 quote internally + and returns it on `quote`; callers normally do not provide `quoteId`. + properties: + sourceAccountId: + type: string + format: uuid + description: Source account that will be debited. + example: "a1b2c3d4-5e6f-7890-abcd-ef1234567890" + targetAccountId: + type: string + format: uuid + description: > + Target account that will be credited. Must belong to the same customer as the + source account, must be in `ACTIVE` status, and must have `amount.asset` enabled. + Cannot equal `sourceAccountId`. + example: "b2c3d4e5-6f70-8901-bcde-f12345678901" + amount: + type: object + description: Amount to move. The same value is debited from the source and credited to the target. + properties: + value: + $ref: "#/components/schemas/AmountValue" + asset: + $ref: "#/components/schemas/Asset" + required: + - value + - asset + quoteId: + type: string + format: uuid + description: > + Optional. ID of a quote returned by `POST /api/quotes`. Reserved for future use; + same-asset transfers do not require one and generate a 1:1 quote internally. + example: "d4e5f6a7-b8c9-0123-defa-2345678901bc" + required: + - sourceAccountId + - targetAccountId + - amount WithdrawalBeneficiary: description: | Beneficiary for the withdrawal. Discriminated by `type`. @@ -1088,8 +1131,10 @@ components: type: object description: > An operation. Withdrawals (`POST /api/operations/withdrawal`), swaps - (`POST /api/operations/swap`), and deposits (`POST /api/operations/deposit`) - share this shape, discriminated by `intent.type`. + (`POST /api/operations/swap`), deposits (`POST /api/operations/deposit`), + and transfers (`POST /api/operations/transfer`) share this shape, discriminated + by `intent.type`. The `quote` field is always present; for transfers without an + explicit `quoteId` in the request, the system generates a 1:1 same-asset quote. properties: id: type: string @@ -1222,12 +1267,14 @@ components: - $ref: "#/components/schemas/OperationWithdrawalIntent" - $ref: "#/components/schemas/OperationSwapIntent" - $ref: "#/components/schemas/OperationDepositIntent" + - $ref: "#/components/schemas/OperationTransferIntent" discriminator: propertyName: type mapping: WITHDRAWAL: "#/components/schemas/OperationWithdrawalIntent" SWAP: "#/components/schemas/OperationSwapIntent" DEPOSIT: "#/components/schemas/OperationDepositIntent" + TRANSFER: "#/components/schemas/OperationTransferIntent" OperationWithdrawalIntent: title: Withdrawal type: object @@ -1270,6 +1317,22 @@ components: required: - type - fundingInstruction + OperationTransferIntent: + title: Transfer + type: object + description: > + Intent for a transfer. Carries a snapshot of the target account at the time the + operation was created. + properties: + type: + type: string + enum: + - TRANSFER + targetAccount: + $ref: "#/components/schemas/AccountReference" + required: + - type + - targetAccount OperationFundingInstruction: description: Funding instruction the customer uses to send money in. Discriminated by `rail`. oneOf: @@ -2223,6 +2286,15 @@ components: resource: "Account" parameters: id: "a1b2c3d4-5e6f-7890-abcd-ef1234567890" + TargetAccountNotFound: + summary: Target account does not exist or is not owned by the authenticated customer + value: + code: "RESOURCE_NOT_FOUND" + message: "Account with given parameters [id:b2c3d4e5-6f70-8901-bcde-f12345678901] not found" + details: + resource: "Account" + parameters: + id: "b2c3d4e5-6f70-8901-bcde-f12345678901" InstructionNotFound: summary: Payment instruction does not exist on the beneficiary value: @@ -2937,6 +3009,220 @@ paths: quoteAlreadyConsumed: $ref: "#/components/examples/QuoteAlreadyConsumed" + # ── Operations: Transfers ───────────────────────────────────────── + /api/operations/transfer: + post: + operationId: createTransfer + tags: + - Operations + summary: Create a transfer + description: > + Creates a same-asset transfer that debits one customer account and credits another. + Both accounts must belong to the authenticated customer and must have `amount.asset` + enabled. No FX is performed — the same amount is moved on both legs. The system + generates a 1:1 quote internally and returns it on `quote`; passing `quoteId` is + optional and reserved for future use. Returns `201` immediately with the operation + in `REQUESTED` status; settlement happens asynchronously. + + Cross-customer transfers are not supported in the current slice — the target account + must be owned by the same customer as the source account. + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + - $ref: "#/components/parameters/TraceVersion" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTransferRequest" + examples: + brlTransfer: + summary: BRL transfer between two accounts of the same customer + value: + sourceAccountId: "a1b2c3d4-5e6f-7890-abcd-ef1234567890" + targetAccountId: "b2c3d4e5-6f70-8901-bcde-f12345678901" + amount: + value: "500.00" + asset: "BRL" + responses: + "201": + description: Transfer accepted. The operation is in `REQUESTED` status. + headers: + X-Request-Id: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/OperationResponse" + examples: + brlTransfer: + summary: BRL transfer in REQUESTED state + value: + id: "4c6d1f10-5b4d-7e6d-cf5b-ae4d7e6dcf5b" + customerId: "11111111-1111-1111-1111-111111111111" + account: + id: "a1b2c3d4-5e6f-7890-abcd-ef1234567890" + owner: "Acme Importação Ltda" + sourceAmount: + value: "500.00" + asset: "BRL" + decimals: 2 + targetAmount: + value: "500.00" + asset: "BRL" + decimals: 2 + fees: [] + transactions: [] + tags: + - key: "psp" + value: "Amazon" + - key: "compliance-tier" + value: "high" + currentState: + status: "REQUESTED" + createdAt: "2026-05-06T16:24:11Z" + states: + - status: "REQUESTED" + createdAt: "2026-05-06T16:24:11Z" + intent: + type: "TRANSFER" + targetAccount: + id: "b2c3d4e5-6f70-8901-bcde-f12345678901" + owner: "Acme Importação Ltda" + quote: + id: "d4e5f6a7-b8c9-0123-defa-2345678901bc" + effectiveRate: + value: "1.00" + base: "BRL" + quote: "BRL" + expiresAt: "2026-05-06T16:24:41Z" + consumedAt: "2026-05-06T16:24:11Z" + createdAt: "2026-05-06T16:24:11Z" + updatedAt: "2026-05-06T16:24:11Z" + "400": + description: "Validation error: missing or malformed fields." + headers: + X-Request-Id: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + missingSourceAccountId: + summary: sourceAccountId is missing + value: + code: "INVALID_DATA" + message: "Object contains invalid data" + details: + errors: + - code: "REQUIRED" + message: "Parameter 'body:sourceAccountId' not found in request" + field: "body:sourceAccountId" + params: {} + missingTargetAccountId: + summary: targetAccountId is missing + value: + code: "INVALID_DATA" + message: "Object contains invalid data" + details: + errors: + - code: "REQUIRED" + message: "Parameter 'body:targetAccountId' not found in request" + field: "body:targetAccountId" + params: {} + invalidSourceAccountUuid: + summary: sourceAccountId is not a UUID + value: + code: "INVALID_DATA" + message: "Object contains invalid data" + details: + errors: + - code: "INVALID_UUID" + message: "'not-a-uuid' is not a valid UUID" + field: "body:sourceAccountId" + params: {} + missingAmount: + summary: amount is missing + value: + code: "INVALID_DATA" + message: "Object contains invalid data" + details: + errors: + - code: "REQUIRED" + message: "Parameter 'body:amount' not found in request" + field: "body:amount" + params: {} + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + description: Source or target account not found for this customer. + headers: + X-Request-Id: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + sourceAccountNotFound: + $ref: "#/components/examples/AccountNotFound" + targetAccountNotFound: + $ref: "#/components/examples/TargetAccountNotFound" + "409": + description: Idempotency key was reused with a different request body. + headers: + X-Request-Id: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + idempotencyConflict: + $ref: "#/components/examples/IdempotencyConflict" + "422": + description: Business rule violation. + headers: + X-Request-Id: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + sameSourceAndTarget: + summary: Source and target accounts are the same + value: + code: "SAME_SOURCE_AND_TARGET" + message: "Source and target accounts must differ" + details: + accountId: "a1b2c3d4-5e6f-7890-abcd-ef1234567890" + targetAccountNotActive: + summary: Target account is not in ACTIVE status + value: + code: "TARGET_ACCOUNT_NOT_ACTIVE" + message: "Target account is not active" + details: + targetAccountId: "b2c3d4e5-6f70-8901-bcde-f12345678901" + status: "OPENING" + sourceAssetNotEnabled: + summary: Requested asset is not enabled on the source account + value: + code: "SOURCE_ASSET_NOT_ENABLED" + message: "Asset is not enabled on the source account" + details: + asset: "USD" + sourceAccountId: "a1b2c3d4-5e6f-7890-abcd-ef1234567890" + targetAssetNotEnabled: + summary: Requested asset is not enabled on the target account + value: + code: "TARGET_ASSET_NOT_ENABLED" + message: "Asset is not enabled on the target account" + details: + asset: "USD" + targetAccountId: "b2c3d4e5-6f70-8901-bcde-f12345678901" + # ── Quotes ──────────────────────────────────────────────────────── /api/quotes: post: diff --git a/docs.json b/docs.json index a02191f..3298212 100644 --- a/docs.json +++ b/docs.json @@ -77,7 +77,8 @@ "journeys/open-multi-currency-account", "journeys/deposit", "journeys/withdrawal", - "journeys/swap" + "journeys/swap", + "journeys/transfer" ] } ] @@ -135,6 +136,7 @@ "api-reference/fx-payment/operations/create-withdrawal", "api-reference/fx-payment/operations/create-swap", "api-reference/fx-payment/operations/create-deposit", + "api-reference/fx-payment/operations/create-transfer", "api-reference/fx-payment/operations/get-operation", "api-reference/fx-payment/operations/list-operations" ] diff --git a/journeys/transfer.mdx b/journeys/transfer.mdx new file mode 100644 index 0000000..5c4efc6 --- /dev/null +++ b/journeys/transfer.mdx @@ -0,0 +1,58 @@ +--- +title: "Make a transfer" +description: "Step-by-step guide to moving funds between two accounts of the same customer." +--- + +## Overview + +Transfers move funds between two accounts owned by the same customer. They are same-asset — the same value is debited from the source account and credited to the target account, with no FX conversion. The system generates a 1:1 quote internally and returns it on the response; you do not need to create a quote first. To move between assets, [execute a swap](/journeys/swap) and then transfer. + +## Prerequisites + +- Two [active accounts](/journeys/open-multi-currency-account) owned by the same customer, both with the requested asset enabled. The source must hold sufficient balance. +- Valid [authentication credentials](/guides/authentication). + +## Steps + + + + Reference the source account, the target account, and the amount to move. Source and target must differ; the target account must be in `ACTIVE` status with the requested asset enabled. + + ```bash + curl --request POST \ + --url {{sandboxUrl}}/api/operations/transfer \ + --header 'Authorization: Bearer ' \ + --header 'X-Idempotency-Key: ' \ + --header 'Content-Type: application/json' \ + --data '{ + "sourceAccountId": "", + "targetAccountId": "", + "amount": { + "value": "500.00", + "asset": "BRL" + } + }' + ``` + + Returns `201` immediately with the operation in `REQUESTED` status. Settlement happens asynchronously. + + + + Poll `GET /api/operations/{operationId}` to follow the operation through `PROCESSING` to `COMPLETED` (or `FAILED`). Lifecycle transitions are not published as webhook events; the `OPERATION_REQUESTED` webhook only fires on creation. + + ```bash + curl --request GET \ + --url {{sandboxUrl}}/api/operations/ \ + --header 'Authorization: Bearer ' + ``` + + + + + Cross-customer transfers are not supported — both accounts must belong to the same customer. If the target account belongs to another customer, the API returns `404 RESOURCE_NOT_FOUND`. + + +## What happens next + +- [Execute a swap](/journeys/swap) — convert between assets within a single account. +- [Make a withdrawal](/journeys/withdrawal) — send funds out to an external beneficiary.