How WentPay works
WentPay adds a "Pay with WENT" checkout to your store. Your customer signs in, picks one of their own crypto wallets and approves a single transfer in it. The money goes straight from the customer's wallet to your payout address, in one on-chain transfer.
WentPay never holds your money or anyone's keys. It prepares the transfer (the amount and your address), then watches the blockchain and tells you when the payment is final. Nobody types an address or picks a network by hand, so payments can't go to the wrong place.
- 1Your server creates a payment with the API and gets a
checkout_url. - 2You send the customer there (a redirect or a popup).
- 3The customer signs in with their email, links a wallet the first time, and approves the transfer.
- 4WentPay verifies the transfer on chain and sends your server a signed
payment_intent.succeededwebhook. - 5You fulfil the order.
Quick start
You can take your first payment in about ten minutes:
- 1Verify your website's domain with a DNS record. Payments need it.
- 2Add a payout address for each network you want to accept. This is where payments land; your first three addresses work right away.
- 3Get your secret key from API keys.
- 4Add a webhook endpoint on your server and verify its signatures.
- 5Create a payment from your server, then show the Pay with WentPay button or send the customer to its
checkout_url. - 6Fulfil the order when the
payment_intent.succeededwebhook arrives.
Selling a fixed amount and no server of your own? Skip the API: Create link gives you a link to share and a button to paste into your site (see Payment links).
Want to see it from the customer's side first? The demo store runs the whole checkout with no account, wallet or money involved.
Checking your integration
There is one mode: every payment is real USDT or USDC on the mainnets. To check your integration end to end, pay a small order yourself, a dollar is enough, from a wallet of your own:
- the customer is sent to the checkout and comes back to your
return_url, - your webhook endpoint receives
payment_intent.succeededand the signature checks out, - the order is fulfilled once, even if the event arrives twice.
The money lands in your own payout address; send it back to your wallet if you like. The customer pays the network fee, so keep a little of the network's coin (TRX, BNB, POL or ETH) in the paying wallet. Send test event on a webhook endpoint also delivers a sample event without any payment.
Verify your domain
Customers see your website's domain in the checkout, so WentPay checks it's really yours: under Settings, the Domain card shows a DNS record to add where your domain is managed (your registrar, Cloudflare, Shopify…):
| Type | Name | Value |
|---|---|---|
| TXT | _wentpay.yourstore.com | wentpay-verify=… |
- Then choose Check and verify and enter your authenticator code. New records can take a few minutes to show up.
- Until the domain is verified, creating a payment answers
403with the codedomain_unverified. - A payment's
return_urlmust be on the verified domain or one of its subdomains (orlocalhostwhile you develop). - Changing your website to another domain in Settings means verifying the new one before payments resume. Keep the record in place.
Payout addresses
A payout address is the wallet that receives your payments on one network. Add one per network in Payout addresses and choose which stablecoins it accepts there. Customers only see networks you have an address for.
- Use a wallet you control. WentPay can't move, recover or reverse money sent to an address. Addresses are checked for typos (checksums) before they're saved.
- One regular EVM wallet address can serve BNB Chain, Polygon and Base; Tron needs its own (starts with T).
- Every change needs the code from your authenticator app.
- Your account's first three addresses work right away. Changing an address, or adding one after those, only starts receiving payments after 24 hours, and the account owner gets an email. If you didn't make the change, cancel it from the banner and change your password.
- Each payment keeps the address that was live when it was created, so changing an address never redirects a payment that's already open.
- You can't create payments until at least one address is live.
API keys
Your server talks to WentPay with a secret key (sk_live_…), from API keys. Send it on every request, either as a bearer token or as the username of HTTP Basic auth:
Authorization: Bearer sk_live_… # or, with curl:curl -u sk_live_…: https://api.wentpay.net/v1/payment_intents- Keep it on your server. Never put it in browser or app code, or in a public repository.
- A secret key is shown only once, when it's created: roll the key to get one to copy, and store it in your server's secret settings.
- If a key leaks, roll it: you get a new key at once and choose when the old one stops working (immediately, or after 1 hour, 24 hours or 7 days, to give you time to deploy). Rolling takes your authenticator code, and we email you when it happens.
- The Pay with WentPay button and payment links need no key in the browser.
Create a payment
When the customer is ready to pay, your server creates a payment intent: POST https://api.wentpay.net/v1/payment_intents. Request bodies are JSON (Content-Type: application/json).
| Field | Required | Description |
|---|---|---|
amount | Yes | Amount in US dollars as a string with up to 2 decimals, e.g. "49.00". 1 USD is paid as 1 USDT or 1 USDC. |
currency | No | Always "USD" (the default). |
order_id | Yes | Your own order reference (up to 100 characters). It comes back in every response and webhook. |
return_url | No | Where the customer goes after the checkout: an https URL on your verified domain or a subdomain, or a path like /thanks. http:// is upgraded to https://. Defaults to your website from Settings. For the popup flow it must be on the site that opens the popup. |
metadata | No | Your own key-value strings (values up to 500 characters), returned as-is. |
curl https://api.wentpay.net/v1/payment_intents \ -u sk_live_…: \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042" \ -d '{ "amount": "49.00", "currency": "USD", "order_id": "1042", "return_url": "https://shop.example/orders/1042", "metadata": { "customer_id": "cus_381" } }'// On your server. Never put the secret key in browser code.const res = await fetch("https://api.wentpay.net/v1/payment_intents", { method: "POST", headers: { Authorization: `Bearer ${process.env.WENTPAY_SECRET_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `order-${order.id}`, }, body: JSON.stringify({ amount: order.total, // "49.00" currency: "USD", order_id: String(order.id), return_url: `https://shop.example/orders/${order.id}`, }),});const intent = await res.json();if (!res.ok) throw new Error(intent.error.message); // Store intent.id with the order, then send the customer to intent.checkout_url.The response is the payment intent (201 Created):
{ "id": "pi_rF8N9TFGxZ1a4Rs6RAWVkEW8", "object": "payment_intent", "mode": "live", "amount": "49.00", "currency": "USD", "order_id": "1042", "metadata": { "customer_id": "cus_381" }, "status": "requires_payment", "network": null, "asset": null, "tx_hash": null, "checkout_url": "https://wentpay.net/c/pi_rF8N9TFGxZ1a4Rs6RAWVkEW8", "expires_at": "2026-10-10T01:05:10.075Z", "created_at": "2026-10-10T00:35:10.075Z", "succeeded_at": null}Idempotency
Send an Idempotency-Key header (any unique string up to 200 characters, such as your order id) so a retried request doesn't create a second payment. Repeating the same key with the same body returns the first payment; the same key with a different body is refused with 409.
Expiry
A payment can be paid until expires_at: 30 minutes by default, or 15 or 60 minutes, set under Settings. After that it becomes expired; create a new one if the customer comes back. A customer who was already approving the transfer in their wallet gets up to 15 more minutes: the payment stays requires_payment until then, and can still become processing and succeeded. For the same reason a payment the customer started paying in the last 15 minutes can't be canceled (409).
Errors
Errors use standard HTTP status codes and always have the same body:
{ "error": { "code": "invalid_request", "message": "Use a decimal amount like 49.00.", "field": "amount" }}| Status | Meaning |
|---|---|
| 400 | The request is invalid; field names the bad field when there is one. |
| 401 | The secret key is missing, wrong, revoked or expired. |
| 403 | Your domain isn't verified yet (domain_unverified): see Verify your domain. Or WentPay suspended the account (account_suspended): the dashboard says why. |
| 404 | No such payment in this mode. |
| 409 | Conflicts with the current state, e.g. canceling a paid payment or reusing an Idempotency-Key. |
| 415 | The body isn't JSON: send Content-Type: application/json. |
Send the customer to checkout
Every payment has a checkout_url: WentPay's hosted checkout for that payment. The customer never sees or types your address, and never picks a network: WentPay shows the wallets they've linked that can pay, with their balance and the network fee.
Option 1: redirect (simplest)
Redirect the customer to checkout_url. When they finish, they come back to your return_url with ?wentpay=success or ?wentpay=cancel.
// Express: create the payment, then send the customer to WentPay.app.post("/checkout", async (req, res) => { const intent = await createWentPayPayment(req.order); // the call above res.redirect(303, intent.checkout_url);}); // The customer comes back to your return_url:// https://shop.example/orders/1042?wentpay=success (paid, and they pressed Done)// https://shop.example/orders/1042?wentpay=cancel (closed the checkout without paying)// Show a matching message, but fulfil the order on the webhook, not on this redirect.Option 2: the Pay with WentPay button
Put the button on your page: the checkout opens in a small window over it, and your page hears the result.
?wentpay=success. Only the signed payment_intent.succeeded webhook (or fetching the payment from the API) proves it's paid.Payment links
For a fixed amount you don't need any server code. In Create link, enter what it's for and the amount. You get:
- a link (
https://wentpay.net/l/pl_…) to share anywhere: it shows your store, what it's for and the amount, and pressing Pay opens the checkout; - a button for your site: paste these two lines, nothing else.
<script src="https://wentpay.net/js/v1/wentpay.js"></script><div data-wentpay-link="pl_…"></div>- Each press of Pay creates a new payment for the link's amount. Its
metadatahaspayment_link(the link's id) andpayment_link_name, and itsorder_idstarts with the link's id, so your webhook can tell what was bought. - Opening the link creates nothing: chat apps that preview links don't leave empty payments behind.
- Turn a link off to stop it taking payments; it can be turned back on.
- Links take payments once your domain is verified, like everything else.
Payment statuses
| Status | Meaning | Webhook |
|---|---|---|
requires_payment | Created; waiting for the customer to pay. | — |
processing | The customer sent the transfer; WentPay is waiting for it to be final on the chain. | payment_intent.processing |
succeeded | Verified on chain and final. Fulfil the order. | payment_intent.succeeded |
failed | The transfer reverted, went somewhere else, was too small, or never appeared on chain. | payment_intent.failed |
expired | Not paid before expires_at (plus 15 minutes if the customer was already approving it). | payment_intent.expired |
canceled | You canceled it with the API before it was paid. | — |
A payment only becomes succeeded when WentPay has checked on chain that:
- the transfer went to the payout address saved on the payment,
- in the expected token (USDT or USDC) on the expected network,
- from the wallet the customer chose, for at least the payment amount,
- and it didn't revert and has reached finality (see Networks and fees).
Webhooks
Webhooks tell your server about payments as they happen. Add an endpoint (an https:// URL on your server) in Webhooks, choose its events, and keep its signing secret (whsec_…) in your server settings. Each mode has its own endpoints.
Every event is a JSON POST with the payment intent in data:
POST https://shop.example/webhooks/wentpayContent-Type: application/jsonUser-Agent: WentPay-Webhooks/1.0WentPay-Signature: t=1791600000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd { "id": "evt_9mQ2vB7xK1pL4nR8sT3wY6zA", "object": "event", "type": "payment_intent.succeeded", "created": 1791600000, "livemode": false, "data": { "id": "pi_rF8N9TFGxZ1a4Rs6RAWVkEW8", "object": "payment_intent", "status": "succeeded", "amount": "49.00", "order_id": "1042", "network": "tron", "asset": "USDT", "tx_hash": "5c0d…e91a", "succeeded_at": "2026-10-10T00:41:22.000Z", "…": "the rest of the payment intent" }}Verify the signature
The WentPay-Signature header is t=<unix time>,v1=<HMAC-SHA256 of "t.body">, signed with the endpoint's secret. Check it on the raw request body before you trust the event, and reject events older than 5 minutes:
import crypto from "node:crypto";import express from "express"; // WentPay-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>function verifyWentPaySignature(rawBody, header, secret) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); // Reject events older than 5 minutes so a captured request can't be replayed. if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest(); const given = Buffer.from(parts.v1 ?? "", "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected);} const app = express(); // Use the raw body: re-serialized JSON won't match the signature.app.post("/webhooks/wentpay", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("WentPay-Signature") ?? ""; if (!verifyWentPaySignature(req.body, header, process.env.WENTPAY_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body); // "Send test event" in the dashboard: a made-up payment, never an order to fulfil. if (event.test_event) return res.sendStatus(200); // Events can arrive more than once: skip ids you've already handled. if (event.type === "payment_intent.succeeded") fulfilOrder(event.data.order_id); res.sendStatus(200);});Respond quickly, expect retries
- Answer with any
2xxstatus within 10 seconds; do slow work afterwards. Redirects count as failures. - Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and then daily, for up to 3 days. Every attempt is listed in the delivery log, where you can also resend one.
- An event can arrive more than once: remember the event
ids (or order ids) you've handled and skip repeats. - Send test event in the endpoint's menu delivers a sample
payment_intent.succeededmarked"test_event": true, for a made-up payment (pi_test_sample, ordertest_order). Don't fulfil anything for it. It's sent once and not retried. - Adding, turning off or deleting an endpoint takes your authenticator code, and we email you when it happens.
API reference
Base URL: https://api.wentpay.net. All endpoints take your secret key; the key's mode decides which payments you see.
| Endpoint | Description |
|---|---|
POST /v1/payment_intents | Create a payment (see Create a payment). |
GET /v1/payment_intents/:id | Get one payment, e.g. to check its status. |
GET /v1/payment_intents | List payments, newest first. limit 1–100 (default 10); pass the last id as starting_after for the next page while has_more is true. |
POST /v1/payment_intents/:id/cancel | Cancel a payment that's still requires_payment. |
curl https://api.wentpay.net/v1/payment_intents/pi_rF8N9TFGxZ1a4Rs6RAWVkEW8 -u sk_live_…: curl "https://api.wentpay.net/v1/payment_intents?limit=20" -u sk_live_…: curl -X POST https://api.wentpay.net/v1/payment_intents/pi_rF8N9TFGxZ1a4Rs6RAWVkEW8/cancel -u sk_live_…:Networks and fees
WentPay accepts USDT and USDC at 1 USD = 1 token, so there are no exchange rates. The customer pays the network fee from their own wallet, in the network's coin; the checkout shows the estimate first and warns when the wallet doesn't hold enough.
| Network | Live | Fee coin | Final after |
|---|---|---|---|
| Tron | USDT | TRX | Solidified block (about 1 minute) |
| BNB Chain | USDTUSDC | BNB | Finalized block |
| Polygon | USDTUSDC | POL | Finalized block |
| Base | USDC | ETH | 10 confirmations |
WentPay never takes a cut out of the transfer: the full amount reaches your address.
Refunds
Because WentPay never holds funds, refunds are sent by you, from your payout wallet, to the customer. Open the payment in Payments to see the wallet it was paid from and the network and token used, and send the refund there on the same network.
Before you launch
Before you show the button to customers, check that you:
- verified your website's domain,
- added a payout address for every network you want to accept (any added after the first three waits 24 hours),
- added a webhook endpoint and verify its signatures,
- fulfil orders only on payment_intent.succeeded (or after fetching the payment), and skip repeated events,
- paid one small order yourself end to end (see Checking your integration),
- keep the secret key on your server only,
- use a strong, unique password for your WentPay account and keep your authenticator app safe.
Questions? Email [email protected].