interpay

API docs

Create a payment from your server, send your customer to pay by crypto or card, and get told when it's done. Everything is JSON over HTTPS.

Interpay never holds your money, and holds no key to it. Every payment goes from your customer straight to your own wallet or Stripe account, in full, and Interpay bills its fees to you weekly (see Fees). Bitcoin and Litecoin are one payment to your wallet, Ethereum payments reach your wallet in the customer's own transaction, and card payments are charged on your own Stripe account. There is no Interpay balance and no payout step.

Getting started

  1. Create an account and make a test key on the API keys page.
  2. Add a wallet address, or connect Stripe, under Ways to pay — that is where the money goes.
  3. Create a payment with the key (below) and open its checkout_url.
  4. Pay it with testnet coins or a Stripe test card and watch the status change.
  5. Add a webhook endpoint so your shop is told when payments complete.

All requests go to https://interpay.one/api/v1.

Authentication

Send your secret key in the Authorization header. Keep it on your server; never put it in a web page or app.

Authorization: Bearer ip_test_…

Keys starting with ip_test_ work in test mode: coins on each chain's test network, and your Stripe test account, neither of which has value. Keys starting with ip_live_ take real money; create one whenever you're ready to go live. A test key only ever sees test payments, and a live key only live ones; test and live credentials are never mixed.

Check a key with:

curl https://interpay.one/api/v1/merchant \
  -H "Authorization: Bearer ip_test_…"

{"id": "cmu…", "name": "Northwind Coffee", "mode": "test"}

Create a payment

POST /api/v1/payment_intents

Without method, the customer picks how to pay on the payment page, and nothing is priced until they do: amount, crypto and card are null until then. With method (and crypto for a coin) the payment is priced right away: the coin amount at the current rate and your own wallet, held for your payment window; or a Stripe Checkout session on your own account.

FieldTypeDescription
amountstringRequired. Your price in the currency, with at most 2 decimals, e.g. "42.50". Between 1.00 and 1,000,000.00.
currencystringRequired. EUR or USD.
methodstring"crypto" or "card". Leave it out to let the customer choose.
cryptostringBTC, LTC, ETH, USDC or USDT, if you've agreed on a coin with your customer. Implies method "crypto".
referencestringOptional. Your own order number, up to 200 characters.
descriptionstringOptional. Shown to the customer on the payment page.
redirect_urlstringOptional. http(s) URL; the payment page offers a button back to it once paid.
metadataobjectOptional. Up to 20 keys with string, number, boolean or null values. Returned as-is.
curl https://interpay.one/api/v1/payment_intents \
  -H "Authorization: Bearer ip_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"amount": "42.00", "currency": "EUR", "reference": "1042",
       "redirect_url": "https://shop.example/thanks"}'

Returns 201 with the payment object. Interpay's fee is either included in your price or added to what the customer pays, as you set it per rail under Ways to pay; the payment records which, and the exact amounts, in fee.

Retrieve a payment

GET /api/v1/payment_intents/:id

Returns the payment object, or 404 if it doesn't exist in this key's mode. This is the only thing that proves a payment: never treat a browser redirect, or anything your page can see, as proof.

List payments

GET /api/v1/payment_intents

FieldTypeDescription
limitnumber1 to 100, default 20.
starting_afterstringA payment id; returns the payments created before it. Use the last id of the previous page.
statusstringOnly payments with this status.
providerstringOnly "crypto" or only "stripe" payments.
cryptostringOnly payments in this coin.
order_idstringOnly payments for this store order.
{"object": "list", "data": [ …payment objects, newest first… ], "has_more": true}

Cancel a payment

POST /api/v1/payment_intents/:id/cancel

Cancels a payment nobody has paid yet, for example when the customer abandons the order. Returns the payment object with status cancelled, or 409 money_received once money has been seen, or not_open once it has closed. Interpay keeps watching a cancelled crypto payment for a day: money that arrives anyway is recorded and waits for your decision instead of being lost.

The payment object

