Add crypto payments to your site

1 minute. No backend required.

01Create your free account
Sign up to get your keys. Takes 30 seconds.

You get two keys, and the difference matters:
pk_live_… (publishable) — goes in your website's button, below. Public by design: all it can do is create a payment that pays into your wallets.
sk_live_… (secret) — logs into your dashboard and can change your payout wallets. Never put it in a web page, an app, or anything a visitor can view the source of. Server-side only.

Create account
02Paste this on your website
Copy the code below. Replace YOUR_PUBLISHABLE_KEY with your pk_live_… key — it's in your dashboard → Settings. Not the sk_live_ one: this code is visible to every visitor.
<!-- PlatypusPay --> <script src="https://platypuspay.xyz/js/platypuspay.js"></script> <button data-platypuspay data-api-key="YOUR_PUBLISHABLE_KEY" data-amount="29.99" data-currency="XMR" data-reference="order_123"> Pay with Crypto </button>

data-amount — price in whatever unit you fixed: crypto (0.5 XMR, 0.001 BTC) if data-currency is set, or fiat if data-fiat-currency is set instead
data-currency — XMR, BTC, LTC, ETH, SOL, or ZEC. Fixes the crypto — no picker shown.
data-reference — your order ID (optional)
data-server — your PlatypusPay URL (default: https://platypuspay.xyz)

The amount lives in the page, so a determined visitor can edit it before clicking — that's true of any browser-side button, ours included. Before you ship an order, check the amount you were actually paid: the payment.confirmed webhook in step 05 carries received_amount, and that number comes from the chain, not from the browser. For anything where being underpaid would hurt, create the payment from your server (step 04) instead.

03Optional: let the customer pick their crypto
Drop data-currency and set a fiat price instead. The customer chooses a crypto on the checkout page — only from the ones you've configured a payout address for in your dashboard settings.
<button data-platypuspay data-api-key="YOUR_PUBLISHABLE_KEY" data-amount="29.99" data-fiat-currency="USD" data-reference="order_123"> Pay with Crypto </button>

One of data-currency or data-fiat-currency is required — a bare amount with neither is ambiguous (29.99 what?), so the button refuses to run and logs an error to the console instead.

04Selling physical goods or have a cart? Call the API from your backend
The button widget above covers a fixed price, or "let the customer pick their crypto" for a fixed amount. For a real shopping cart — a total that changes depending on what's in it, collecting a shipping address, or sending the customer back to your own thank-you/cancel pages — call POST /payments directly from your own server instead of using the widget. Compute the total from the cart yourself, then create the payment right before redirecting the customer.
// From your own backend (Node, Python, PHP, whatever) — never the browser POST https://platypuspay.xyz/payments x-api-key: YOUR_SECRET_KEY // sk_live_… — server-side only { "amount": 84.97, // computed from the cart's contents "fiat_currency": "EUR", "merchant_reference": "2x Widget, 1x Gadget", "success_url": "https://yoursite.com/success", "cancel_url": "https://yoursite.com/cart", "collect_info": true } // Response: { "id": "...", "checkout_url": "/checkout/abc123", ... } // checkout_url is a path — prefix it with https://platypuspay.xyz and // redirect your customer there.

amount — same rule as data-amount: a fixed crypto amount if you set currency, otherwise a fiat amount (requires fiat_currency). Recompute this from the cart on your server — never trust a total sent by the browser.
success_url / cancel_url — where the checkout page sends the customer once payment is confirmed on-chain, or if it expires. Not available as data-attributes on the widget — this is why a real cart needs the API, not just the button.
collect_info: true — adds a name/email/phone/address form to the checkout page before the payment address is shown, saved against the payment and visible in your dashboard. Turn this on if you're shipping something physical.
merchant_reference — anything that helps you match the payment back to an order later: an order ID, or a short summary of what's in the cart.
How long the link lives — set currency and the payment window starts immediately: 30 minutes, 45 for Bitcoin. Omit it (customer picks their coin at checkout) and the link stays valid for a week, with the payment window starting only when they choose. That second form is what to use for a link you email or print on an invoice — no address exists until the customer picks, and your price stays fixed in fiat and converts at the rate of the moment they pay.

05Get notified server-side when a payment confirms — webhooks
Set a Webhook URL and PlatypusPay POSTs a signed payload to it the instant a payment confirms on-chain — this is what should trigger your own order fulfillment (print a receipt, send a confirmation email, update your database), not success_url: that one only fires while the customer's browser is still open on the checkout page, so it can be missed if they close the tab.
POST https://yoursite.com/your-webhook-endpoint X-PlatypusPay-Event: payment.confirmed PlatypusPay-Signature: t=1721650000,v1=<hex hmac> { "event": "payment.confirmed", "payment_id": "9c1e...", "status": "confirmed", "currency": "SOL", "expected_amount": "0.5432", "received_amount": "0.5432", "fee_amount": "0.0027", "tx_hash": "...", "merchant_reference": "2x Widget, 1x Gadget", "confirmed_at": "2026-07-22T10:00:00Z" }
// Verify before trusting the payload (Node example) const crypto = require('crypto'); function verify(secret, timestamp, rawBody, signature) { const expected = crypto.createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`).digest('hex'); return expected === signature; // + reject if timestamp is too old }

Set your Webhook URL under your dashboard → Settings → "Webhook URL". Your signing secret is just below it, under "webhook signing".
PlatypusPay-Signature is t=<timestamp>,v1=<hex> — an HMAC-SHA256 over "{timestamp}.{raw body}" using your webhook secret. Reject the request if the signature doesn't match, or if the timestamp is too old (replay protection) — don't just check the header is present.
Three events exist: payment.confirmed (status: "confirmed") fires once, when the full amount arrives on-chain within the payment window — this is the one to fulfill orders on. payment.partial (status: "partial") fires if a payment expires having received only part of the amount: the partial funds are still forwarded to you, but do NOT treat it as paid in full — check received_amount against expected_amount before shipping anything. payment.late (status: "late") fires when the full amount arrives, but after the window closed — a slow Bitcoin confirmation, an exchange holding a withdrawal, or a swap service freezing a transfer will all do it, and we keep watching for a week. Those funds are forwarded to you like any other, but the order is deliberately not confirmed: the crypto amount was priced when the invoice was created, so a late payment can be worth noticeably less than the order. Decide whether to ship it. Always branch on the event/status field, never assume every webhook means "confirmed". None of them fire on payment creation.

06Read your payments back from your own code
Webhooks push, this pulls. Everything the dashboard shows you is available over the API with the same secret key, so you can put an orders page in your own back-office, reconcile a month's takings from a script, or check a single payment's status on demand.
// Your payments, newest first GET https://platypuspay.xyz/payments?status=confirmed&limit=50 x-api-key: YOUR_SECRET_KEY // sk_live_… — server-side only { "data": [ { "id": "9c1e…", "status": "confirmed", "currency": "XMR", "expected_amount": "0.5432", "received_amount": "0.5432", "fee_amount": "0.0013", "requested_amount": "84.97", // what you priced the order at "requested_fiat_currency": "EUR", "received_fiat_amount": "84.91", // its value when it landed "tx_hash": "…", "merchant_reference": "2x Widget, 1x Gadget", "created_at": "2026-07-22T09:58:11Z", "confirmed_at": "2026-07-22T10:00:00Z", "expires_at": "2026-07-22T10:28:11Z" } ], "has_more": true } // Next page: pass the last id you got back GET /payments?limit=50&starting_after=9c1e… // One payment, same shape GET /payments/9c1e… // Cancel an invoice nobody paid DELETE /payments/9c1e… // Your account: keys, payout addresses, webhook URL GET /merchants/me

status — filter on one of pending, confirmed, expired, partial, late. Omit it and you get everything.
limit — up to 100, 25 by default. When has_more is true, ask for the next page with starting_after set to the last id of the page you just read. Paging this way stays correct while new payments keep arriving at the top of the list.
requested_amount / received_fiat_amount — what the order was priced at, and what the crypto was worth at the moment it landed. Both are what you want for bookkeeping; they are null on invoices you priced directly in crypto.
DELETE /payments/{id} closes an invoice early — a cart the customer walked away from, an order cancelled by hand. It returns the payment with status: "expired", and returns 409 instead if any money has already arrived on it, since those funds are on their way to your wallet. A closed invoice keeps being watched for a week like any other, so a customer who pays late still reaches you through payment.late rather than disappearing.
GET /merchants/me — your account as it stands: name, email, publishable key, payout addresses, accepted_currencies (the currencies your customers are actually offered at checkout, derived from the addresses you configured), webhook URL. Use PUT /merchants/me with any of the same fields to change them.
All of these take your secret key. The publishable key creates payments and nothing else — it never reads your history.

07Or take donations instead
Two ways to do this. If you have a website, same script, one extra attribute: data-donation="true" turns the button into an open form — the visitor sets their own amount, name, and an optional message.
<script src="https://platypuspay.xyz/js/platypuspay.js"></script> <button data-platypuspay data-donation="true" data-api-key="YOUR_PUBLISHABLE_KEY" data-fiat-currency="USD"> Donate </button>

No website? Every account also gets a hosted donation page — no HTML needed, just a link. There are actually two, same form, different wording: a neutral donation link for anywhere (social bio, website, email signature) under your dashboard → Settings → "Donations", and a stream donation link that tells donors their name is shown live, under Settings → "Stream alerts" — use that one for your Twitch/YouTube bio instead (see step 08).

08Show donations live on Twitch/YouTube
Add your overlay URL as a Browser Source in OBS (or Streamlabs/StreamElements). It's a transparent page that pops up the donor's name, amount, and message the moment a donation confirms on-chain — no polling, no refresh.
// OBS → Sources → + → Browser Source URL: https://platypuspay.xyz/overlay/YOUR_MERCHANT_ID Width: 600 Height: 200 // leave "Shutdown source when not visible" unchecked

Your ready-to-paste overlay URL is on your dashboard → Settings → "Stream alerts" (your donation link itself is under "Donations", just above it) — no need to build either URL by hand. Confirmation time depends on the coin — SOL or ZEC for near-instant alerts, BTC if you don't mind a few minutes' delay.

That's it. You're accepting crypto.

Customer clicks the button, picks a crypto (if you didn't fix one), sees checkout with QR code, pays, you receive.

Don't have an account yet?

Create free account