Skip to content

Create a payment order ​

A payment order connects a business order in your system to the Taria Pay payment flow. When your server creates a payment intent, it receives a paymentId and a checkoutUrl.

  • With Hosted Checkout, the frontend redirects to checkoutUrl.
  • With the Checkout Button, the button on your page opens checkoutUrl.
  • With the Checkout Panel, the frontend passes paymentId to <CheckoutPanel />.

Who makes the request ​

The request must come from your server. Never call the Taria Pay API directly from the browser, and never put an API key in frontend code, mobile app bundles, or public repositories.

FieldRequiredDescription
orderIdRequiredYour own order ID; must be unique within your merchant account
amountRequiredThe order amount; a string is recommended
currencyRequiredUSDC, USDT, or EURC — the primary coin
acceptedCurrenciesOptionalExtra coins the hosted checkout may offer, e.g. ["USDC","USDT"]. The primary currency is always included. Omit to show only currency.
successUrlRecommendedWhere the buyer returns after paying. The checkout page never sees it: Taria Pay redirects there server-side, only once the payment is processing or confirmed, appending payment_id, tx_hash and checkout_mode. The redirect is only issued to the browser that opened the checkout page before the payment; any other visitor sees the paid status with no return link
cancelUrlRecommendedWhere the buyer returns after canceling (also redirected server-side)
displayReferenceOptionalThe order reference the buyer sees on the checkout page (an invoice number, a short order code), up to 64 printable characters. Your orderId is never shown to the buyer; without displayReference the page shows no order reference
publicMetadataOptionalUp to 10 key/value pairs you want the buyer to see (keys A-Z a-z 0-9 _ . -, up to 40 characters; values up to 200 printable characters)
customerOptionalBuyer email, name, and other details
metadataOptionalYour business context, e.g. a cart ID. Private to you: it comes back in webhooks, the dashboard and the API, not on the checkout page (apart from the display keys title, product_name, merchant_name, brand_name)
paymentMethodsOptionalControls which payment methods the checkout page shows, see below
deferTransferDepositOptionalREST API boolean, default false. Set true for hosted redirects to prepare the transfer address when checkout loads, instead of waiting during creation. The create response may omit transferDepositAddress. Fixed-customer address ownership checks always run during creation.
fixedTransferCustomerIdOptionalGive a verified returning customer a fixed transfer deposit address, see below
expiresAtOptionalOrder expiration time, ISO-8601 format. No server-side cap and no default: omit it and the order never expires on its own. For programmatic payers (see below) allow at least an hour

Optional request header: Idempotency-Key (1–255 printable ASCII characters, scoped to your merchant account). Retrying a create with the same key and the same body returns the original order with HTTP 200 and idempotentReplay: true instead of a second order; the same key with a different body is refused with HTTP 409 and code idempotency_key_reused. Without the header, a repeated orderId still returns the usual 409 conflict.

SDK example ​

ts
const paymentIntent = await tariapay.paymentIntents.create({
  orderId: "order_1001",
  amount: "12.50",
  currency: "USDC",
  acceptedCurrencies: ["USDC", "USDT"],
  successUrl: "https://merchant.example/success",
  cancelUrl: "https://merchant.example/cancel",
  customer: {
    email: "buyer@example.com",
    name: "Alice",
  },
  metadata: {
    cartId: "cart_12",
  },
});

return {
  paymentId: paymentIntent.paymentId,
  checkoutUrl: paymentIntent.checkoutUrl,
};

REST example ​

bash
curl -X POST "https://api.tariapay.com/v1/payment-intents" \
  -H "Authorization: Bearer tpk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "orderId": "order_1001",
    "amount": "12.50",
    "currency": "USDC",
    "acceptedCurrencies": ["USDC", "USDT"],
    "successUrl": "https://merchant.example/success",
    "cancelUrl": "https://merchant.example/cancel",
    "customer": {
      "email": "buyer@example.com",
      "name": "Alice"
    },
    "metadata": {
      "cartId": "cart_12"
    }
  }'

