Payment Intents
Collect a fiat amount in BTC or USDT. Your server creates the intent, and the payer, a person or an AI agent, pays it with only the intent ID.
Overview
A payment intent is a fiat amount you want collected, such as 10.00 USD. Your server creates it with your API key. The payer pays it through the public Payer API using only the intent ID: they pick a crypto, get an address and an exact amount, and send the funds. Blockonomics tracks the payments and calls your callback URL as they move the intent to processing, underpaid and paid.
Your server never converts prices, generates addresses or watches the blockchain. The payer can split the amount across several payments, even in different cryptos.
| Who | Authenticates with | Does |
|---|---|---|
| Your server | API key | Creates intents, receives callbacks, checks the status before fulfilling |
| The payer: your checkout page, the checkout widget or an AI agent | The intent ID | Checks what is due, gets a quote, pays, and submits USDT transaction hashes |
How It Works
- Your server creates a payment intent for the order total.
- You give the intent
idto the payer. - The payer calls Get payment status to see
due_amountandaccepted_cryptos. - The payer calls Create a payment quote with a crypto, and gets an
address, acrypto_amountand a 5-minute price lock. - The payer sends the funds. For USDT, they also submit the transaction hash.
- Blockonomics calls your callback URL as the status moves through
processingtopaid. Your server fetches the intent and fulfils the order onpaid.
Setup
You need a store with a callback URL and a wallet for each crypto you accept. Follow the setup steps in Receiving Payments.
The payer can only pay in the cryptos the store has a wallet for. These are listed in accepted_cryptos.
Create a Payment Intent
Call Create a payment intent from your server when the customer checks out:
curl -X POST https://www.blockonomics.co/api/v2/payment_intents \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": "10.00", "currency": "USD", "extra_data": "order-1234"}'{
"data": {
"id": "pi_5GfD5m1b8khzFSLza91VR6",
"store_id": 1243,
"amount": "10.00",
"currency": "USD",
"status": "unpaid",
"due_amount": "10.00",
"extra_data": "order-1234",
"created_at": 1793615164,
"payments": []
}
}extra_datais your own reference, up to 2048 characters. Use it to link the intent to your order. The payer never sees it.match_callbackpicks the store when your account has more than one. Pass part of that store's callback URL, such as"example.com".- Amounts are decimal strings with 2 decimals, in requests and responses.
- Intents can't be changed and don't expire. Only quotes expire. If the total changes, create a new intent.
Store the intent id with your order.
Let the Customer Pay
The intent id is all the payer needs. Anyone who has it can see the amount and pay it, so share it only with the payer.
On Your Website: the Checkout Widget
The checkout widget runs the whole payer flow: crypto selection, quote, QR code, USDT wallet connection, and a receipt with a Pay remaining button after an underpayment.
<script src="https://www.blockonomics.co/js/checkout.js"></script>
<div id="payment_area"></div>
<script>
Blockonomics.checkout({
msg_area: 'payment_area',
intent_id: 'pi_5GfD5m1b8khzFSLza91VR6',
})
</script>| Option | Required | Description |
|---|---|---|
msg_area | Yes | id of the element to render into |
intent_id | Yes | The payment intent ID |
onPaymentDetected | No | Called once a payment covering the full amount is seen on the network, before it confirms. Receives { txid, value, expected, crypto, intentId }. Don't fulfil the order here: wait for the paid callback. |
Style it with CSS custom properties on the blockonomics-checkout element, such as --bk-bg, --bk-fg, --bk-accent, --bk-font and --bk-radius.
With an AI Agent or Your Own UI: the Payer API
The Payer API needs no API key and allows cross-origin requests, so it works from a browser, a mobile app or an AI agent.
1. Check what is due.
curl https://www.blockonomics.co/api/v2/pay_stat/pi_5GfD5m1b8khzFSLza91VR6{
"data": {
"id": "pi_5GfD5m1b8khzFSLza91VR6",
"amount": "10.00",
"currency": "USD",
"status": "unpaid",
"due_amount": "10.00",
"created_at": 1793615164,
"accepted_cryptos": ["BTC", "USDT"],
"testmode": false
}
}2. Get a quote in one of accepted_cryptos.
curl -X POST https://www.blockonomics.co/api/v2/pay/pi_5GfD5m1b8khzFSLza91VR6 \
-H "Content-Type: application/json" \
-d '{"crypto": "BTC"}'{
"data": {
"id": "pq_8GgAyuReWzLbJtEQrTNaT5",
"amount": "10.00",
"crypto_amount": "12966",
"paid_crypto_amount": "0",
"crypto": "BTC",
"address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "pending",
"txid": null,
"expires_timestamp": 1793615475,
"paid_timestamp": null
}
}crypto_amount is in base units, as a string:
| Crypto | Base unit | Example |
|---|---|---|
| BTC | satoshi, 8 decimals | "12966" = 0.00012966 BTC |
| USDT | 6 decimals | "10013511" = 10.013511 USDT |
The price is locked until expires_timestamp, 5 minutes after the quote is created. If it passes before the payer sends, call the same endpoint again. For the same crypto and the same due_amount, you get the same quote and address back with a new price and a new 5-minute lock. This also makes it safe to call on every page load.
3. Send the payment.
- BTC: send exactly
crypto_amountsatoshis toaddress, for example from the payment URIbitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh?amount=0.00012966. Each BTC quote has its own address, so the payment is detected automatically. - USDT: send an ERC-20 transfer of
crypto_amountunits of USDT (contract0xdAC17F958D2ee523a2206206994597C13D831ec7on Ethereum) toaddress. The payer also needs ETH for gas. Then submit the transaction hash, as below.
4. USDT only: submit the transaction hash.
All USDT quotes on a store share the store's wallet address, so the transaction hash is the only way to tell which intent a transfer pays. A USDT payment whose hash is never submitted is not detected.
curl -X POST https://www.blockonomics.co/api/v2/pay/pi_5GfD5m1b8khzFSLza91VR6/submit \
-H "Content-Type: application/json" \
-d '{"crypto": "USDT", "txhash": "0x9b1c7f3e2a4d5b6c8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"}'The response is 200 with an empty body. Submit the hash as soon as you have it. If your wallet can sign without broadcasting, submit the hash first and broadcast after. Submitting the same hash again returns 200, so retrying is safe.
5. Poll until the payment is detected. Call Get payment status, at most every 10 seconds:
due_amountabove0.00: something is still owed. Get a quote for it (step 2).due_amountis0.00andstatusisprocessing: the payment is detected and waiting for confirmations. The payer is done.statusispaid: the payment is confirmed.
Telling an Agent How to Pay
An agent that hits your checkout or a paid endpoint needs the intent ID and where to find these docs. For example, your API can answer with 402 Payment Required:
{
"error": "payment_required",
"payment_intent_id": "pi_5GfD5m1b8khzFSLza91VR6",
"amount": "10.00",
"currency": "USD",
"pay_status_url": "https://www.blockonomics.co/api/v2/pay_stat/pi_5GfD5m1b8khzFSLza91VR6",
"docs": "https://developers.blockonomics.co/llms.txt"
}The response format is yours to choose. Blockonomics only needs the payer to call the Payer API with the intent ID.
Statuses
Payment Intent
| Status | Meaning | Payer | Your server |
|---|---|---|---|
unpaid | No payment detected | Pay due_amount | Wait |
processing | A payment is detected but not confirmed | Pay due_amount if it is above 0.00, otherwise wait | Wait |
underpaid | Confirmed payments cover part of amount, nothing else is in progress | Pay due_amount | Wait for a top-up, or contact the customer |
paid | Confirmed payments cover amount | Nothing | Fulfil the order |
due_amount counts a payment as soon as it is detected. status only counts confirmed payments. So due_amount can be 0.00 while status is still processing. Fulfil on status, never on due_amount.
Payment Quote
| Status | Meaning |
|---|---|
pending | Waiting for payment |
unconfirmed | Payment detected, no confirmations yet |
partially_confirmed | Payment has confirmations but is not final |
confirmed | Payment is final and credited to the intent |
failed | The USDT transaction reverted on-chain and is not credited. See Failed payments. |
A quote is valid only until expires_timestamp, even though its status still reads pending. Create a quote again to renew it.
Partial Payments and Top-ups
A payer can pay in several parts, in the same crypto or different ones. Each payment is credited at the price of the quote it was sent to.
- The payer sends 5 USDT on a 10.00 USD intent. Once the payment is detected,
statusisprocessinganddue_amountis5.01. - The payer calls Create a payment quote again, in any crypto. They get a new quote for
5.01. They don't need to wait for the first payment to confirm. - When both payments confirm,
statusispaid.
If the first payment confirms before a top-up arrives, status becomes underpaid and your server gets a callback. The intent stays open, and the payer can top it up at any time.
If a top-up is requested before Blockonomics knows how much the first payment was, the request fails with 409 and code 1079. Retry after a few seconds.
Overpayments are credited in full. due_amount stops at 0.00, status is paid, and nothing is refunded automatically. Compare paid_crypto_amount with crypto_amount in payments to find the excess.
To work out the fiat value of a payment, use amount × paid_crypto_amount / crypto_amount from its entry in payments.
Failed Payments
A USDT transaction can be detected and then revert when it is mined, for example when the sender doesn't hold enough USDT. Its quote becomes failed, its credit comes off due_amount, and the intent goes back to unpaid or underpaid. No callback is sent for a failed payment. The change shows in Get a payment intent and Get payment status, and the payer can pay again with a new quote.
Callbacks
Blockonomics sends an HTTP GET to your store's callback URL each time a payment moves an intent to processing, underpaid or paid. A payment event that leaves the status unchanged, such as a first confirmation, sends no callback, and neither does a failed payment.
GET https://example.com/blockonomics/callback?secret=YOUR_SECRET&id=pi_5GfD5m1b8khzFSLza91VR6&status=paid&amount=10.00¤cy=USD&due_amount=0.00&extra_data=b3JkZXItMTIzNA%3D%3D
| Parameter | Description |
|---|---|
id | Payment intent ID |
status | New status: processing, underpaid or paid |
amount | Total amount, in currency |
currency | Fiat currency code |
due_amount | Amount still to pay, counting payments not confirmed yet |
extra_data | Your extra_data, base64-encoded. Empty if the intent has none. |
Payments to a payment intent send only this callback, never the per-address callback (addr, value, txid) described in Callbacks. If your store also uses the per-address flow, the id parameter tells the two apart.
Handling the Callback
- Check the secret in your callback URL.
- Fetch the intent with Get a payment intent. Callbacks are not signed, so read the status from the API, not from the query string.
- If
statusispaid, fulfil the order. Make this idempotent: the same status can arrive more than once. - Return
200.
// Express.js example
app.get('/blockonomics/callback', async (req, res) => {
if (req.query.secret !== process.env.BLOCKONOMICS_CALLBACK_SECRET) {
return res.status(403).send('Forbidden')
}
const response = await fetch(
`https://www.blockonomics.co/api/v2/payment_intents/${encodeURIComponent(req.query.id)}`,
{ headers: { Authorization: `Bearer ${process.env.BLOCKONOMICS_API_KEY}` } }
)
const { data: intent } = await response.json()
if (intent.status === 'paid') {
await fulfilOrder(intent.extra_data, intent) // no-op if already fulfilled
}
res.send('OK')
})A callback that doesn't get a 200 is retried up to 7 times with exponential backoff. After the last failure, Blockonomics emails you. Every attempt is listed at Log/Test Bench → Callback Logs.
Testing
Turn on Testmode for the store, as described in Testing. Quotes then get test addresses (1TestBTCAddress…, 0xTestUSDTAddress…), testmode is true in Get payment status, and callbacks fire as they would for real payments.
Do not send real BTC or USDT to test addresses — funds sent will be lost.
BTC: create a BTC quote, then go to Log/Test Bench. Under Test Bitcoin Wallet, send to the quote's address. Send less than crypto_amount to test an underpayment. The payment is detected at once and confirmed after about 10 minutes.
USDT: create a USDT quote, then submit the hash TestUSDTTxid_<base units>_<unique suffix>. It simulates a payment of that many base units, detected at once and confirmed after about 1 minute. Use a new suffix every time: like a real hash, a test hash can only be used once.
# Pays 5 USDT, an underpayment on a 10.00 USD intent
curl -X POST https://www.blockonomics.co/api/v2/pay/pi_5GfD5m1b8khzFSLza91VR6/submit \
-H "Content-Type: application/json" \
-d '{"crypto": "USDT", "txhash": "TestUSDTTxid_5000000_order1234a"}'The checkout widget's test wallet does this for you.
Errors
Errors use the standard format:
{ "error": { "code": 1010, "message": "Invalid API parameter. crypto is required" } }| HTTP | Code | When |
|---|---|---|
400 | 1010 | A parameter is missing or invalid. The message says which. |
400 | 1055 | The request body is missing or is not valid JSON |
400 | 1049 | No store matches match_callback |
400 | 1032 | Your account has several stores and match_callback was left out |
400 | 1050 | The USDT transaction hash is not valid |
400 | 1038 | The transaction hash was already used for another payment |
401 | Missing or invalid API key. The body is empty. | |
404 | The intent doesn't exist or isn't yours, or there is no open quote to submit a hash to. The body may not be JSON, so check the status code. | |
409 | 1078 | The intent is already paid |
409 | 1079 | A payment already covers the intent, or is still being processed. Retry after a few seconds if you are topping up. |
409 | 1041 | The store has no wallet for this crypto |
422 | 1029 | Unsupported crypto |
422 | 1069 | Unsupported fiat currency |