---
title: "Create and fund a cross-border transaction"
method: POST
path: "/irt/v1/transactions"
tags: ["IRT"]
---

# Create and fund a cross-border transaction

`POST /irt/v1/transactions`

## Request

- The document declares no server URL.
- Auth: none declared. Required headers: `ApiKey`, `MerchantId`, `Environment`

Initiates a cross-border payout in a single call: resolves the merchant configuration, funds the transaction against the quote identified by rateId, reserves the float and dispatches the payout asynchronously. Returns synchronously with state `pending`. Replaying the same externalId returns the original transaction instead of creating a second payout. When duplicate protection is enforced for your account, the externalId is also your payout reference across every payout endpoint: a replay with the same payout details answers 200 with the `X-Idempotent-Replay: true` header and `idempotentReplay: true` in the body, and an externalId already used with different payout details, or held by a transfer, is refused with 409 `duplicate_reference`. An existing transaction with this externalId that does not itself hold your payout reference — one created before duplicate protection, for instance — is still returned whatever the details sent, even when a transfer holds the reference: 200 with the `X-Idempotent-Replay: true` header and `idempotentReplay: true` in the body when duplicate protection is enforced, 201 otherwise.

## Headers

- `ApiKey` string, required
- `MerchantId` string, required
- `Environment` 'live' | 'sandbox', required

## Request body

- CreateCrossBorderTransactionDto
  - `externalId` string, required: Merchant-owned idempotency key. Replaying a request with the same external_id returns the original transaction instead of creating a second payout. When duplicate protection is enforced for your account, it is also your payout reference across every payout endpoint: reusing it with different payout details, or when a transfer holds it, is refused with 409 duplicate_reference. An existing transaction with this external_id that does not itself hold your payout reference (one created before duplicate protection, for instance) is still returned whatever the details sent, even when a transfer holds the reference: 200 with the X-Idempotent-Replay header when duplicate protection is enforced, 201 otherwise.
  - `rejectionId` string: Confirms a payout refused with `409 possible_duplicate`: the `rejection_id` of that response. Resend the refused payout unchanged, to the same endpoint and with the same reference (the `externalId`, for a cross-border transaction), before the response's `expires_at`. A rejection confirms one payout only: resending the confirmed payout again gets the idempotent replay, while an unknown or expired rejection, one replaced by a later refusal of its confirmation, one already used by another payout, or one that refused a different payout is answered `422 invalid_duplicate_confirmation`. Ignored unless duplicate protection and near-duplicate protection are both enforced for your account.
  - `senderId` string, required: Identifier of a registered sender belonging to this merchant. The sender must be approved.
  - `rateId` string, uuid, required: Identifier of the quote to fund at, as returned by `POST /irt/v1/quote/calculate`. The quote must not have expired and its rate must still be current, otherwise the request is rejected with 422 rate_expired. A quote is funded for the rail, the country and, on a mobile wallet, the operator it was quoted for: another `recipient.rail` is rejected with 422 quote_rail_mismatch, another `recipient.countryCode` with 422 quote_country_mismatch, another `recipient.provider` with 422 quote_provider_mismatch.
  - `amount` string, required: Amount to send, in the quote input currency, as an exact decimal string. Must match the amount the quote was issued for.
  - `currency` string, required: Input currency of the amount, ISO 4217. Must match the quote input currency.
  - `recipient` CrossBorderRecipientDto, required
    - `rail` 'mobile_wallet' | 'bank_transfer', required: Last-mile rail the payout travels on. Decides which recipient fields are required.
    - `firstName` string, required: Recipient first name
    - `lastName` string, required: Recipient last name
    - `countryCode` string, required: Two-letter [ISO country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) of the recipient.
    - `provider` string: Provider handling the payout, in lowercase. Required for the mobile_wallet rail. Use the operator slug of the recipient country (e.g. orange, mtn, moov, wave); Celtiis Benin is celtis or celtiis. An unknown slug fails the payout with bad_parameters.
    - `phone` string: Recipient mobile number in E.164 format. Required for the mobile_wallet rail.
    - `accountNumber` string: Recipient bank account number. Required for the bank_transfer rail.
    - `bankCode` string: Hub2 bank code; the connector resolves the provider bank code from it. Required for the bank_transfer rail.
    - `middleName` string: Recipient middle name
    - `address1` string: Recipient address line 1
    - `address2` string: Recipient address line 2
    - `city` string: Recipient city
    - `email` string: Recipient e-mail address
    - `dateOfBirth` string: Recipient date of birth, YYYY-MM-DD
    - `idType` string: Recipient identity document type
    - `idNumber` string: Recipient identity document number
  - `description` string: Free-text description carried on the transaction

