Integration guide
Headless checkout
When to go headless
Headless is more work and it is not more capable. What it buys you is that the customer never leaves your checkout, and that the payment screen is yours to design, translate and instrument.
| Hosted | Headless | |
|---|---|---|
| Payment screen | We serve it | You build it |
| Card payments | Included | Crypto only. Send a customer who picks card to the hosted page |
| Calls to make | One, plus the webhook | Three, plus the webhook |
| You must render | Nothing | Coin picker, address, QR, amount, countdown |
| Rate handling | Ours | Yours to display and refresh |
| Settlement, fees, statuses | Identical | Identical |
Headless covers the crypto rails, and only those. There is no endpoint that takes a card number and no widget to embed: card lives on the hosted page because the customer has to fund a wallet and clear an identity check first. The payment you created works either way, so handle the coins yourself and send a customer who picks “card” to the url from the create call — the webhook lands as normal. What that journey involves.
The flow
Four calls, three of them server-side. Your key never reaches the browser: the front end talks to your backend, and your backend talks to us.
checkout confirmed
│
├─ POST /api/v1/payment → reference_id
│ store it against the order
│
├─ GET /api/v1/blockchain-currency/reference/:reference_id
│ → the coins and chains this payment can take
│ render your picker from this, not from a hard-coded list
│
├─ customer picks a coin
│
├─ POST /api/v1/deposit-address/reference/:reference_id
│ { "blockchain_code": "BASE" } → Address
│ render address + QR + amount + countdown
│
└─ wait
webhook → status FILLED → release the order
(a "check now" button can GET the payment instead)1. Create a payment
Identical to hosted, and it still returns a url — you simply do not have to use it. Full field reference is on the hosted guide.
curl -X POST https://pay.peptiport.com/api/v1/payment \
-H "API-Key: $PEPTIPORT_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"customerEmail": "buyer@example.com",
"customerID": "cus_8814",
"amountInUSD": 240.00,
"invoiceID": "ORD-10432"
}'{
"host": "https://pay.peptiport.com",
"reference_id": "c80f5363-0397-4761-aa1a-3155c3a21470",
"url": "https://pay.peptiport.com/payments?reference_id=c80f5363-0397-4761-aa1a-3155c3a21470&host=https://pay.peptiport.com"
}Store reference_id against the order now. Every call below is addressed by it, and so is every webhook.
2. Deposit options
What this payment can actually be paid in. Ask per payment rather than hard-coding a list: which chains are enabled is a setting on your brand, and a picker built from a constant will offer a coin we cannot take the week someone changes it.
curl https://pay.peptiport.com/api/v1/blockchain-currency/reference/c80f5363-0397-4761-aa1a-3155c3a21470 \
-H "API-Key: $PEPTIPORT_PAY_KEY"[
{
"id": 7,
"blockchainCode": "BASE",
"network": "Base",
"currencyCode": "USDC",
"currency": "USDC",
"customerAddress": "",
"tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"standard": "ERC20",
"walletPrecision": 6,
"family": "ETH_Family",
"recommended": true,
"mostUsed": true,
"blockchainID": 4,
"currencyID": 2
}
]| Field | Use it for |
|---|---|
| blockchainCode | The value you send back when you assign an address. BTC, ETH, TRX, BASE, POLYGON. |
| network | The chain's display name. Show this, not the code. |
| currencyCode / currency | The coin. Show alongside the network — USDC on Base is not USDC on Polygon. |
| walletPrecision | Decimal places for this asset. Format the amount with exactly this many. |
| standard | ERC20, TRC20, and so on. Useful in help text for customers pasting into an exchange. |
| family | Chains that share an address format. ETH_Family covers Ethereum, Base and Polygon. |
| recommended / mostUsed | Sort and badge your picker with these rather than inventing an order. |
| customerAddress | Empty until you assign one. Do not render it as an address. |
Show the network as loudly as the coin. A customer sending USDC on Ethereum to a Base address loses the money, and every support ticket of that shape starts with a picker that said only “USDC”.
This response is the authority on what a payment can take — including any chain we accept by routing it for the customer and settling to you elsewhere. Render what it returns and you get those for free; render a list you wrote and you never will.
3. Assign an address
Once the customer has picked, ask for the address they should send to. Call this when they choose — not on page load for every option — so a customer who never pays does not consume an address on five chains.
curl -X POST https://pay.peptiport.com/api/v1/deposit-address/reference/c80f5363-0397-4761-aa1a-3155c3a21470 \
-H "API-Key: $PEPTIPORT_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{ "blockchain_code": "BASE" }'{
"id": 324,
"createdAt": "2026-09-02T06:53:42.419556Z",
"Address": "0xCb12499d865271D1FfFf16308E523e0BB624a779",
"Family": "ETH_Family",
"Status": "active",
"MemberID": 271
}| Field | Notes |
|---|---|
| blockchain_code | Send BTC, ETH, TRX, BASE or POLYGON — the blockchainCode from step 2. |
| Address | Where the customer sends. Capitalised A; it is easy to read past. |
| Family | Chains sharing an address format. One ETH_Family address receives on Ethereum, Base and Polygon alike. |
| Status | active means it is receiving. |
The address is stable for this customer on this family, so calling again returns the same one rather than minting another. Render what comes back; never cache an address across payments in your own database and reuse it.
4. Render your screen
This is the part you are buying by going headless, so it is the part worth getting right. Five things belong on the screen.
| Why | |
|---|---|
| The address, copyable | A copy button, not a selection. Nobody types 42 characters correctly. |
| A QR code | Most customers pay from a phone wallet. Encode the payment URI, not the bare address. |
| Coin and network together | The single most expensive mistake a customer can make is the right coin on the wrong chain. |
| The exact amount | Formatted to walletPrecision. An underpayment lands as PARTIALLY_FILLED and stalls the order. |
| A countdown | The payment expires. Say when, and what happens then. |
Payment URIs for the QR
Generate the QR from a URI so the wallet fills in the amount too. The three shapes you need:
Bitcoin bitcoin:<address>?amount=<btc>
Ethereum, ethereum:<token>@<chainId>/transfer?address=<to>&uint256=<units>
Base, chainId 1 Ethereum 8453 Base 137 Polygon
Polygon units integer, scaled by walletPrecision
Tron tron:<address>?amount=<units>&token=<tokenAddress>Wallet support for URIs is uneven, especially for tokens. Always show the address and the amount as copyable text next to the QR — a QR that one wallet cannot read is a lost sale if it is the only thing on the screen.
Keep the browser out of it
Both calls above take your API key, so both belong on your server. Proxy them through your own backend and give the browser only what it needs: the address, the amount, the network name and the expiry. A key in a fetch from the page is a key in the page.
// POST /api/checkout/:orderId/address { blockchainCode }
export async function assignAddress(orderId, blockchainCode) {
const order = await db.orders.find(orderId); // your session, your order
if (!order.payReference) throw new Error("No payment on this order");
const res = await fetch(
`${process.env.PEPTIPORT_PAY_URL}/api/v1/deposit-address/reference/${order.payReference}`,
{
method: "POST",
headers: {
"API-Key": process.env.PEPTIPORT_PAY_KEY, // server-side only
"Content-Type": "application/json",
},
body: JSON.stringify({ blockchain_code: blockchainCode }),
signal: AbortSignal.timeout(10_000),
},
);
if (!res.ok) throw new Error("Could not assign an address: " + res.status);
const { Address } = await res.json();
// Only what the screen needs. The reference stays server-side.
return { address: Address, amountUsd: order.totalUsd };
}Prices and precision
You priced the order in dollars, and the customer is sending a coin. If you want to show them the coin amount rather than only the dollar figure, this is where the number comes from.
curl https://pay.peptiport.com/api/v1/ticker[
{
"blockchainCode": "TRX",
"currencyCode": "TRX",
"tokenAddress": "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb",
"standard": "TRX",
"walletPrecision": 6,
"family": "TRX_Family",
"price": "0.2796"
}
]No key needed — it is public, and it carries the live USD price per unit. Divide your dollar total by price, then format to walletPrecision. Do that in integer units rather than floating point: a cent lost to rounding is a PARTIALLY_FILLED payment and a customer waiting on an order that will never release.
// $240.00 of an asset at 0.2796, with 6 decimals.
function units(amountUsd, price, precision) {
const scale = 10n ** BigInt(precision);
// Integers throughout: cents in, whole units out, no float in between.
const cents = BigInt(Math.round(amountUsd * 100));
const priceMicro = BigInt(Math.round(Number(price) * 1e6));
// Round up. Asking for a hair more never stalls a payment; asking for a
// hair less does, and the customer has already left the tab.
return (cents * scale * 10000n + priceMicro - 1n) / priceMicro;
}The rate moves while the customer is deciding. Refresh the displayed coin amount every 30 seconds or so, and say plainly that the dollar figure is the one that settles the order. We hold the USD amount, not the coin amount.
The webhook
Give us one HTTPS endpoint and we will POST to it every time a payment changes. Save it on the Payments tab of your dashboard, then use the test button there to fire a real signed event at it before any money depends on it.
{
"reference_id": "c80f5363-0397-4761-aa1a-3155c3a21470",
"invoice_id": "ORD-10432",
"customer_id": "cus_8814",
"status": "FILLED",
"amount": "240.00",
"currency": "USDC",
"filled_amount": "240.00",
"filled_amount_in_usd": "240.00",
"sponsored_amount": "0",
"sponsored_amount_in_usd": "0",
"confirmation_current": 12,
"confirmation_required": 12,
"timestamp": 1756819200,
"payment_info": [
{
"source_address": "0x3fb9...de14",
"destination_address": "0x21d4...a8d4",
"transaction_hash": "0x7c1f...9ab3",
"block_number": 21897412
}
]
}| Field | Notes |
|---|---|
| reference_id | Ours, and opaque. Match on it; never parse it. |
| invoice_id | Yours, exactly as you sent it on the create call. |
| amount | What you asked for, in the coin being paid. |
| filled_amount_in_usd | What actually arrived, in dollars. This is the one to compare against your order total. |
| sponsored_amount | Any part covered on the customer's behalf. Normally 0. |
| payment_info | One entry per on-chain transfer. The last is the most recent. |
| confirmation_current / _required | Progress. Equal means confirmed. |
Every amount is a string, not a number. A JSON parser will hand you "240.00", and event.filled_amount_in_usd >= order.total compares a string to a number. Coerce first, and compare in cents rather than floats.
Verify the signature first
Every delivery carries X-PeptiPort-Signature, which is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your API key. Compute it over the bytes you received, before parsing the JSON — re-serialising the object first will change the bytes and the signature will never match.
import { createHmac, timingSafeEqual } from "node:crypto";
// Raw body, not the parsed object. In Express:
// app.post("/webhooks/peptiport", express.raw({ type: "application/json" }), handler)
function genuine(rawBody, header) {
if (!header) return false;
const expected =
"sha256=" +
createHmac("sha256", process.env.PEPTIPORT_PAY_KEY)
.update(rawBody)
.digest("hex");
const a = Buffer.from(header.trim());
const b = Buffer.from(expected);
// Lengths must agree before timingSafeEqual, which throws otherwise.
return a.length === b.length && timingSafeEqual(a, b);
}
export function handler(req, res) {
if (!genuine(req.body, req.get("x-peptiport-signature"))) {
return res.status(401).json({ error: "Bad signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
// Idempotency: the same event can arrive more than once, and a partial
// payment sends several. Key on reference + status + confirmation count.
const key = [event.reference_id, event.status, event.confirmation_current ?? 0].join(":");
if (!claim(key)) return res.json({ ok: true });
if (event.status === "FILLED" || event.status === "OVER_FILLED") {
// Amounts arrive as strings. Compare in cents; never trust a float here.
const paidCents = Math.round(Number(event.filled_amount_in_usd) * 100);
markPaid(event.invoice_id, paidCents);
}
// Anything non-2xx is retried, so acknowledge a verified event even when
// your own side failed to apply it. A retry storm will not fix your bug.
res.json({ ok: true });
}Check filled_amount_in_usd against what you charged before you release goods. A FILLED status means the payment closed, and OVER_FILLED means more arrived than you asked for — refund the difference rather than treating it as a tip.
What we do when you do not answer
Reply with any 2xx as soon as you have stored the event. Anything from 400 up, and any timeout, is a failure we retry — so slow work belongs in a queue rather than in the request.
| Delivery | Retries |
|---|---|
| Progress | Three attempts, at 0s, 2s and 4s. A confirmation count you miss is superseded by the next one anyway. |
| Final status | The same quick attempts, then backing off — 30 minutes, an hour, two — until something answers 2xx. A paid order is not dropped because your server was down for an afternoon. |
Payment statuses
| Status | Meaning | What to do |
|---|---|---|
| OPEN | Created, nothing has arrived yet. | Nothing. Leave the order pending. |
| PARTIALLY_FILLED | Some money arrived, less than the total. | Hold. The customer can top it up until the payment expires. |
| FILLED | The full amount arrived and confirmed. | Release the order. |
| OVER_FILLED | More than the total arrived. | Release the order, refund the excess. |
| CANCELLED | Expired or cancelled. No more money is expected. | Fail the order, or issue a fresh payment. |
FILLED, OVER_FILLED and CANCELLED are final: nothing further will arrive on that reference.
Reading a payment back
The same truth the webhook carries, pulled instead of pushed. Worth having behind a “check now” button on your own admin.
curl https://pay.peptiport.com/api/v1/payment/reference/c80f5363-0397-4761-aa1a-3155c3a21470 \
-H "API-Key: $PEPTIPORT_PAY_KEY"{
"referenceID": "c80f5363-0397-4761-aa1a-3155c3a21470",
"invoiceID": "ORD-10432",
"customerID": "cus_8814",
"amountInUSD": "240.00",
"paymentState": "FILLED",
"createdAt": "2026-09-02T14:11:03Z"
}Do not poll this on a timer for every open order. The webhook already tells you, and a poll loop across every pending payment is the first thing that gets rate limited. One customer-facing screen refreshing one payment is fine.
Going live
Six things, and the first four cost nothing to check.
| Check | |
|---|---|
| 1 | Your picker is built from the deposit options call, not a hard-coded list — disable a chain and watch it disappear. |
| 2 | The API key appears nowhere in your browser bundle. Search the built assets for it. |
| 3 | Your webhook endpoint rejects an unsigned request with a 401. |
| 4 | The test button on your Payments tab reports a pass. |
| 5 | A real payment on the cheapest chain — five dollars in USDC on Base — moves the order to paid. |
| 6 | Deliberately send slightly less than asked. You should get PARTIALLY_FILLED and the order should hold, not release. |
Rules that matter
| Rule | Why |
|---|---|
| Key server-side only | It signs webhooks. In a browser bundle it is a free-goods button. |
| Verify every webhook | An unverified endpoint will be found and used. Signature first, JSON second. |
| Sign over raw bytes | Parsing and re-serialising changes whitespace and key order, and the HMAC with it. |
| One invoiceID per payment | It is your matching key. Reusing one makes two orders indistinguishable. |
| Be idempotent | Retries and progress events mean the same order is announced more than once. |
| Trust the webhook, not the screen | A customer who reached your success page has not necessarily paid. |
Troubleshooting
| Symptom | Usually |
|---|---|
| Empty deposit options | The payment is already final, or no chains are enabled for your brand. Check the payment status first. |
| 400 on assigning an address | blockchain_code is not one of BTC, ETH, TRX, BASE, POLYGON — the picker sent a currency code instead of a chain code. |
| The address looks familiar | It is. One address serves a customer across a whole family, so an ETH_Family address repeats on Base and Polygon. |
| Money arrived, nothing happened | It went to the right address on the wrong chain, or the amount was short. A short payment is PARTIALLY_FILLED, not a failure. |
| PARTIALLY_FILLED on the exact amount | The coin amount was rounded down, or the customer's wallet took its fee from the send. Round up when you quote. |
| Customer's wallet will not scan the QR | Token URIs are unevenly supported. Show the address and amount as copyable text as well. |
Stuck on something
Send us the reference id and roughly when it happened and we can see the same payment you are looking at. If a key has leaked, say so first and explain second — rotating it takes a minute.
support@peptiport.com