{
  "id": "cmuss7faf000c7bzek7tv79v5",
  "object": "payment_intent",
  "mode": "test",
  "status": "confirming",
  "provider": "crypto",
  "currency": "EUR",
  "price": "42.00",
  "amount": "42.00",
  "merchant_amount": "41.58",
  "platform_fee": "0.42",
  "provider_fee": null,
  "fee": {"mode": "deducted", "percent_bps": 100, "fixed": "0.00"},
  "fee_billed": true,
  "amount_refunded": "0.00",
  "checkout_url": "https://interpay.one/pay/cmuss7faf000c7bzek7tv79v5",
  "reference": "1042",
  "crypto": {
    "asset": "LTC",
    "chain": "litecoin",
    "exchange_rate": {"price": "61.31", "currency": "EUR", "source": "kraken", "confirmed_by": "coingecko", "fetched_at": "…"},
    "merchant_wallet": "tltc1quvwe5a06lwlup798r9v03nd4jg27qjyvurc7u0",
    "fee_wallet": null,
    "fee_billed": true,
    "merchant_amount": "0.68504323",
    "platform_fee": "0",
    "total": "0.68504323",
    "received": {"merchant": "0.68504323", "fee": "0", "total": "0.68504323"},
    "remaining": {"merchant": "0", "fee": "0"},
    "overpaid": "0",
    "required_confirmations": 1,
    "confirmations": 0,
    "payment_uris": {"merchant": "litecoin:tltc1quvwe…?amount=0.68504323", "fee": null},
    "ethereum": null,
    "transactions": [
      {"txid": "b34348…", "index": 0, "merchant_amount": "0.68504323", "fee_amount": "0",
       "confirmations": 0, "block_height": null, "late": false, "matched_by": "exact_amount", "seen_at": "…"}
    ]
  },
  "card": null,
  "refunds": [],
  "expires_at": "2026-10-03T19:40:34.000Z",
  "detected_at": "2026-10-03T19:28:11.000Z",
  "completed_at": null,
  "created_at": "2026-10-03T19:25:34.000Z"
}
FieldTypeDescription
pricestringWhat you asked for.
amountstringWhat the customer pays: the price, plus Interpay's fee when your settings add it. Null until the method is locked.
merchant_amountstringWhat you keep after Interpay's fee, before any Stripe fee.
platform_feestringInterpay's fee on this payment, at the rule in force when it was created. Editing the rule never changes it. It's billed on your weekly statement once the payment completes.
fee_billedbooleantrue when Interpay's fee isn't taken from the payment: you receive all of amount, and the fee goes on your weekly statement. false on payments created before fees were billed, which paid it at checkout. Null until the method is locked.
provider_feestringStripe's own fee, in provider_fee_currency, once Stripe reports it. Crypto has none: the customer pays the network fee.
crypto.merchant_walletstringYour own address, as it was when the payment was priced. Changing your wallet later doesn't move an open payment.
crypto.fee_billedbooleanSame as fee_billed: the customer pays the whole amount (total) to merchant_wallet in one transaction, and Interpay bills its fee to you weekly. crypto.merchant_amount is then the whole amount and crypto.platform_fee is 0.
crypto.fee_walletstringInterpay's fee address, paid in the same transaction, on payments created before fees were billed weekly: on Ethereum one address per network, on Bitcoin and Litecoin an address for that payment only. Null otherwise.
crypto.payment_urisobjectBitcoin and Litecoin wallet links for QR codes: merchant, and fee for the older payments that have a fee_wallet. Null on Ethereum, which uses the proxy below.
crypto.ethereumobjectchain_id, the payment_reference the fee proxy emits, the proxy contract to call (with a fee of 0 when fee_billed is true), and token for an ERC-20 (USDC, USDT). A transfer only counts when the contract, chain, recipient, amount and reference all match.
crypto.required_confirmationsnumberFrom your settings. Defaults: Bitcoin 1 or 3, Litecoin 1 or 3, Ethereum 12 or 64, the larger count from 50.00 up.
crypto.transactionsarrayEvery transfer seen. late is true for money that arrived after the payment closed; it never counts until you decide. matched_by says how it was tied to the payment.
card.account_idstringYour own Stripe account the charge was made on (acct_…).
card.application_feestringInterpay's fee as Stripe's application fee on that charge: 0.00 when fee_billed is true, since the fee is billed instead.
card.settlementobjectHow the charge settled on your Stripe balance: currency, gross, net and the exchange rate Stripe used.
refundsarrayEach refund with its status. A crypto refund is a transaction you send from your own wallet; Interpay records it and checks the chain for it.
expires_atstringEnd of the payment window (15 minutes unless changed in your settings). Crypto first seen after it is late.