Controlling the checkout's payment methods ​

By default the checkout page offers both wallet payment and direct transfer (on supported networks), with the transfer section collapsed. You can adjust this per order with the optional paymentMethods field:

json
{
  "orderId": "order_1001",
  "amount": "12.50",
  "currency": "USDC",
  "paymentMethods": {
    "wallet": true,
    "transfer": true,
    "transferDefaultOpen": true
  }
}
FieldDefaultDescription
wallettrueShow the connect-wallet payment module
transfertrueOffer direct transfer; false also makes the server reject transfer requests for this order
transferDefaultOpenfalseRender the transfer section expanded; forced on when wallet is false

Rules and fail-safes:

  • wallet and transfer cannot both be false — that returns a 400.
  • Both switches are enforced server-side, not just hidden in the UI: the endpoint behind a disabled method returns 403.
  • Direct transfer remains subject to network support. If you set wallet: false but the network the order is currently on does not support transfer, the checkout automatically falls back to showing the wallet module — and accepts wallet payments on it — so the buyer always has a way to pay. Switching to a transfer-capable network hides the wallet module again.
  • Omitting the field keeps today's behavior exactly; existing integrations need no changes.

Fixed deposit address for returning customers ​

By default, every order gets a brand-new transfer deposit address that is never reused. For identity-verified returning customers, you can instead give each customer one fixed deposit address that stays the same across all of their orders — and across every supported network. Repeat buyers who pay from an exchange can save the address once (for example in their exchange's address book) and skip copying a new address every time.

Pass your own customer ID when creating the order:

ts
const paymentIntent = await tariapay.paymentIntents.create({
  orderId: "order_1001",
  amount: "12.50",
  currency: "USDC",
  fixedTransferCustomerId: "user_42", // your customer ID — verified accounts only
})

Over REST, set the reserved metadata key instead (this is exactly what the SDK field does):

json
{
  "orderId": "order_1001",
  "amount": "12.50",
  "currency": "USDC",
  "metadata": {
    "fixed_transfer_customer_id": "user_42"
  }
}

Rules and responsibilities:

  • Only pass customers whose identity you have verified — a signed-in account with a verified email, a wallet-signature login, or an equivalent check. The buyer will rely on this address staying theirs, so never derive it from unverified input such as a free-typed email. For guests and unverified users, simply omit the field — they keep getting fresh per-order addresses.
  • The address is scoped to your merchant account plus the customer ID you pass. Use a stable, opaque ID from your own system (a user ID, not an email that can change).
  • One open order at a time. While a customer has an unpaid order using the fixed address, creating another order with the same fixedTransferCustomerId returns 409 with code fixed_transfer_active_payment. Resume or cancel the customer's previous order instead of retrying — with a stored orderId you can look it up via retrieveByOrderId.
  • Only funds sent while the order is open count toward it. Each new order starts from a fresh balance checkpoint on the address; money that arrived before the order was created (or between orders) is not automatically applied to a later order. Tell buyers to transfer only after opening an order — if funds are sent outside one, contact support to recover them.
  • Wallet payments are unaffected: the checkout still offers both methods as usual, and payment results and webhook events are identical either way.
  • The fixed address only changes which deposit address transfer payments use. Everything else — confirmation, wrong-network recovery, webhooks, the transfer refund process — works the same as ordinary direct transfers (see Supported payment methods).

Pay programmatically from your own wallet (no checkout page) ​

Some payers are programs, not people: an API customer topping up a prepaid balance from a hot wallet, a treasury paying an invoice, an agent settling a bill. They should not have to open a checkout page. Every payment order already carries a direct-transfer deposit address, so the flow is: create the order, read the structured transfer instructions, send the exact ERC-20 amount from your own wallet, poll the transfer status until the deposit is detected, and fulfil from the webhook.

1. Create the order and read transferInstructions ​

Create the order as usual (do not set deferTransferDeposit). Give it a longer expiresAt — an hour or more: a transfer from an exchange or a hot wallet takes minutes to land, and the window must cover a retry. Send an Idempotency-Key so a timed-out request can be retried safely.

bash
curl -X POST "https://api.tariapay.com/v1/payment-intents" \
  -H "Authorization: Bearer tpk_..." \
  -H "Idempotency-Key: topup_7f3a" \
  -H "Content-Type: application/json" \
  -d '{ "orderId": "topup_7f3a", "amount": "25", "currency": "USDC",
        "acceptedCurrencies": ["USDC", "USDT"],
        "expiresAt": "2026-10-18T18:00:00.000Z" }'

