Custom Payment Flow
Build a custom payment UI and state machine with PaymentIntents
Custom Payment Flow uses PaymentIntents to give your backend control over the state and next action of each payment.
If Hosted Checkout meets your requirements, we recommend Hosted Checkout because it requires less implementation and maintenance.
In the current production release, onchain deposits do not automatically advance a PaymentIntent to succeeded. Use Hosted Checkout for live and testnet payments, and use simulate for end-to-end PaymentIntent tests. See Compatibility and known limitations.
Create a client
import { PaymentClient } from "@tradom/payment";
const TRADOM_PAYMENT_SECRET_KEY =
process.env.TRADOM_PAYMENT_SECRET_KEY;
if (!TRADOM_PAYMENT_SECRET_KEY) {
throw new Error("TRADOM_PAYMENT_SECRET_KEY is required");
}
const tradomPayment = new PaymentClient({
apiKey: TRADOM_PAYMENT_SECRET_KEY,
});1. Create
Create a PaymentIntent using the share token of a payment link owned by the same merchant.
const paymentIntent = await tradomPayment.paymentIntents.create({
paymentLinkToken: "<share_token>",
mode: "simulate",
metadata: { orderId: "order_42" },
idempotencyKey: "order-42",
});An idempotencyKey must be unique within the merchant account. Retrying the same key returns the existing PaymentIntent. A new PaymentIntent starts in requires_payment_method.
Amounts use the fiat currency's major unit. JPY uses yen and USD uses dollars; do not multiply USD amounts by 100.
2. Preview a quote (optional)
const quote = await tradomPayment.paymentIntents.cryptoQuote(
paymentIntent.id,
{ stablecoin: "usdc", network: "ethereum" },
);The quote ID cq_… is for display purposes. The current confirm API does not accept a quote ID and recalculates the amount for the selected rail.
3. Confirm
const confirmed = await tradomPayment.paymentIntents.confirm(
paymentIntent.id,
{ stablecoin: "usdc", network: "ethereum" },
);
const deposit = confirmed.nextAction?.cryptoDeposit;After confirmation, the PaymentIntent moves to requires_action. The response includes deposit instructions such as depositAddress, amount, eip681Uri, and expiresAt. Instructions expire after 15 minutes. Call confirm again after expiry to create a new deposit session.
4. Retrieve or cancel
const latest = await tradomPayment.paymentIntents.retrieve(paymentIntent.id);
if (latest.status === "requires_action") {
await tradomPayment.paymentIntents.cancel(latest.id);
}Only incomplete PaymentIntents can be canceled. A succeeded or already canceled PaymentIntent cannot be canceled again.
About hosted sessions
The REST endpoint POST /api/v1/checkout_sessions creates a client_secret without an API key. Subsequent REST requests accept that value through X-Client-Secret or a JSON client_secret field.
SDK 0.5.0 can create a session and make client-secret-scoped calls from a keyless client.
const checkoutClient = new PaymentClient({});
const session = await checkoutClient.paymentIntents.createCheckoutSession({
paymentLinkToken: "<share_token>",
mode: "simulate",
});
const access = { clientSecret: session.clientSecret! };
const quote = await checkoutClient.paymentIntents.cryptoQuote(
session.id,
{ stablecoin: "usdc", network: "ethereum" },
access,
);
const confirmed = await checkoutClient.paymentIntents.confirm(
session.id,
{ stablecoin: "usdc", network: "ethereum" },
access,
);
const latest =
await checkoutClient.paymentIntents.retrieve(session.id, access);When the options include clientSecret, the SDK sends X-Client-Secret instead of a Bearer key. cancel, refund, and simulateDeposit still require the merchant secret key (simulateDeposit only works on a simulate intent created with sk_test_…).
The SDK is server-only. Never send a secret key to the browser; keep this logic in your backend or BFF. Do not include a client secret in a URL query string.