Statuses

Not started created
Created; the customer hasn't picked how to pay yet, so nothing is priced.
Awaiting payment awaiting_payment
Ready to pay: the coin, rate and amount are locked, or Stripe Checkout is open.
Partly paid payment_detected
Part of the amount has arrived, and the customer can still send the rest before the window closes.
Confirming confirming
Everything due has arrived, and is waiting for its confirmations.
Confirmed confirmed
Final on the chain, or the card charge succeeded: every recipient has its confirmations and the block hashes still match. Interpay is applying it.
Paid completed
Done. The money is in your wallet or your Stripe account, and Interpay's fee goes on your next weekly statement. This is the one to fulfil orders on, and the only status that means paid.
Underpaid underpaid
The window closed with less than due, all of it confirmed. Never completed on its own: in the dashboard you keep what arrived or send it back.
Overpaid overpaid
More than due arrived. The excess is recorded in crypto.overpaid and waits for the same decision; the payment itself still completes.
Expired expired
The window closed with nothing paid.
Failed failed
The provider refused it: a declined card, or a crypto payment whose terms could no longer be honoured. failure_reason says which.
Cancelled cancelled
You cancelled it before anything was paid.

completed, underpaid, expired, failed and cancelled are final. Interpay still watches a closed crypto payment for a day: money that arrives then is recorded with late: true, reported as payment.late_payment, and never counts until the merchant decides. A second payment for an order something else already paid is marked duplicate for the same reason.

Payment page

The simplest integration is to redirect the customer to checkout_url. It offers every way you accept, shows the exact amount and a QR code for Bitcoin and Litecoin, opens the customer's browser wallet for Ethereum, sends them to Stripe Checkout for cards, shows live progress, and offers a link back to your redirect_url once paid.

Don't treat that redirect, or anything else the browser tells you, as proof of payment: rely on the webhook, or retrieve the payment from your server.

To show a crypto payment in your own page instead, create it with crypto and use what is under crypto. On Bitcoin and Litecoin the customer sends crypto.total to crypto.merchant_wallet, your own address, in one transaction; crypto.payment_uris.merchant is the link for the QR code. The payment is recognised by that exact amount, which can be a few of the coin's smallest units above the converted price so that no two open payments share it. Show it exactly: a different amount isn't matched to the payment. Ethereum pays through the fee proxy named in crypto.ethereum, whose single call pays you the whole amount, with a fee of 0, and emits your payment reference; a plain transfer to your address, or one with the wrong token, chain, amount or reference, is not matched to the payment.

Cards open a Stripe Checkout session on your own connected account, so the card details only ever reach Stripe. Interpay never sees or stores them.

Fees

Interpay's fee on a payment is set when the payment is created, and is in platform_fee. Whatever the customer pays with, nothing is taken at checkout: you get the whole payment, and Interpay bills the fee to you once the payment completes.

  • Cards: the charge on your Stripe account carries no application fee. Stripe still takes its own.
  • Ethereum, USDC and USDT: the customer's transaction pays your wallet through the fee proxy, with a fee of 0.
  • Bitcoin and Litecoin: one transaction pays your wallet.

A refund doesn't take the fee off. Payments created before fees were billed (fee_billed false) paid their fee at checkout, and are never billed.

Every Monday at 00:00 UTC, Interpay issues one statement per mode and currency for the fees not billed yet. It's due 7 days later. Fees that add up to less than 10.00 EUR (or the minimum for your currency, shown on the Billing page) wait for the next Monday. Pay a statement on the Billing page: in Bitcoin or Litecoin from your own wallet, or by card. Test-mode statements are paid with test coins or a Stripe test card.