Alongside paymentIntent, the response (and GET /v1/payment-intents/:paymentId) carries transferDepositAddress and transferInstructions:

json
{
  "transferDepositAddress": "0x1c73d3e5db612db118aa99c864f277a5743b152c",
  "transferInstructions": {
    "depositAddress": "0x1c73d3e5db612db118aa99c864f277a5743b152c",
    "expiresAt": "2026-10-18T18:00:00.000Z",
    "payable": true,
    "chains": [
      {
        "chainId": 8453,
        "chainName": "Base",
        "tokens": [
          { "symbol": "USDC", "currency": "USDC", "tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "decimals": 6, "amountMinor": "25000000", "amount": "25" },
          { "symbol": "USDT", "currency": "USDT", "tokenAddress": "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", "decimals": 6, "amountMinor": "25000000", "amount": "25" }
        ]
      },
      {
        "chainId": 56,
        "chainName": "BNB Smart Chain",
        "tokens": [
          { "symbol": "USDC", "currency": "USDC", "tokenAddress": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", "decimals": 18, "amountMinor": "25000000000000000000", "amount": "25" },
          { "symbol": "USDT", "currency": "USDT", "tokenAddress": "0x55d398326f99059ff775485246999027b3197955", "decimals": 18, "amountMinor": "25000000000000000000", "amount": "25" }
        ]
      }
    ],
    "rules": {
      "exactAmount": true,
      "singleTransfer": true,
      "topUpAllowed": true,
      "addressSingleUse": true,
      "sameAddressOnEveryChain": true,
      "nativeTokenNotAccepted": true,
      "payerCoversNetworkFees": true,
      "payBefore": "2026-10-18T18:00:00.000Z"
    }
  }
}

Pass these to your payer verbatim and have them follow the rules:

  • Pick one chain and one token from the list and send exactly amountMinor (an integer in that token's own decimals — the same coin has 6 decimals on Base and 18 on BNB Smart Chain, so never reuse a minor amount across chains). amount is the same value as a decimal string for logs and UIs.
  • One transfer, from any wallet. Exchange withdrawals work; the payer, not the order, covers the withdrawal fee, so a payer whose exchange deducts the fee from the amount must add it back. A short transfer can be completed with a second transfer of the missing amount to the same address (topUpAllowed).
  • The address is the same on every listed chain (sameAddressOnEveryChain); a transfer sent on a listed chain other than the one you expected is still detected and settled. Only the listed ERC-20 tokens count — never the chain's native coin.
  • addressSingleUse: true means the address belongs to this one order; never pay a second order into it. It is false only for a fixed customer address.
  • Send before payBefore (= expiresAt). A deposit that lands later is still detected and settled by the background sweeps; it just shows up as fundedAfterExpiry in the status below.

transferInstructions is only present when the order holds a deposit address. It is omitted when you created the order with deferTransferDeposit: true and the checkout page has not run yet.

2. Send the transfer ​

Any ERC-20 transfer(depositAddress, amountMinor) works. With viem:

ts
import { createWalletClient, http, erc20Abi, type Address } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { base } from "viem/chains"

const chain = instructions.chains.find((c) => c.chainId === base.id)!
const usdc = chain.tokens.find((t) => t.symbol === "USDC")!

const wallet = createWalletClient({
  account: privateKeyToAccount(process.env.HOT_WALLET_KEY as `0x${string}`),
  chain: base,
  transport: http(process.env.BASE_RPC_URL),
})
const txHash = await wallet.writeContract({
  address: usdc.tokenAddress as Address,
  abi: erc20Abi,
  functionName: "transfer",
  args: [instructions.depositAddress as Address, BigInt(usdc.amountMinor)],
})

With ethers:

ts
import { Contract, JsonRpcProvider, Wallet } from "ethers"

const wallet = new Wallet(process.env.HOT_WALLET_KEY!, new JsonRpcProvider(process.env.BASE_RPC_URL))
const erc20 = new Contract(usdc.tokenAddress, ["function transfer(address to, uint256 value) returns (bool)"], wallet)
const receipt = await (await erc20.transfer(instructions.depositAddress, BigInt(usdc.amountMinor))).wait()

3. Poll GET /v1/payment-intents/:paymentId/transfer-status ​

The checkout page normally drives deposit detection by polling. With no page open, your poll does the same job: every call reads the deposit address live and, the moment the full amount is there, marks the payment received (the order moves to processing and the payment_intent.processing webhook fires) and starts settlement. Poll every 10–15 seconds while the payer is sending; funds typically take a few minutes to arrive from an exchange.

bash
curl "https://api.tariapay.com/v1/payment-intents/<paymentId>/transfer-status" \
  -H "Authorization: Bearer tpk_..."
json
{
  "paymentIntentStatus": "requires_payment",
  "state": "underpaid",
  "transfer": {
    "chainId": 8453,
    "depositAddress": "0x1c73d3e5db612db118aa99c864f277a5743b152c",
    "funded": false,
    "orderProcessed": false,
    "settlementTxHash": null,
    "settlementChainId": null,
    "tokens": [
      { "currency": "USDC", "tokenAddress": "0x8335…2913", "decimals": 6, "amountMinor": "25000000", "balanceMinor": "0" },
      { "currency": "USDT", "tokenAddress": "0xfde4…9bb2", "decimals": 6, "amountMinor": "25000000", "balanceMinor": "24000000" }
    ],
    "residueSweeps": []
  },
  "received": { "chainId": 8453, "currency": "USDT", "tokenAddress": "0xfde4…9bb2", "decimals": 6, "amountMinor": "24000000", "source": "balance", "at": null },
  "confirmations": { "txHash": null, "chainStatus": null, "paidAt": null, "confirmedAt": null },
  "anomalies": {
    "underpaid": true,
    "shortfall": { "chainId": 8453, "currency": "USDT", "tokenAddress": "0xfde4…9bb2", "decimals": 6, "amountMinor": "1000000" },
    "overpaid": false,
    "excess": null,
    "wrongToken": false,
    "wrongTokenDeposits": [],
    "wrongChain": null,
    "wrongChainDeposits": [],
    "recoveredOnChainId": null,
    "fundedAfterExpiry": false,
    "degraded": false
  },
  "observedAt": "2026-10-18T11:00:00.000Z",
  "paymentIntent": { "id": "…", "status": "requires_payment", "…": "…" }
}
FieldMeaning
stateawaiting_transfer → underpaid (something arrived, less than the amount) → funded (full amount seen, being verified) → processing (payment received, settling into your balance) → confirmed. Also expired, failed, refunded.
receivedThe deposit that pays this order: a live balance while it is still arriving (source: "balance"), then the recorded receipt (deposit_received) or settlement (settlement) with its timestamp.
transfer.tokens[].balanceMinorWhat sits at the address right now, per accepted token on the pinned chain. balanceUnavailable: true marks a failed chain read.
confirmationsThe settlement transaction and its chain confirmations once the order is processing/confirmed.
anomalies.underpaid / shortfallLess than the amount arrived; shortfall is what is missing on the token the payer used. Tell the payer to send the difference to the same address.
anomalies.overpaid / excessMore than the amount arrived (before settlement: the live balance over the amount; after it: the surplus swept off the address). It is not refunded automatically — contact support with the payer's return address.
anomalies.wrongToken / wrongTokenDepositsA stablecoin this order does not accept sits at the address on the pinned chain (for example EURC for a USD order).
anomalies.wrongChain / wrongChainDeposits / recoveredOnChainIdThe deposit landed on another enabled chain. wrongChain is null until you ask for a cross-chain read with ?scanAllChains=true (one extra bundled read per chain — use it when the payer says "I sent it" and nothing shows, not on every poll) or a settlement proves it; recoveredOnChainId names the chain the payment was settled on when that differs from the pinned chain.
anomalies.fundedAfterExpiryThe deposit arrived or settled after expiresAt. It is still honored.
anomalies.degradedA chain read failed on this call, so a zero balance may be stale. Poll again.

The endpoint answers only orders that hold a deposit address (otherwise HTTP 409, code transfer_deposit_not_allocated) and only your own orders. These flags are reporting only: they never move, block or refund funds. The existing GET …/transfer-deposit remains available as the strictly read-only variant (it never triggers detection).

4. Fulfil from the webhook ​

Treat the poll as progress reporting. Fulfil when payment_intent.confirmed arrives (or when GET /v1/payment-intents/:paymentId returns confirmed); payment_intent.processing tells you the payer has paid and the settlement is in flight. Payload shape and event types are identical to wallet payments — see Receive payment results.

Security conventions ​

The checkout page is reachable by anyone who holds its link, so treat what identifies a payment as a secret and keep your own authorization on your side:

  • paymentId and checkoutUrl are secrets. Holding one opens the checkout page and its payment state. Send them only to the buyer who pays; keep them out of logs, analytics events and third-party URLs.
  • The return redirect belongs to the buyer's browser. Only a browser that opened the checkout page before the payment is sent on to your successUrl / cancelUrl; anyone who opens the checkout link after the payment, including the buyer on a new device or browser, sees the paid status with no return link. Don't make fulfillment depend on the redirect happening.
  • Don't authorize order or success pages by order number alone. Bind them to the buyer's login session or a one-time token you issue. The payment_id Taria Pay appends to your successUrl identifies the payment; it does not prove the visitor is the buyer.
  • Decide "paid" from HMAC-verified webhooks (or the authenticated API: GET /v1/payment-intents/:paymentId, GET /v1/payment-status?orderId=). Never from the success redirect, and never by polling the public status endpoint with a transaction hash.
  • Keep personal data out of metadata. What the buyer should see goes into displayReference / publicMetadata.
  • Avoid purely sequential order ids as the only thing standing between a stranger and an order. Order ids are yours to look up with your API key, not an access credential.

Important rules ​

  • A single payment order supports both wallet payments and direct transfers (the checkout page shows an order-specific deposit address); you do not need to pass any payment-method fields. Both methods produce identical payment results and webhook events. To adjust the presentation, see paymentMethods above; for background see Supported payment methods.
  • orderId must be unique within your merchant account.
  • Creating the same orderId again usually returns a conflict error. If create times out, call retrieveByOrderId instead of minting a new id — or send an Idempotency-Key and retry the same request.
  • checkoutUrl looks like https://checkout.tariapay.com/pi_<uuid>. Always use the value returned by the API as-is — do not construct checkout URLs yourself. The paymentId itself is a plain UUID with no prefix.
  • Merchants do not need to submit an on-chain settlement address.
  • You usually do not need to pass chainId when creating a payment order; the checkout page shows the payment networks currently available.
  • Never treat successUrl as the final payment result.

Next steps ​