## Response `200`

Idempotent replay. Duplicate protection is enforced for your account, and a payout with this reference and the same payout details already exists: it is returned as it is now, and nothing is created or charged. Only ever sent with the `X-Idempotent-Replay: true` header and the body field `idempotentReplay: true`, which is absent from a normal creation. The payout details compared are the destination type, country, beneficiary, operator, requested amount and currency; on IRT and cross-border routes, also the sender identity and, on cross-border, the `rateId`, unless the reference was recorded by an earlier version of this check, which compared neither; other names (recipient, bank, display name), descriptions and metadata are ignored. Also returned when duplicate protection is enforced, whatever the details sent and even when a transfer holds the reference, for an existing transaction with this externalId that does not itself hold your payout reference (one created before duplicate protection, for instance).

- CrossBorderTransactionDto
  - `id` string, required: Hub2 transaction identifier
  - `externalId` string, required: Merchant-owned idempotency key echoed back
  - `status` 'created' | 'pending' | 'compliance_review' | 'pending_manual' | 'successful' | 'failed' | 'cancelled', required: Current state. A successful create-and-fund returns `pending`: the float is reserved and the payout is on its way.
  - `senderId` string, required: Registered sender the payout was initiated for
  - `corridor` string, required: Corridor the payout was priced on
  - `rail` 'mobile_wallet' | 'bank_transfer', required: Last-mile rail
  - `inputCurrency` string, required: Input currency, ISO 4217
  - `inputAmount` string, required: Amount sent, exact decimal string. This is the amount converted and paid out — the Hub2 fee is charged on top of it, so the float is debited `inputAmount` plus `hub2Fee`.
  - `outputCurrency` string, required: Output currency, ISO 4217
  - `outputAmount` string, required: Amount the recipient receives, frozen at funding. Priced on the full `inputAmount` — the Hub2 fee is additive and never reduces it.
  - `hub2Fee` string, required: Total Hub2 fee applied, frozen at funding. The sum of the `fees` lines. Charged in addition to `inputAmount`, not deducted from it.
  - `fees` CrossBorderFeeDto[], required: Itemisation behind `hub2Fee`, frozen at funding and never recomputed. Each line carries the taxes reported against it.
    - `id` string, required: Unique identifier of the applied fee
    - `rate` string, required: The configured rate, as an exact two-decimal string: a percentage for `percent`, the flat amount for `flat`. On a `flat` line it normally equals `amount`. Where the configured rate is finer than the currency's minor unit the two differ by a cent, and `amount` is the one that was charged.
    - `type` 'flat' | 'percent', required: How the fee is derived from the amount sent. `percent` applies the rate as a percentage of it; `flat` applies the rate as is.
    - `amount` string, required: Amount charged by this line, in `currency`, as an exact two-decimal string — the same format as the `hub2Fee` total it sums into
    - `currency` string, required: Currency the fee is charged in, ISO 4217. Always the input currency.
    - `label` string: Optional label explaining the reason for this fee
    - `taxes` CrossBorderFeeTaxDto[], required: Taxes applicable to this fee line. Reported for your records — never charged.
      - `id` string, required: Unique identifier of the tax row
      - `taxType` 'tva', required: The kind of tax applied
      - `type` 'flat' | 'percent', required: Whether the tax is a percentage of the fee or a flat amount
      - `value` string, required: Tax rate or amount, per `type`, as an exact two-decimal string
  - `hub2Rate` string, required: Exchange rate applied, frozen at funding and never recalculated
  - `description` string: Free-text description carried on the transaction
  - `failureCause` CrossBorderTransactionFailureCauseDto
    - `code` 'unknown_reason' | 'internal_error' | 'service_unavailable' | 'service_temporarily_disabled' | 'too_many_request' | 'timeout' | 'insufficient_funds' | 'hub2_insufficient_funds' | 'forbidden_by_provider' | 'forbidden_by_provider_maximum_limit' | 'canceled' | 'unsupported_currency' | 'invalid_payment_processor' | 'invalid_amount' | 'fraud_suspicion' | 'invalid_destination' | 'bad_request' | 'duplicate_request' | 'authentication_failed' | 'bad_parameters', required: Failure cause code
    - `message` string: Human-readable message, when the partner provided one
  - `duplicateConfirmation` DuplicateConfirmationDto
    - `rejectionId` string, required: The `rejection_id` of the `possible_duplicate` refusal this payout confirmed.
  - `createdAt` string, required: Creation timestamp, ISO 8601
  - `updatedAt` string, required: Timestamp of the last state change, ISO 8601. Equal to "createdAt" until the transaction first moves, so a lookup can tell a payout still awaiting its partner from one that settled without a webhook arriving.
  - `idempotentReplay` true: Present, and `true`, only when the response replays an existing payout (idempotent replay, HTTP 200 with the `X-Idempotent-Replay: true` header). Absent when the transaction has just been created.