If a statement is still unpaid 7 days after it's due, new payments pause in that mode, whatever the method: the payment page stops offering any way to pay, and creating one through the API answers method_unavailable. Payments already open aren't affected. Paying the statement lifts the pause straight away.

Stores and products

If you sell through an Interpay storefront, the API reads your catalog. Products are the same in test and live mode; a key sees the products of every store of the business.

GET /api/v1/stores
GET /api/v1/products              ?store_id=&status=active&type=digital&limit=&starting_after=
GET /api/v1/products/:id
{
  "id": "cmv1p…",
  "object": "product",
  "store_id": "cmv0s…",
  "type": "digital",
  "status": "active",
  "name": "Field Recorder Presets",
  "slug": "field-recorder-presets",
  "short_description": "48 presets for ambient recordings",
  "options": [{"name": "License", "values": ["Personal", "Studio"]}],
  "images": [{"id": "cmv1i…", "url": "https://interpay.one/api/media/cmv1f…/image", "alt": null}],
  "variants": [
    {"id": "cmv1v…", "title": "Personal", "options": ["Personal"], "price": "19.00",
     "compare_at_price": null, "sku": "FRP-P", "inventory_quantity": null}
  ],
  "collection_ids": ["cmv1c…"],
  "metadata": {},
  "created_at": "…",
  "updated_at": "…"
}

inventory_quantity is null when the product doesn't count stock. Prices are in the store's currency, which GET /api/v1/stores returns along with each store's address and whether its checkout takes test or live payments (checkout_mode).

Orders

Orders come from your storefront and payment links. Each one opens an Interpay payment, so the payment endpoints and webhooks above work for them too; the order adds what was bought, by whom and what happened next. An order can have several payments, one per attempt, because a customer who lets one expire or switches coin gets a new one. Like payments, orders belong to a mode: a test key sees test orders only.

GET  /api/v1/orders               ?store_id=&status=&payment_status=&fulfillment_status=&email=&limit=&starting_after=
GET  /api/v1/orders/:id
POST /api/v1/orders/:id/fulfill   {"tracking_number": "1Z999…", "tracking_url": "https://…"}

fulfill marks a paid order as fulfilled and completes it, with optional tracking the customer sees on their order page. Digital items are delivered automatically once an order is paid, so you only need it for physical products and services. It answers 409 invalid_state for orders that aren't paid or are already closed.

StatusFieldDescription
pendingstatusPlaced; waiting for the payment.
paidstatusPaid in full. Digital items are delivered; anything else waits for you.
processingstatusYou started working on it.
completedstatusFulfilled. Final, unless refunded.
canceledstatusGiven up or canceled; stock went back.
refundedstatusMoney sent back to the customer.
underpaid, overpaidpayment_statusThe customer paid too little or too much. Underpaid orders wait for your decision in the dashboard; overpaid ones too if your checkout settings say so.
disputedpayment_statusA card payment is disputed. Stripe holds the disputed amount on your account until it's decided.

The order object

{
  "id": "cmv2o…",
  "object": "order",
  "mode": "test",
  "number": 1042,
  "store": {"id": "cmv0s…", "slug": "northwind", "name": "Northwind Coffee"},
  "source": "storefront",
  "status": "paid",
  "payment_status": "paid",
  "fulfillment_status": "unfulfilled",
  "email": "[email protected]",
  "customer_id": "cmv2c…",
  "customer_name": "Maya R.",
  "currency": "EUR",
  "subtotal": "38.00",
  "discount": "3.80",
  "discount_code": "WELCOME10",
  "shipping": "4.90",
  "tax": "0.00",
  "tax_included": false,
  "total": "39.10",
  "provider": "crypto",
  "crypto": "LTC",
  "overpaid_crypto": null,
  "payment_intent_id": "cmv2i…",
  "payment_link_id": null,
  "items": [
    {"id": "cmv2l…", "product_id": "cmv1p…", "variant_id": "cmv1v…", "type": "physical",
     "name": "House Blend", "variant": "1 kg", "sku": "HB-1000", "quantity": 2,
     "unit_price": "19.00", "discount": "3.80", "total": "34.20"}
  ],
  "shipping_address": {"name": "Maya R.", "line1": "…", "city": "…", "postalCode": "…", "country": "DE"},
  "customer_note": null,
  "tracking_number": null,
  "tracking_url": null,
  "created_at": "…",
  "paid_at": "…",
  "fulfilled_at": null,
  "completed_at": null,
  "canceled_at": null,
  "refunded_at": null
}

