# PeptiPort 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 return a URL,
the customer pays on it, and we POST the result to your server.

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

## Create a payment

`POST https://pay.peptiport.com/api/v1/payment`

```json
{
  "customerEmail": "buyer@example.com",
  "customerID": "cus_8814",
  "amountInUSD": 240.00,
  "invoiceID": "ORD-10432"
}
```

| Field | Required | Notes |
| --- | --- | --- |
| `customerEmail` | yes | Where the receipt goes. |
| `customerID` | yes | Your id for the buyer. Opaque to us; returned on the webhook. |
| `amountInUSD` | yes | Dollars as a number, two decimals. **Not cents, not a string.** |
| `invoiceID` | in practice | Your order number, unique per payment. The API accepts a payment without one; do not send one that way — it is how a webhook is matched back to an order. |
| `expire` | no | RFC 3339. Defaults to 24 hours. |
| `currency` | no | Pin to one coin, e.g. `USDC`. |
| `network` | no | Pin to one chain, e.g. `BASE`. |

Response:

```json
{
  "host": "https://pay.peptiport.com",
  "reference_id": "c80f5363-0397-4761-aa1a-3155c3a21470",
  "url": "https://pay.peptiport.com/payments?reference_id=c80f5363-...&host=https://pay.peptiport.com"
}
```

Store `reference_id` against the order **before** sending the customer
anywhere. It is the only handle on the payment afterwards.

## Send the customer

Redirect to `url`, or open it in a new tab. That page handles the coin
choice, address, QR, rate and confirmations.

The redirect is not payment. The customer can close the tab, pay later from
a phone, or send too little. The webhook is the truth.

## Return URLs

Where the customer lands after they finish on the hosted page. Two of them:

| | Fires when |
| --- | --- |
| Success URL | The payment closes successfully. |
| Cancel URL | The customer abandons or cancels. |

They are configured per brand by PeptiPort, not per payment and not by you —
send us the two URLs and we will set them. There is no API parameter for
them, so they cannot carry an order id chosen at checkout time.

**A customer landing on your Success URL has not necessarily paid.** The
redirect is a browser navigation: it can be replayed, shared, typed by hand,
or reached by a customer who closed the payment page and pressed back. Every
"paid" order created from a success redirect is an order someone can create
for free.

So treat both URLs as presentation only:

1. Identify the order from your own session or a value you stored before
   redirecting — not from anything in the incoming URL, which you did not
   sign and cannot trust.
2. Read the order's real state from your own database, which the webhook has
   updated. If it is not paid yet, show "we are confirming your payment"
   rather than a receipt, and poll your own backend, not ours.
3. Never mark an order paid, release goods, or send a receipt on this
   request.

Do not assume query parameters are appended to either URL. If any are, they
are unsigned and therefore not evidence of anything.

If you leave them unset, the customer simply stays on the payment page after
paying — which is worse than a plain "thanks, we are confirming" page on your
own site, so it is worth setting both.

## Card payments

Cards, Apple Pay, Google Pay, bank transfers and ~175 local methods across
190+ countries, on the same hosted page, settling to you in crypto.

- Enabled per brand by PeptiPort — ask, there is no self-serve toggle and no
  underwriting queue.
- The customer creates a wallet and clears a one-time identity check on their
  first card payment. It is longer than typing a card number and loses some
  first-time buyers; consider leading with crypto if your customers are
  mostly new.
- Settles as USDC on Base into your settlement wallet. No markup from us; the
  onramp partner's fee is shown to the customer before they pay.
- **No chargebacks.** The customer buys crypto from the onramp and pays you
  with it, so a card dispute is between them and the onramp.
- There is no card API and no embeddable widget. A headless integration keeps
  the hosted `url` for the card path.

## Supported crypto

| Chain | Assets |
| --- | --- |
| Bitcoin | BTC |
| Ethereum | ETH, USDC, USDT, PYUSD, cbBTC |
| Base | ETH, USDC, cbBTC |
| Polygon | POL, USDC, USDT |
| Tron | TRX, USDT |

## 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. A created payment returns a reference and a URL, and you stored the reference.
2. Your webhook endpoint is publicly reachable over HTTPS, answers directly
   (no redirect), and rejects an unsigned request with 401.
3. The test button on your PeptiPort Payments tab reports a pass.
4. Delivering the same event twice leaves one paid order.
5. An expired payment leaves the order unpaid rather than stuck.
6. One small real payment end to end — five dollars of USDC on Base is cheapest.
