# PeptiPort headless checkout

The same payments with the payment screen built by you. We mint the deposit
address and watch the chain; your checkout renders the coin picker, address,
QR and countdown, and the customer never leaves your site.

Base URL: `https://pay.peptiport.com`
Auth: every request carries the header `API-Key: <your key>`

Headless covers the crypto rails only. There is no card endpoint and no
widget — send a customer who picks card to the `url` from the create call.

## Flow

```
checkout confirmed
  ├─ POST /api/v1/payment                                  -> reference_id
  ├─ GET  /api/v1/blockchain-currency/reference/:reference_id
  │        -> the coins and chains this payment can take
  ├─ customer picks one
  ├─ POST /api/v1/deposit-address/reference/:reference_id
  │        { "blockchain_code": "BASE" }                    -> Address
  └─ wait for the webhook -> FILLED -> release the order
```

## 1. Create a payment

Identical to hosted. See the hosted guide for the full field reference.
Returns `{ host, reference_id, url }`.

## 2. Deposit options

`GET https://pay.peptiport.com/api/v1/blockchain-currency/reference/{reference_id}`

```json
[
  {
    "id": 7,
    "blockchainCode": "BASE",
    "network": "Base",
    "currencyCode": "USDC",
    "currency": "USDC",
    "customerAddress": "",
    "tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
    "standard": "ERC20",
    "walletPrecision": 6,
    "family": "ETH_Family",
    "recommended": true,
    "mostUsed": true
  }
]
```

Build the picker from this response, never from a hard-coded list: which
chains are enabled is a per-brand setting, and this is also where any
cross-chain route we accept shows up.

- `blockchainCode` — what you send back to assign an address. One of
  `BTC`, `ETH`, `TRX`, `BASE`, `POLYGON`.
- `network` — display name. Show it as prominently as the coin: USDC on
  Ethereum sent to a Base address is lost money.
- `walletPrecision` — decimals for this asset. Format the amount with
  exactly this many.
- `recommended` / `mostUsed` — sort and badge with these.
- `customerAddress` — empty until you assign one. Do not render it.

## 3. Assign an address

`POST https://pay.peptiport.com/api/v1/deposit-address/reference/{reference_id}`
with `{ "blockchain_code": "BASE" }`.

```json
{
  "id": 324,
  "Address": "0xCb12499d865271D1FfFf16308E523e0BB624a779",
  "Family": "ETH_Family",
  "Status": "active"
}
```

Note the capital `A` on `Address`. Call this when the customer chooses,
not on page load for every option. The address is stable per customer per
family (one `ETH_Family` address receives on Ethereum, Base and Polygon
alike), so calling again returns the same one — render what comes back
rather than caching it yourself across payments.

## 4. Render the payment screen

Five things belong on it:

1. The address, with a copy button.
2. A QR code, encoding a payment URI rather than the bare address.
3. Coin **and** network together.
4. The exact amount, formatted to `walletPrecision`.
5. A countdown to expiry.

URI shapes:

```
Bitcoin   bitcoin:<address>?amount=<btc>
EVM       ethereum:<token>@<chainId>/transfer?address=<to>&uint256=<units>
            chainId 1 Ethereum, 8453 Base, 137 Polygon
Tron      tron:<address>?amount=<units>&token=<tokenAddress>
```

Wallet support for token URIs is uneven — always show the address and
amount as copyable text next to the QR.

**Both authenticated calls belong on your server.** Proxy them through your
own backend and give the browser only the address, amount, network name and
expiry. A key in a fetch from the page is a key in the page.

## Return URLs

Headless keeps the customer on your site, so these do not apply — except on
the card path, where you hand off to the hosted page. If you use that path,
see Return URLs in the hosted guide: the rule that matters is that a customer
landing back on your site is not evidence they paid.

## Prices and precision

`GET https://pay.peptiport.com/api/v1/ticker` — public, no key. Returns each asset with
`blockchainCode`, `currencyCode`, `walletPrecision` and a live USD
`price` per unit.

Divide your dollar total by `price`, format to `walletPrecision`, and do
it in integer units — a cent lost to floating point is a
`PARTIALLY_FILLED` payment and an order that never releases. **Round up:**
asking for a hair more never stalls a payment, asking for a hair less does.

```js
function units(amountUsd, price, precision) {
  const scale = 10n ** BigInt(precision);
  const cents = BigInt(Math.round(amountUsd * 100));
  const priceMicro = BigInt(Math.round(Number(price) * 1e6));
  return (cents * scale * 10000n + priceMicro - 1n) / priceMicro;
}
```