## Other responses

- `201`
- `400`: Bad Request: DTO validation failure, or, with duplicate protection enforced, code `reference_required` when the reference (`externalId` on cross-border) is blank — empty or only whitespace. Where the route's validation already refuses an empty value (`/irt/*`, cross-border), only a whitespace-only value reaches `reference_required`.
- `401`: Wrong credentials
- `409`: Conflict. With code `duplicate_reference` (duplicate protection enforced): the reference is already used by a payout with different details, or by a payout of another kind. The refusal does not mean that a payout failed: the body carries `original_transaction_id`, the id of the payout that already holds the reference, to fetch its status. It is absent in two cases, when there is no payout id to give: the payout holding the reference does not belong to your merchant scope and environment, or it cannot be read. With code `possible_duplicate` (near-duplicate protection enforced): a payout with the same details was created under another reference in the last 15 minutes, on this or another duplicate-protected payout endpoint, whatever its status — a failed or cancelled payout counts too; the reference stays free. The body then also carries `matching_transfers` (the earlier payouts: `id`, `reference`, `status`, `created_at`, `operator`, `amount`, `currency`; an internal `awaiting_confirmation` status is shown as `pending`), `end_user_message` (a text in `en` and `fr` that a merchant can show its end user, depending on whether the most advanced of the earlier payouts is sent, in progress or reported as failed), `rejection_id` and `expires_at`: resend the payout unchanged, to the same endpoint and with the same reference (the `externalId`, for a cross-border transaction), with `rejectionId` set to `rejection_id` before `expires_at` to confirm it. A confirmation is refused again, with a new `rejection_id`, when it finds such a payout the refusal had not seen — typically one created since; only that new `rejection_id` can then confirm it, and the one sent is answered `422` with reason `expired`. It is checked last, so a request that another check refuses or replays — validation, `reference_required`, `duplicate_reference`, the idempotent replay, the testing-merchant limit, on cross-border the quote and float checks, or an invalid `rejectionId`'s 422 — gets that answer instead. Nothing is created. The route's other conflicts keep their own code.
- `422`: Unprocessable Entity Unprocessable Entity. With code `invalid_duplicate_confirmation` (near-duplicate protection enforced): the `rejectionId` sent cannot confirm this payout — it is expired, replaced by a later refusal of its confirmation, or already used, the payout or its reference, or its flow, differs from the one it refused, or it is unknown — including one that belongs to another merchant, scope or mode. A flow is one of: transfers, delegated transfers, IRT transfers, delegated IRT transfers, cross-border transactions. The `reason` field says which: `expired`, `used`, `content_changed` or `unknown`. Nothing is created; resend the payout without `rejectionId` to get a new one if it is still a possible duplicate. A retry of a confirmation that already created its payout gets the idempotent replay, not this error. The route's other unprocessable requests keep their own code — on cross-border, `Unprocessable Entity` with the reason in `message` (`quote_not_found`, `rate_expired`, `transaction_not_created`, …).
- `429`: ThrottlerException: Too Many Requests
- `500`: Internal error
- `503`: Service unavailable

## Changes

- **2026-11-03** `f6d3f87df5e0`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/hub2/apis/hub2-service-api/changes/irt/v1/transactions/post.md)

---

[API](https://skmtc.dev/hub2/apis/hub2-service-api.md) - [All operations](https://skmtc.dev/hub2/apis/hub2-service-api/llms.txt) - [OpenAPI document](https://skmtc.dev/hub2/apis/hub2-service-api/revisions/f6d3f87df5e0?raw)
