Payments
Accept one-time stablecoin payments. Complete the Prerequisites (API key and signing keys) first.
Payment modes
The Checkout API supports two payment modes, set per checkout:
| Mode | Description |
|---|---|
| Direct (default) | Funds are sent directly to your settlement address when the customer’s payment is detected. |
| Two-step | Funds are held in escrow until you decide to capture (release to you) or refund (back to payer). |
Pick direct when you want funds immediately on payment. Pick two-step when you need to verify or fulfill an order before taking the funds, and refund cleanly if you can’t.
Signing key required: Direct checkout creation and two-step capture/refund all require a signature from your signing key. See Signed Requests for how it works, and Prerequisites to generate your keys.
Direct payment
Funds settle to your settlement address as soon as the customer pays.
Creating a direct payment: two things to know
- The signature goes in the request body, not in the
X-Signatureheader that every other signed action uses (capture, refund, subscriptions). Don’t send anX-Signatureheader when creating a direct payment — you’ll see this in the example below. - You need your PaymentFactory address. Fetch it once from
GET /settingsor your dashboard, then store it with your config. Treat it as a constant — it only changes if something about your account or signing setup changes, which you’d coordinate with Exodus.
import { signDirectPaymentMultiChain } from '@exodus/checkout-signer'
// Your PaymentFactory address — read once from GET /settings (chain_configs[].factory_address)
const factoryAddress = '0xfaceb00cfaceb00cfaceb00cfaceb00cfaceb00c'
// Signs for every supported chain family (EVM + Solana); Solana needs no factory.
const sigs = signDirectPaymentMultiChain(process.env.SIGNING_MNEMONIC, {
factoryAddresses: { evm: factoryAddress },
})
const response = await fetch('https://checkout-api.exodus-int.com/checkouts', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 4999, // $49.99 USD
currency: 'USD',
payment_method: 'direct',
description: 'Wireless headphones',
// One signature per chain family the customer can pay on.
signatures: {
evm: { on_chain_id: sigs.evm.onChainId, signature: sigs.evm.signature },
solana: { on_chain_id: sigs.solana.onChainId, signature: sigs.solana.signature },
},
success_url: 'https://yoursite.com/success',
cancel_url: 'https://yoursite.com/cancel',
}),
})
const checkout = await response.json()
// The hosted page lets the customer pay on EVM or Solana
console.log(checkout.checkout_url)A direct payment only offers the chains your business is set up for. Your signing mnemonic
derives a signer for every chain family, and each chain also needs a settlement address — both
arranged with us during onboarding (we’ll help you set it up). A signatures entry for a chain you
aren’t set up for is silently skipped, so make sure EVM and Solana are both configured if you want
to offer both.
Two-step payment
A two-step payment holds the customer’s deposit in an escrow contract until you act on it. Creation needs no signature — the API generates the on-chain identifier and deposit address for you. You sign later, when you capture or refund.
1. Create the checkout
const response = await fetch('https://checkout-api.exodus-int.com/checkouts', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 2500, // $25.00 USD
currency: 'USD',
payment_method: 'two_step',
description: 'Pre-authorized order',
success_url: 'https://yoursite.com/success',
cancel_url: 'https://yoursite.com/cancel',
}),
})
const checkout = await response.json()
// Send the customer to checkout.checkout_url to pay
console.log(checkout.checkout_url)Once the customer deposits and the funds are escrowed, you receive a payment.escrow_confirmed webhook carrying the id, on_chain_id, deposit_address, detected_chain, and payer_addresses you need to capture or refund.
Two values, don’t confuse them: the payment id (an opaque cuid2 string, no prefix) goes in
the capture/refund URL path, while on_chain_id (a 0x-prefixed 32-byte hex) is what you pass to
signCapture / signRefund as paymentId. Both arrive on the payment.escrow_confirmed webhook.
2a. Capture the funds
Capturing releases the escrowed funds to your settlement address. Sign the capture, then POST it with the signature in the X-Signature header. The body carries no parameters (every value is bound into the signature), so send an empty object ({}) with Content-Type: application/json.
import { signCapture } from '@exodus/checkout-signer'
// All four come from the `payment.escrow_confirmed` webhook (event.data.object):
const paymentId = 'tz4a98xxat96iws9zmbrgj3a' // payment `id` (cuid2, no prefix) — used in the URL path
const onChainId = '0xabc123...' // `on_chain_id` — what signCapture signs over
const depositAddress = '0x5fbd...'
const chain = 'eip155:1' // `detected_chain` (CAIP-2)
const signature = signCapture({
paymentId: onChainId, // the SDK's `paymentId` field takes the on-chain id
depositAddress,
chain,
mnemonic: process.env.SIGNING_MNEMONIC, // or a per-chain privateKey
})
const response = await fetch(`https://checkout-api.exodus-int.com/payments/${paymentId}/capture`, {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
'X-Signature': signature,
},
body: JSON.stringify({}),
})
const result = await response.json()
console.log(result.status) // 'settled'
console.log(result.tx_hash) // on-chain transfer to your settlement addressCapture is idempotent: replaying a captured payment returns the existing result instead of submitting a second transaction.
2b. Or refund the customer
Refunding returns the escrowed funds to the customer. The refund signature binds the destination and a deadline; submit the same deadline in the request body.
import { signRefund } from '@exodus/checkout-signer'
// These come from the `payment.escrow_confirmed` webhook (event.data.object):
const paymentId = 'tz4a98xxat96iws9zmbrgj3a' // payment `id` (cuid2, no prefix) — used in the URL path
const onChainId = '0xabc123...' // `on_chain_id` — what signRefund signs over
const depositAddress = '0x5fbd...'
const chain = 'eip155:1' // `detected_chain` (CAIP-2)
const destination = '0xPayerAddress...' // payer_addresses[0] — refund the address that paid
const deadline = Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
const signature = signRefund({
paymentId: onChainId, // the SDK's `paymentId` field takes the on-chain id
depositAddress,
destination,
chain,
deadline,
mnemonic: process.env.SIGNING_MNEMONIC, // or a per-chain privateKey
})
const response = await fetch(`https://checkout-api.exodus-int.com/payments/${paymentId}/refund`, {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
'X-Signature': signature,
},
body: JSON.stringify({ destination, deadline }),
})
const result = await response.json()
console.log(result.status) // 'refunded'
console.log(result.tx_hash)destination is the address the refund is sent to. Most of the time that’s the payer’s address
(payer_addresses[0]), but if the customer paid from an exchange, double-check with them that they
can receive funds at that address before refunding.
The deadline (unix seconds) is enforced on-chain and must be in the future.
Handle webhooks
Fund movement is asynchronous, so drive fulfillment from webhooks rather than the create response. Verify every event against your webhook secret (see Webhooks), then switch on type:
import express from 'express'
import crypto from 'crypto'
const app = express()
// express.raw() so we verify the signature over the unparsed body
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex')
// Constant-time compare; normalize the header to a string first
const received = Buffer.from(String(req.headers['x-signature'] ?? ''))
const expectedBuffer = Buffer.from(expected)
if (received.length !== expectedBuffer.length || !crypto.timingSafeEqual(received, expectedBuffer)) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(req.body)
const payment = event.data.object
switch (event.type) {
case 'payment.escrow_confirmed':
// Two-step: the deposit is escrowed and ready. Capture or refund it.
console.log('Ready to capture/refund:', payment.id)
break
case 'payment.settled':
// Funds reached your settlement address (direct, or after a capture).
console.log('Settled:', payment.id)
break
case 'payment.refunded':
console.log('Refunded:', payment.id)
break
case 'payment.failed':
console.log('Failed:', payment.id, payment.failure_reason)
break
}
res.status(200).send('OK')
})
app.listen(3000)The full event list, payload shape, and screening events (payment.flagged) are in the Webhooks reference.
Next steps
- Subscriptions Quickstart
- Checkouts API reference
- Payments API reference for captures, refunds, and rescue
- Signed Requests
