Integration guide

Hosted checkout

Take card and crypto payments on your own store without holding a key, running a node, or watching a chain. You create a payment, we hand you a URL, the customer pays on it, and we tell your server what landed.
Want the payment screen inside your own checkout?Headless keeps this call and adds two more, so you render the coin picker, the address and the QR yourself. Same key, same webhook, same statuses.

What you get

A hosted payment page. You never see a card number and never touch a private key: the page is served by us, the money is watched by us, and your server only handles your own order.

What it means for you
CardTaken on our page, through our onramp partners. No PCI scope on your side, and a longer customer journey than a card field — see Card payments below.
CryptoBitcoin, Ethereum, Base, Polygon and Tron. Every payment gets a fresh deposit address, so two orders for the same amount are never confused.
CoinsBTC; ETH, USDC, USDT, PYUSD and cbBTC on Ethereum; ETH, USDC and cbBTC on Base; POL, USDC and USDT on Polygon; TRX and USDT on Tron.
RatesQuoted in USD and held for the customer while the payment is open.
SettlementTo the wallet on file for your brand. Our fee is taken at settlement, not at checkout.

Three calls in total: create a payment, send the customer to it, and accept one webhook. Everything else is optional.

Your credentials

You have two values from us. They are per brand, they are the only configuration you need, and both are on the Payments tab of your dashboard.

ValueWhat it is
https://pay.peptiport.comThe base URL. Every path below hangs off it.
Your API keyA 32-character hex string, sent as the API-Key header. We issue it once.

The API key is also the secret we sign webhooks with. Anyone holding it can forge a “payment received” event and get goods shipped for free — so keep it in a server-side environment variable, never in client-side code, a repository, or a browser bundle. If it leaks, tell us and we will rotate it in minutes.

.env
PEPTIPORT_PAY_URL=https://pay.peptiport.com
PEPTIPORT_PAY_KEY=your_api_key_here

Create a payment

When the customer confirms their basket, call this from your server. You get back a reference and a URL.

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"
  }'
FieldRequiredNotes
customerEmailYesWhere the receipt goes.
customerIDYesYour own id for the buyer. Opaque to us; it comes back on the webhook.
amountInUSDYesThe total, in dollars, to two decimal places. Not cents.
invoiceIDYes*Your order number, unique per payment. *The API will accept a payment without one; do not send one that way. It is how you match a webhook back to an order, and without it you have a payment you cannot attribute.
expireNoRFC 3339 timestamp. When the payment stops accepting money. Defaults to 24 hours.
currencyNoPin the payment to one coin, e.g. USDC. Omit to let the customer choose.
networkNoPin it to one chain, e.g. BASE. Omit to let the customer choose.
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 your order before you send anyone anywhere. It is the only handle you have on the payment afterwards, and the webhook arrives keyed on it. Treat it as an opaque string — the format is ours to change, so match on it and never parse it.

node
const res = await fetch(process.env.PEPTIPORT_PAY_URL + "/api/v1/payment", {
  method: "POST",
  headers: {
    "API-Key": process.env.PEPTIPORT_PAY_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    customerEmail: order.email,
    customerID: String(order.customerId),
    amountInUSD: Math.round(order.totalCents) / 100,
    invoiceID: order.number,
  }),
  signal: AbortSignal.timeout(10_000),
});

if (!res.ok) throw new Error("Payment could not be created: " + res.status);
const { reference_id, url } = await res.json();

await db.orders.update(order.id, { payReference: reference_id });
return url;

Send the customer

Redirect to the url we returned, or open it in a new tab. The page handles the coin choice, the address, the QR code, the rate and the confirmations. You do not need to poll it or render anything.

Do not treat the redirect as payment. The customer can close the tab, pay ten minutes later from a phone, or send too little. The webhook is the truth; the redirect is just where they go.

Card payments

Cards, Apple Pay, Google Pay, bank transfers and around 175 local payment methods across 190-odd countries, all on the same hosted page and all settling to you in crypto. There is nothing to integrate: the option appears on the payment page you already send customers to.

Ask us to turn it on

Card is a channel we enable on your brand, not a toggle on your Payments tab. Email us and it is on the same day — there is no application, no underwriting queue and no KYB paperwork on your side.

What the customer actually does

Worth knowing before you promise a one-click checkout, because it is longer than a card field and the drop-off is real.

Step
1Picks Card on the payment page.
2Creates a self-custodial wallet with their email address. It is made for them; there is no seed phrase to write down.
3Completes a one-time identity check. Once, not per purchase — a returning customer skips straight past this.
4Chooses a payment method and funds the wallet.
5Pays you from it. You get the same webhook as any other payment.

Steps 2 and 3 happen on the customer’s first card payment only, but they do happen, and a first-time buyer who expected to type a card number may not finish. If your customers are mostly new, price that in — or lead with crypto and offer card as the fallback rather than the default.

Where the money lands

SettlementAs USDC on Base, into your settlement wallet, like everything else. Some onramp partners can route via Ethereum or Polygon instead depending on the customer's method and country.
Our feeNothing extra. We add no markup on a card payment over any other.
Partner feeThe onramp partner sets its own, and it is shown to the customer before they pay. It comes out of what they fund, not out of your settlement.
ChargebacksNone. The customer is buying crypto from the onramp and paying you with it, so a card dispute is between them and the onramp, not you.

That last row is the reason to bother. A card payment through this route settles as final on chain, which is a different risk position from a card processor that can claw funds back for months.

There is no card API

Card exists on the hosted page and nowhere else — there is no endpoint that takes a card number, and no widget to embed. If you have built headless checkout, handle the coins yourself and send the customer who picks “card” to the url from the create call. The payment is the same payment either way, so the webhook lands as normal.

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

Before you switch real customers onto it, prove these five things.

Check
1A created payment returns a reference and a URL, and you stored the reference against the order.
2Your webhook endpoint is reachable over HTTPS from the public internet, and rejects an unsigned request with a 401.
3The test button on your Payments tab reports a pass.
4Delivering the same event twice leaves one paid order, not two.
5An expired payment leaves the order unpaid rather than stuck.

A small real payment is the last check worth doing. Five dollars in USDC on Base is the cheapest way to move real money end to end.

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
401 on createThe key is missing, has whitespace around it, or is in an Authorization header instead of API-Key.
400 on createamountInUSD sent in cents, or as a string. It is dollars, as a number.
Signature never matchesThe body was parsed before it was verified. Read the raw bytes.
No webhooks arrivingThe endpoint is not public, redirects, or sits behind basic auth. We do not follow redirects.
Webhooks arriving repeatedlyYou are not replying 2xx. We retry until you do.
Order paid twiceNo idempotency key. Dedupe on reference + status + confirmation count.

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