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
- Create an account and make a test key on the API keys page.
- Add a wallet address, or connect Stripe, under Ways to pay — that is where the money goes.
- Create a payment with the key (below) and open its
checkout_url. - Pay it with testnet coins or a Stripe test card and watch the status change.
- 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.
| Field | Type | Description |
|---|---|---|
amount | string | Required. Your price in the currency, with at most 2 decimals, e.g. "42.50". Between 1.00 and 1,000,000.00. |
currency | string | Required. EUR or USD. |
method | string | "crypto" or "card". Leave it out to let the customer choose. |
crypto | string | BTC, LTC, ETH, USDC or USDT, if you've agreed on a coin with your customer. Implies method "crypto". |
reference | string | Optional. Your own order number, up to 200 characters. |
description | string | Optional. Shown to the customer on the payment page. |
redirect_url | string | Optional. http(s) URL; the payment page offers a button back to it once paid. |
metadata | object | Optional. 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
| Field | Type | Description |
|---|---|---|
limit | number | 1 to 100, default 20. |
starting_after | string | A payment id; returns the payments created before it. Use the last id of the previous page. |
status | string | Only payments with this status. |
provider | string | Only "crypto" or only "stripe" payments. |
crypto | string | Only payments in this coin. |
order_id | string | Only 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"
}| Field | Type | Description |
|---|---|---|
price | string | What you asked for. |
amount | string | What the customer pays: the price, plus Interpay's fee when your settings add it. Null until the method is locked. |
merchant_amount | string | What you keep after Interpay's fee, before any Stripe fee. |
platform_fee | string | Interpay'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_billed | boolean | true 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_fee | string | Stripe's own fee, in provider_fee_currency, once Stripe reports it. Crypto has none: the customer pays the network fee. |
crypto.merchant_wallet | string | Your own address, as it was when the payment was priced. Changing your wallet later doesn't move an open payment. |
crypto.fee_billed | boolean | Same 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_wallet | string | Interpay'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_uris | object | Bitcoin 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.ethereum | object | chain_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_confirmations | number | From your settings. Defaults: Bitcoin 1 or 3, Litecoin 1 or 3, Ethereum 12 or 64, the larger count from 50.00 up. |
crypto.transactions | array | Every 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_id | string | Your own Stripe account the charge was made on (acct_…). |
card.application_fee | string | Interpay's fee as Stripe's application fee on that charge: 0.00 when fee_billed is true, since the fee is billed instead. |
card.settlement | object | How the charge settled on your Stripe balance: currency, gross, net and the exchange rate Stripe used. |
refunds | array | Each 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_at | string | End 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.overpaidand 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_reasonsays 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.
| Status | Field | Description |
|---|---|---|
pending | status | Placed; waiting for the payment. |
paid | status | Paid in full. Digital items are delivered; anything else waits for you. |
processing | status | You started working on it. |
completed | status | Fulfilled. Final, unless refunded. |
canceled | status | Given up or canceled; stock went back. |
refunded | status | Money sent back to the customer. |
underpaid, overpaid | payment_status | The 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. |
disputed | payment_status | A 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/:idLive endpoints must use https. Test endpoints may also use http, so you can point them at your own machine while developing.
| Event | Description | |
|---|---|---|
payment.created | A payment was created, before the customer picked how to pay. | |
payment.pending | The method is locked: the coin, rate and amounts, or Stripe Checkout. | |
payment.detected | Part of the amount arrived. | |
payment.confirming | Everything due arrived and is waiting for confirmations. | |
payment.confirmed | Final on the provider's side. | |
payment.completed | Done; the money is yours. This is the one to fulfil orders on. | |
payment.underpaid | Closed with less than due. | |
payment.overpaid | More than due arrived; the excess waits for your decision. | |
payment.expired | Closed with nothing paid. | |
payment.failed | The provider refused it; failure_reason says why. | |
payment.cancelled | You cancelled the payment. | |
payment.late_payment | Money arrived after the payment closed. Never counted until you decide. | |
payment.refunded | A refund went through: through Stripe, or your own transaction confirmed on the chain. | |
payment.disputed | A card payment was disputed. Stripe holds the amount until it's decided. | |
order.created | A customer placed an order in your storefront or through a payment link. data is the order object. | |
order.paid | The order's payment is complete. | |
order.fulfilled | You marked the order fulfilled. | |
order.completed | The order is done. Sent right after order.fulfilled; digital orders get both once they're delivered. | |
order.canceled | The order was canceled or its payment given up. | |
order.refunded | Money for the order was sent back. | |
test.ping | Sent 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\"."}}| Code | Status | Description |
|---|---|---|
invalid_api_key | 401 | Missing, unknown or revoked key. |
invalid_json | 400 | The body isn't valid JSON. |
invalid_request | 400 | A field is missing or has the wrong type; the message names it. |
invalid_amount | 400 | amount isn't a decimal with at most 2 places. |
amount_out_of_range | 400 | amount is below 1.00 or above 1,000,000.00. |
amount_too_small | 422 | The amount is too small to cover Interpay's fee. |
invalid_url | 400 | Webhook URL isn't allowed (see Webhooks). |
too_many_endpoints | 400 | Up to 10 webhook endpoints per mode. |
not_found | 404 | No such object in this key's mode. |
not_open | 409 | The payment has already closed. |
money_received | 409 | It can't be cancelled: money has already been seen. |
method_locked | 409 | The customer already picked how to pay. |
rate_unavailable | 503 | The exchange rate couldn't be fetched. Retry shortly with the same Idempotency-Key. |
chain_unavailable | 503 | That chain couldn't be reached. Retry shortly with the same Idempotency-Key. |
method_unavailable | 422 | That 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_unavailable | 422 | Cards can't be used: not accepted, no connected Stripe account that can take charges, or below Stripe's minimum for the currency. |
wallet_missing | 422 | You haven't set a wallet address for that chain yet. |
account_closed | 403 | The business has closed its Interpay account. |
rate_limited | 429 | Over 300 requests a minute for this key. Wait for the Retry-After seconds. |
mode_unavailable | 503 | This 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
| Selector | Description | |
|---|---|---|
.store | The whole storefront. Carries the theme variables and layout attributes below. | |
.store-announcement | Announcement bar | |
.store-header | Header; .store-logo, .store-nav-link and .store-search inside it | |
.store-section | Every 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-head | A section's heading block: .store-eyebrow, .store-heading, .store-text | |
.store-hero | Hero: .store-hero-heading, .store-hero-text, .store-hero-media | |
.store-product-grid | Product grids | |
.store-product | A product card: .store-product-name, .store-price, .store-badge | |
.store-product-view | Product page: .store-gallery, .store-buy-box, .store-add-to-cart, .store-buy-now | |
.store-collection | A collection tile | |
.store-features | Highlights: .store-feature, .store-feature-icon, .store-feature-title | |
.store-testimonial | A testimonial: .store-testimonial-author, .store-testimonial-role | |
.store-faq-item | One FAQ question and answer | |
.store-prose | Formatted text from rich text settings and pages | |
.store-button | Buttons; .store-link for text links | |
.store-cart-drawer | The cart that slides in; .store-cart for the cart page | |
.store-footer | Footer: .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.
| Variable | Description | |
|---|---|---|
--s-bg | Page background | |
--s-surface | Cards, panels, fields | |
--s-surface-2 | A step darker or lighter than the surface | |
--s-text | Text | |
--s-muted | Muted text | |
--s-border | Borders; --s-border-strong for a firmer line | |
--s-primary | Primary; --s-primary-hover, --s-on-primary for text on it | |
--s-secondary | Secondary; --s-on-secondary for text on it | |
--s-accent | Accent; --s-on-accent for text on it | |
--s-success | Success | |
--s-font-heading | Heading font stack | |
--s-font-body | Body font stack | |
--s-radius | Corner radius; --s-radius-button, --s-radius-image | |
--s-max | Content width | |
--s-gutter | Space at the page edges | |
--s-gap | Space between sections |
Layout attributes
Your layout choices, as attributes on .store, e.g. .store[data-buttons="pill"].
| Attribute | Description | |
|---|---|---|
data-theme | linen, market, noir, spotlight, vault | |
data-scheme | light, dark (from the background color) | |
data-header | inline, centered, split | |
data-cards | plain, framed, overlay | |
data-ratio | square, portrait, landscape | |
data-buttons | solid, outline, pill | |
data-density | comfortable, compact | |
data-product-layout | split, 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.