Documentation

Everything you need to accept USDT and USDC with WentPay: setup, the API, the checkout, webhooks and going live.

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.

  1. 1Your server creates a payment with the API and gets a checkout_url.
  2. 2You send the customer there (a redirect or a popup).
  3. 3The customer signs in with their email, links a wallet the first time, and approves the transfer.
  4. 4WentPay verifies the transfer on chain and sends your server a signed payment_intent.succeeded webhook.
  5. 5You fulfil the order.

Quick start

You can take your first payment in about ten minutes:

  1. 1Verify your website's domain with a DNS record. Payments need it.
  2. 2Add a payout address for each network you want to accept. This is where payments land; your first three addresses work right away.
  3. 3Get your secret key from API keys.
  4. 4Add a webhook endpoint on your server and verify its signatures.
  5. 5Create a payment from your server, then show the Pay with WentPay button or send the customer to its checkout_url.
  6. 6Fulfil the order when the payment_intent.succeeded webhook 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.succeeded and 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…):

TypeNameValue
TXT_wentpay.yourstore.comwentpay-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 403 with the code domain_unverified.
  • A payment's return_url must be on the verified domain or one of its subdomains (or localhost while 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:

Authentication
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).

FieldRequiredDescription
amountYesAmount 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.
currencyNoAlways "USD" (the default).
order_idYesYour own order reference (up to 100 characters). It comes back in every response and webhook.
return_urlNoWhere 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.
metadataNoYour own key-value strings (values up to 500 characters), returned as-is.
curl
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" }  }'
Node.js
// 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):

Response
{  "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
{  "error": {    "code": "invalid_request",    "message": "Use a decimal amount like 49.00.",    "field": "amount"  }}
StatusMeaning
400The request is invalid; field names the bad field when there is one.
401The secret key is missing, wrong, revoked or expired.
403Your domain isn't verified yet (domain_unverified): see Verify your domain. Or WentPay suspended the account (account_suspended): the dashboard says why.
404No such payment in this mode.
409Conflicts with the current state, e.g. canceling a paid payment or reusing an Idempotency-Key.
415The 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.

Node.js (Express)
// 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.

Fulfil orders on the webhook, not in the browser. The redirect and the popup message are for showing the customer a result; anyone can open a URL with ?wentpay=success. Only the signed payment_intent.succeeded webhook (or fetching the payment from the API) proves it's paid.

Pay with WentPay button

wentpay.js draws the same lime Pay with WENT button as the demo store on your page, opens the checkout in a popup (a full-page redirect on phones, or when popups are blocked), and tells your page how it went. Add two lines where the button goes:

Your checkout page
<script src="https://wentpay.net/js/v1/wentpay.js"></script><div id="wentpay-button"></div> <script>  WentPay.button("#wentpay-button", {    // Your server creates the payment with its secret key and answers with it (or just its id).    createPayment: () => fetch("/create-payment", { method: "POST" }).then((r) => r.json()),    onSuccess: (payment) => showThankYou(payment), // display only: fulfil on the webhook    onCancel: () => {},    onError: (error) => alert(error.message),  });</script>

The amount can't come from the browser (a customer could change it), so your server creates the payment with the secret key. The button calls it when pressed and shows Preparing… meanwhile:

Node.js (Express)
// Express: the endpoint the button calls. The amount comes from your order, never from the browser.app.post("/create-payment", async (req, res) => {  const intent = await createWentPayPayment(req.order); // POST /v1/payment_intents, as above  res.json({ id: intent.id });});
Payment created ahead
// Already created the payment when the page was built? Pass its id instead.WentPay.button("#wentpay-button", { paymentId: "pi_rF8N9TFGxZ1a4Rs6RAWVkEW8", onSuccess: showThankYou });

No server? Use a payment link: its button needs no code of your own.

OptionWhat it does
createPaymentCalled on click; resolve with your API response, { id } or the id itself.
paymentIdA payment your server already created.
linkA payment link's id (pl_…).
onSuccess(payment)The payment is confirmed on chain. Show a thank-you; fulfil on the webhook.
onCancel()The customer closed the checkout without paying.
onError(error)createPayment failed, or the options are wrong.
The checkout's message (onSuccess gets its payment)
{  "type": "wentpay:success",  "payment": {    "intentId": "pi_rF8N9TFGxZ1a4Rs6RAWVkEW8",    "orderId": "1042",    "network": "tron",    "asset": "USDT",    "amount": "49.00",    "txHash": "5c0d…e91a"  }}
  • The result goes to the origin of the payment's return_url: put the button on that site (your verified domain).
  • Don't send a Cross-Origin-Opener-Policy: same-origin header on that page: it cuts the popup off from your page.
  • The button lives in its own shadow DOM, so your CSS can't break it; size it with the element you put it in. WentPay.checkout(options) opens the checkout from a button of your own.

Payment statuses

StatusMeaningWebhook
requires_paymentCreated; waiting for the customer to pay.—
processingThe customer sent the transfer; WentPay is waiting for it to be final on the chain.payment_intent.processing
succeededVerified on chain and final. Fulfil the order.payment_intent.succeeded
failedThe transfer reverted, went somewhere else, was too small, or never appeared on chain.payment_intent.failed
expiredNot paid before expires_at (plus 15 minutes if the customer was already approving it).payment_intent.expired
canceledYou 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:

Webhook request
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:

Node.js (Express)
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 2xx status 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.succeeded marked "test_event": true, for a made-up payment (pi_test_sample, order test_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.

EndpointDescription
POST /v1/payment_intentsCreate a payment (see Create a payment).
GET /v1/payment_intents/:idGet one payment, e.g. to check its status.
GET /v1/payment_intentsList 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/cancelCancel a payment that's still requires_payment.
curl
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.

NetworkLiveFee coinFinal after
TronUSDTTRXSolidified block (about 1 minute)
BNB ChainUSDTUSDCBNBFinalized block
PolygonUSDTUSDCPOLFinalized block
BaseUSDCETH10 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].