Amounts are decimal strings in the order's currency. The link to the customer's order page is never part of the API: it unlocks their downloads.

Webhooks

Add endpoints on the Webhooks page or with the API. Each one gets a signing secret, shown only when it's created.

POST   /api/v1/webhook_endpoints      {"url": "https://shop.example/hooks/interpay"}
GET    /api/v1/webhook_endpoints
DELETE /api/v1/webhook_endpoints/:id

Live endpoints must use https. Test endpoints may also use http, so you can point them at your own machine while developing.

EventDescription
payment.createdA payment was created, before the customer picked how to pay.
payment.pendingThe method is locked: the coin, rate and amounts, or Stripe Checkout.
payment.detectedPart of the amount arrived.
payment.confirmingEverything due arrived and is waiting for confirmations.
payment.confirmedFinal on the provider's side.
payment.completedDone; the money is yours. This is the one to fulfil orders on.
payment.underpaidClosed with less than due.
payment.overpaidMore than due arrived; the excess waits for your decision.
payment.expiredClosed with nothing paid.
payment.failedThe provider refused it; failure_reason says why.
payment.cancelledYou cancelled the payment.
payment.late_paymentMoney arrived after the payment closed. Never counted until you decide.
payment.refundedA refund went through: through Stripe, or your own transaction confirmed on the chain.
payment.disputedA card payment was disputed. Stripe holds the amount until it's decided.
order.createdA customer placed an order in your storefront or through a payment link. data is the order object.
order.paidThe order's payment is complete.
order.fulfilledYou marked the order fulfilled.
order.completedThe order is done. Sent right after order.fulfilled; digital orders get both once they're delivered.
order.canceledThe order was canceled or its payment given up.
order.refundedMoney for the order was sent back.
test.pingSent by the Send test event button in the dashboard. data holds only a message.
{
  "id": "evt_4f1c…",
  "type": "payment.completed",
  "created_at": "2026-10-03T19:31:02.000Z",
  "mode": "test",
  "data": { …the payment object… }
}

Respond with any 2xx status within 10 seconds. Otherwise Interpay retries after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours. The same event can arrive more than once, so use its id or the payment status to ignore repeats, and make fulfilment idempotent: a payment that confirms, is reorganised out and confirms again sends the events twice.

Verifying webhooks

Every request carries an Interpay-Signature header: t=<unix time>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw body> with your endpoint secret. Check it against the raw body, before parsing, and reject old timestamps.

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

export function verifyInterpay(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && timingSafeEqual(expected, given);
}

Safe retries

Send an Idempotency-Key header (up to 200 characters, such as your order number) when creating a payment. If the request times out and you send it again with the same key, you get the payment from the first request instead of a second one.

Errors

Errors use a 4xx or 5xx status and the same shape:

{"error": {"code": "invalid_amount", "message": "amount must be a decimal with at most 2 places, e.g. \"42.50\"."}}
CodeStatusDescription
invalid_api_key401Missing, unknown or revoked key.
invalid_json400The body isn't valid JSON.
invalid_request400A field is missing or has the wrong type; the message names it.
invalid_amount400amount isn't a decimal with at most 2 places.
amount_out_of_range400amount is below 1.00 or above 1,000,000.00.
amount_too_small422The amount is too small to cover Interpay's fee.
invalid_url400Webhook URL isn't allowed (see Webhooks).
too_many_endpoints400Up to 10 webhook endpoints per mode.
not_found404No such object in this key's mode.
not_open409The payment has already closed.
money_received409It can't be cancelled: money has already been seen.
method_locked409The customer already picked how to pay.
rate_unavailable503The exchange rate couldn't be fetched. Retry shortly with the same Idempotency-Key.
chain_unavailable503That chain couldn't be reached. Retry shortly with the same Idempotency-Key.
method_unavailable422That coin can't be used: not accepted in your settings, not set up on the server, or you have no wallet for its chain. Also every method while new payments are paused because a fee statement is more than 7 days overdue.
card_unavailable422Cards can't be used: not accepted, no connected Stripe account that can take charges, or below Stripe's minimum for the currency.
wallet_missing422You haven't set a wallet address for that chain yet.
account_closed403The business has closed its Interpay account.
rate_limited429Over 300 requests a minute for this key. Wait for the Retry-After seconds.
mode_unavailable503This mode isn't set up on the server.

Storefront CSS

In the storefront editor, under Theme › Custom CSS, you can add your own styles. They load on your storefront pages only: never on checkout, order pages or the Interpay dashboard, so the amount, address, QR code and time left always look the way customers expect. Write every selector inside .store; the class names, variables and attributes below are kept stable for that purpose.

.store .store-hero-heading {
  letter-spacing: -0.03em;
}
.store[data-scheme="dark"] .store-product-name {
  font-weight: 500;
}
.store #section-s4k2m9q1x .store-button {
  background: var(--s-accent);
  color: var(--s-on-accent);
}

Class names

SelectorDescription
.storeThe whole storefront. Carries the theme variables and layout attributes below.
.store-announcementAnnouncement bar
.store-headerHeader; .store-logo, .store-nav-link and .store-search inside it
.store-sectionEvery section; .store-section-hero, .store-section-faq… for one kind
#section-<id>One particular section. Its id is shown under the section's settings.
.store-section-headA section's heading block: .store-eyebrow, .store-heading, .store-text
.store-heroHero: .store-hero-heading, .store-hero-text, .store-hero-media
.store-product-gridProduct grids
.store-productA product card: .store-product-name, .store-price, .store-badge
.store-product-viewProduct page: .store-gallery, .store-buy-box, .store-add-to-cart, .store-buy-now
.store-collectionA collection tile
.store-featuresHighlights: .store-feature, .store-feature-icon, .store-feature-title
.store-testimonialA testimonial: .store-testimonial-author, .store-testimonial-role
.store-faq-itemOne FAQ question and answer
.store-proseFormatted text from rich text settings and pages
.store-buttonButtons; .store-link for text links
.store-cart-drawerThe cart that slides in; .store-cart for the cart page
.store-footerFooter: .store-footer-menu, .store-footer-contact, .store-footer-legal

Theme variables

Set on .store from your theme's colors and fonts. Use them to stay in step when you change the palette.

VariableDescription
--s-bgPage background
--s-surfaceCards, panels, fields
--s-surface-2A step darker or lighter than the surface
--s-textText
--s-mutedMuted text
--s-borderBorders; --s-border-strong for a firmer line
--s-primaryPrimary; --s-primary-hover, --s-on-primary for text on it
--s-secondarySecondary; --s-on-secondary for text on it
--s-accentAccent; --s-on-accent for text on it
--s-successSuccess
--s-font-headingHeading font stack
--s-font-bodyBody font stack
--s-radiusCorner radius; --s-radius-button, --s-radius-image
--s-maxContent width
--s-gutterSpace at the page edges
--s-gapSpace between sections

Layout attributes

Your layout choices, as attributes on .store, e.g. .store[data-buttons="pill"].

AttributeDescription
data-themelinen, market, noir, spotlight, vault
data-schemelight, dark (from the background color)
data-headerinline, centered, split
data-cardsplain, framed, overlay
data-ratiosquare, portrait, landscape
data-buttonssolid, outline, pill
data-densitycomfortable, compact
data-product-layoutsplit, stacked, wide

Each section's own id is shown under its settings in the editor. Up to 50,000 characters. Anything in it that could end the style element is neutralized, and CSS can't run scripts.