The rate moves while the customer decides. Refresh the displayed coin amount
every ~30 seconds and say plainly that the dollar figure is what settles the
order — we hold the USD amount, not the coin amount.

## The webhook

You give us one HTTPS endpoint. We POST to it every time a payment changes.

```json
{
  "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, opaque. Match on it; never parse it. |
| `invoice_id` | Yours, exactly as sent on the create call. |
| `filled_amount_in_usd` | What actually arrived, in dollars. Compare this against the 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.** `"240.00"`, not `240.00`. Coerce
before comparing, and compare in cents rather than floats.

### Verify the signature before parsing

Header `X-PeptiPort-Signature`, value `sha256=<hex>`, where the hex is
HMAC-SHA256 of the **raw request body bytes** keyed with the API key.
Re-serialising the parsed JSON changes the bytes and the signature will
never match. Compare in constant time.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function genuine(rawBody, header, apiKey) {
  if (!header) return false;
  const expected =
    "sha256=" + createHmac("sha256", apiKey).update(rawBody).digest("hex");
  const a = Buffer.from(header.trim());
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

### Answer quickly, and be idempotent

Reply with any 2xx as soon as the event is stored; do slow work in a queue.
Anything 400 and up, or a timeout, is retried: three quick attempts for a
progress event (0s, 2s, 4s), then a long backoff on a final status (30
minutes, an hour, two) until something answers 2xx.

The same event can therefore arrive more than once, and a partial payment
sends several. Dedupe on
`reference_id + status + (confirmation_current ?? 0)`.

**Do not follow-redirect your own endpoint.** We do not follow redirects on
a signed delivery, so the URL you give us must answer directly — not 301 to
a trailing slash, not bounce to a login page.

### Firewall and access

The delivery is an ordinary POST from the public internet. It has to reach
you, which rules out more setups than people expect:

| Blocks us | Because |
| --- | --- |
| Platform deployment protection | A Vercel or Netlify preview behind password or SSO answers every request with a redirect to a login page. Point us at production. |
| An auth wall or basic auth | Same shape: we get the sign-in page, not your handler. Exempt the webhook route. |
| Bot protection | Cloudflare "Under Attack" mode, bot-fight rules and aggressive WAF rules on JSON POSTs will challenge or drop us. Add a rule that skips them for the webhook path. |
| IP allowlists | We do not publish fixed egress addresses, so an allowlist will fail sooner or later. |
| A private network | VPN-only hosts, internal load balancers and anything on a private address are unreachable by definition. |
| An invalid certificate | HTTPS only, and the certificate has to verify. Self-signed and expired both fail. |

**Authenticate on the signature, not on the source.** Do not allowlist by IP
or by User-Agent: neither is a secret, both change, and the HMAC already
proves the delivery is ours. Leave the route open to the internet and let a
bad signature be what turns a request away.

The test button on your Payments tab is the cheapest way to prove all of
this at once — it makes the same request from the same place a live
delivery does, and prints exactly what your server said back.

## Payment statuses

| Status | Meaning | What to do |
| --- | --- | --- |
| `OPEN` | Created, nothing arrived. | Leave the order pending. |
| `PARTIALLY_FILLED` | Some money arrived, less than the total. | Hold. The customer can top up until it expires. |
| `FILLED` | Full amount arrived and confirmed. | Release the order. |
| `OVER_FILLED` | More than the total arrived. | Release, refund the excess. |
| `CANCELLED` | Expired or cancelled. | Fail the order, or issue a fresh payment. |

`FILLED`, `OVER_FILLED` and `CANCELLED` are final.

## Reading a payment back

`GET https://pay.peptiport.com/api/v1/payment/reference/{reference_id}` with the
`API-Key` header returns
`{ referenceID, invoiceID, customerID, amountInUSD, paymentState, createdAt }`.

Use it behind a "check now" button. Do not poll it on a timer for every
open order — the webhook already tells you, and a poll loop across every
pending payment is what gets rate limited.

## Rules

1. The API key is server-side only. It signs webhooks; in a browser bundle
   it is a free-goods button.
2. Verify every webhook. Signature first, JSON second.
3. Sign over raw bytes.
4. One unique `invoiceID` per payment. It is your matching key.
5. Be idempotent.
6. Trust the webhook, not the customer reaching your success page.

## Going live

1. The picker is built from the deposit-options call, not a constant.
2. The API key appears nowhere in your browser bundle — search the built assets.
3. Your webhook endpoint answers directly over HTTPS and rejects an unsigned
   request with 401.
4. The test button on your PeptiPort Payments tab reports a pass.
5. A real payment on the cheapest chain moves the order to paid.
6. Deliberately underpay: you should get `PARTIALLY_FILLED` and the order
   should hold, not release.
