Integration guide

Headless checkout

The same payments, with the screen built by you. We mint the deposit address and watch the chain; your checkout renders the coin picker, the address, the QR and the countdown, in your own design, without the customer ever leaving your site.
Just want a payment page that works?Hosted checkout is three calls and no UI to build. Start there, and move to headless when the redirect starts costing you conversions.

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.

HostedHeadless
Payment screenWe serve itYou build it
Card paymentsIncludedCrypto only. Send a customer who picks card to the hosted page
Calls to makeOne, plus the webhookThree, plus the webhook
You must renderNothingCoin picker, address, QR, amount, countdown
Rate handlingOursYours to display and refresh
Settlement, fees, statusesIdenticalIdentical

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.

order of operations
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.

POST https://pay.peptiport.com/api/v1/payment
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"
  }'
200 OK
{
  "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.

GET https://pay.peptiport.com/api/v1/blockchain-currency/reference/:reference_id
curl https://pay.peptiport.com/api/v1/blockchain-currency/reference/c80f5363-0397-4761-aa1a-3155c3a21470 \
  -H "API-Key: $PEPTIPORT_PAY_KEY"
200 OK
[
  {
    "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
  }
]
FieldUse it for
blockchainCodeThe value you send back when you assign an address. BTC, ETH, TRX, BASE, POLYGON.
networkThe chain's display name. Show this, not the code.
currencyCode / currencyThe coin. Show alongside the network — USDC on Base is not USDC on Polygon.
walletPrecisionDecimal places for this asset. Format the amount with exactly this many.
standardERC20, TRC20, and so on. Useful in help text for customers pasting into an exchange.
familyChains that share an address format. ETH_Family covers Ethereum, Base and Polygon.
recommended / mostUsedSort and badge your picker with these rather than inventing an order.
customerAddressEmpty 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.

POST https://pay.peptiport.com/api/v1/deposit-address/reference/:reference_id
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" }'
200 OK
{
  "id": 324,
  "createdAt": "2026-09-02T06:53:42.419556Z",
  "Address": "0xCb12499d865271D1FfFf16308E523e0BB624a779",
  "Family": "ETH_Family",
  "Status": "active",
  "MemberID": 271
}
FieldNotes
blockchain_codeSend BTC, ETH, TRX, BASE or POLYGON — the blockchainCode from step 2.
AddressWhere the customer sends. Capitalised A; it is easy to read past.
FamilyChains sharing an address format. One ETH_Family address receives on Ethereum, Base and Polygon alike.
Statusactive 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, copyableA copy button, not a selection. Nobody types 42 characters correctly.
A QR codeMost customers pay from a phone wallet. Encode the payment URI, not the bare address.
Coin and network togetherThe single most expensive mistake a customer can make is the right coin on the wrong chain.
The exact amountFormatted to walletPrecision. An underpayment lands as PARTIALLY_FILLED and stalls the order.
A countdownThe 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:

uri formats
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.

node · your own endpoint
// 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.

GET https://pay.peptiport.com/api/v1/ticker
curl https://pay.peptiport.com/api/v1/ticker
200 OK
[
  {
    "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.

node
// $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.

POST https://your-store.com/webhooks/peptiport
{
  "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
    }
  ]
}
FieldNotes
reference_idOurs, and opaque. Match on it; never parse it.
invoice_idYours, exactly as you sent it on the create call.
amountWhat you asked for, in the coin being paid.
filled_amount_in_usdWhat actually arrived, in dollars. This is the one to compare against your order total.
sponsored_amountAny part covered on the customer's behalf. Normally 0.
payment_infoOne entry per on-chain transfer. The last is the most recent.
confirmation_current / _requiredProgress. 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.

node · express
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.

DeliveryRetries
ProgressThree attempts, at 0s, 2s and 4s. A confirmation count you miss is superseded by the next one anyway.
Final statusThe 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

StatusMeaningWhat to do
OPENCreated, nothing has arrived yet.Nothing. Leave the order pending.
PARTIALLY_FILLEDSome money arrived, less than the total.Hold. The customer can top it up until the payment expires.
FILLEDThe full amount arrived and confirmed.Release the order.
OVER_FILLEDMore than the total arrived.Release the order, refund the excess.
CANCELLEDExpired 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.

GET https://pay.peptiport.com/api/v1/payment/reference/:reference_id
curl https://pay.peptiport.com/api/v1/payment/reference/c80f5363-0397-4761-aa1a-3155c3a21470 \
  -H "API-Key: $PEPTIPORT_PAY_KEY"
200 OK
{
  "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
1Your picker is built from the deposit options call, not a hard-coded list — disable a chain and watch it disappear.
2The API key appears nowhere in your browser bundle. Search the built assets for it.
3Your webhook endpoint rejects an unsigned request with a 401.
4The test button on your Payments tab reports a pass.
5A real payment on the cheapest chain — five dollars in USDC on Base — moves the order to paid.
6Deliberately send slightly less than asked. You should get PARTIALLY_FILLED and the order should hold, not release.

Rules that matter

RuleWhy
Key server-side onlyIt signs webhooks. In a browser bundle it is a free-goods button.
Verify every webhookAn unverified endpoint will be found and used. Signature first, JSON second.
Sign over raw bytesParsing and re-serialising changes whitespace and key order, and the HMAC with it.
One invoiceID per paymentIt is your matching key. Reusing one makes two orders indistinguishable.
Be idempotentRetries and progress events mean the same order is announced more than once.
Trust the webhook, not the screenA customer who reached your success page has not necessarily paid.

Troubleshooting

SymptomUsually
Empty deposit optionsThe payment is already final, or no chains are enabled for your brand. Check the payment status first.
400 on assigning an addressblockchain_code is not one of BTC, ETH, TRX, BASE, POLYGON — the picker sent a currency code instead of a chain code.
The address looks familiarIt is. One address serves a customer across a whole family, so an ETH_Family address repeats on Base and Polygon.
Money arrived, nothing happenedIt 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 amountThe 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 QRToken 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