Wolf Pay API (v1)
Create stablecoin payments from your server, send customers to a hosted checkout, and get signed webhooks when payments settle.
Base URL: https://wolf-pay-five.vercel.app/api/v1
Status: test mode is open. Live mode is built and opens once our custodian (Bridge) goes live; until then live keys get live_mode_unavailable.
Pricing
1% per payment, at least 3.00, never more than the payment received (a
2.00 payment costs 2.00). The minimum applies once per payment, even if the
customer pays in parts. No setup or monthly fees. Fees are kept in your
balance as fees_owed and settled when you pay out. Refunds don't return the fee.
Authentication
Every request needs a secret key from Dashboard → Developers:
Authorization: Bearer wp_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXwp_test_…keys create test payments. No real money moves.wp_live_…keys create real payments. They need a business verified in Dashboard → Settings; until then (and until live mode opens) they getlive_mode_unavailable.- Keep keys on your server. Never put them in a browser, an app, or a public repo.
- The API is server-to-server: no CORS headers are sent.
Wolf Pay stores only a SHA-256 hash of each key, so a lost key can't be shown again. Revoke it and create a new one.
Requests and responses
- Send JSON with
Content-Type: application/json. - Amounts are decimal strings (
"25.50") with up to 6 decimals, so nothing is lost to floating-point rounding. Plain numbers are accepted too. - Responses are JSON. Every response has a
Wolf-Request-Idheader. Include it when you contact support. - Timestamps are ISO 8601 in UTC.
Errors
{ "error": { "type": "invalid_request", "message": "amount: Use a positive decimal…", "param": "amount" } }type | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | A parameter is missing or wrong. param names it. |
invalid_api_key | 401 | Missing, malformed, revoked, or unknown key. |
live_mode_unavailable | 403 | Live payments aren't available for this business yet (verification pending), or not on this coin and network. |
not_found | 404 | No such payment for this key's business and mode, or an unknown endpoint. |
method_not_allowed | 405 | The endpoint doesn't support this method (e.g. creating payouts with an API key). |
rate_limited | 429 | More than 600 requests in a minute with one key. Wait for Retry-After seconds. |
invalid_state | 409 | The action doesn't fit the payment's status (e.g. canceling a paid payment), or no live deposit address is free right now. Retry after a short wait. |
internal_error | 500 | Our fault. Safe to retry with the same Idempotency-Key. |
Idempotency
Send Idempotency-Key: <your unique id> (up to 255 printable characters, e.g.
your order id) with POST /payments. Retrying with the same key returns the
original payment instead of creating a second one, even if two requests race.
Keys are scoped to your business and mode.
The payment object
{
"id": "pay_8Hq2kZ0rT4mW9xV1cN7bL3sD",
"object": "payment",
"livemode": false,
"status": "pending",
"amount": "25.5",
"amount_paid": "0",
"amount_due": "25.5",
"currency": "USDT",
"network": "tron",
"address": "TQ7m…9fKp",
"checkout_url": "https://wolf-pay-five.vercel.app/pay/pay_8Hq2kZ0rT4mW9xV1cN7bL3sD",
"order_id": "1001",
"description": "Hoodie",
"customer_email": "sam@example.com",
"redirect_url": "https://yourstore.com/thanks",
"metadata": { "cart": "42" },
"paid_with": null,
"expires_at": "2026-10-20T15:30:00+00:00",
"paid_at": null,
"canceled_at": null,
"created_at": "2026-10-20T14:30:00+00:00"
}status
| Status | Meaning | Final? |
|---|---|---|
pending | Waiting for the customer to send money. | no |
confirming | Money seen on-chain, waiting for confirmations (phase 4). | no |
paid | Paid in full. Ship the order. | yes |
overpaid | Paid more than due. Ship the order; settle the extra with the customer. | yes |
underpaid | Paid less than due. Don't ship; contact the customer. | yes |
expired | Nobody paid before expires_at. | yes |
canceled | You canceled it. | yes |
Coins and networks
network | USDT | USDC |
|---|---|---|
tron (TRC-20) | ✓ | — |
ethereum | ✓ | ✓ |
base | — | ✓ |
bnb (BEP-20) | ✓ | ✓ |
polygon | ✓ | ✓ |
arbitrum | ✓ | ✓ |
solana (SPL) | ✓ | ✓ |
All of these work in test mode. Live mode supports USDT on tron,
ethereum and solana, and USDC on ethereum, base, polygon, arbitrum
and solana. Live amounts must be whole cents ("25.50", not "25.505").
paid_with is set when the customer paid with another coin at checkout
(see Paying with other coins below), for example
{ "currency": "BTC", "amount": "0.00031875" }. You still received
amount_paid in currency, exactly as with any payment; it's only there so
you can show the customer what they sent. Otherwise it's null.
Create a payment
POST /payments → 201 with a payment object.
| Parameter | Type | Required | Notes |
|---|---|---|---|
amount | string | yes | "25.50". More than 0, at most 1,000,000, up to 6 decimals. |
currency | string | yes | USDT or USDC. |
network | string | yes | See the table above. |
order_id | string | Your reference, up to 200 characters. | |
description | string | Shown on the checkout, up to 500 characters. | |
customer_email | string | ||
redirect_url | string | http(s). Customers are sent back here after paying. | |
metadata | object | Up to 20 keys (≤ 40 chars); values are strings (≤ 500 chars), numbers, booleans or null. | |
expires_in_minutes | integer | 15–1440. Default 60. |
curl https://wolf-pay-five.vercel.app/api/v1/payments \
-H "Authorization: Bearer $WOLF_PAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1001" \
-d '{"amount": "25.50", "currency": "USDT", "network": "tron", "order_id": "1001", "redirect_url": "https://yourstore.com/thanks"}'Send the customer to checkout_url, or show address and amount_due in your
own UI.
Retrieve a payment
GET /payments/:id → 200 with a payment object.
List payments
GET /payments → 200
| Query | Notes |
|---|---|
limit | 1–100, default 20. |
starting_after | A payment id. Returns the payments created before it (next page). |
status | Only payments with this status. |
{ "object": "list", "data": [ { "id": "pay_…", "object": "payment", "…": "…" } ], "has_more": true }Newest first. To page, pass the last id in data as starting_after while
has_more is true.
Cancel a payment
POST /payments/:id/cancel → 200 with the payment, now canceled.
Only pending payments can be canceled (otherwise 409 invalid_state).
Balance
GET /balance → 200
{
"object": "balance",
"livemode": false,
"data": [
{"currency": "USDT", "available": "97", "fees_owed": "3", "payouts_pending": "0"},
{"currency": "USDC", "available": "0", "fees_owed": "0", "payouts_pending": "0"}
]
}available: what you can pay out now, after Wolf Pay's fee.fees_owed: Wolf Pay's fees so far. They're settled from your balance when you next send a payout in that coin.payouts_pending: requested payouts not yet delivered.
Payouts
Payouts send your balance to one of your own wallet addresses or, once your
business is verified, your own bank accounts (ACH or wire in USD, SEPA in
EUR). They're
created in the dashboard only (Wallet → Send payout, by an owner or
admin), never with an API key, so a leaked key can't move money.
POST /payouts answers 405 method_not_allowed. The API can read them:
GET /payouts/:id→ the payout.GET /payouts?limit=20&starting_after=po_…→ a list, newest first, paged like payments.
{
"id": "po_8Hq2…",
"object": "payout",
"livemode": false,
"status": "completed",
"amount": "50",
"amount_sent": "50",
"fee_settled": "3",
"currency": "USDT",
"network": "tron",
"address": "TDd…",
"bank": null,
"destination_id": "6c1f…",
"source": "dashboard",
"batch_id": null,
"failure_reason": null,
"tx_hashes": ["9f2c…"],
"created_at": "2026-10-20T22:50:31.003Z",
"completed_at": "2026-10-20T22:51:12.400Z"
}status | Meaning |
|---|---|
pending | Queued. Can still be canceled in the dashboard. |
processing | Being sent. |
completed | Delivered. tx_hashes has the on-chain transactions. |
partially_completed | Part arrived (amount_sent); the rest is back in your balance. |
failed | Nothing was sent; the full amount is back in your balance. |
canceled | Canceled before sending; back in your balance. |
A payout to a bank account has network and address set to null and a
bank object instead. bank.currency is what arrives (usd or eur; our
custody partner converts the stablecoin at its rate), and bank.label shows
only the last 4 digits:
"network": null,
"address": null,
"bank": { "rail": "ach", "currency": "usd", "label": "Chase ••••9123 (ACH, USD)" },For bank payouts tx_hashes lists the stablecoin side only. ACH and SEPA
usually arrive in 1 to 2 business days, wires the same day.
Rules: at least 1.00, at most 2 decimals, up to your available balance. A new payout address or bank account can receive live payouts 24 hours after it's added. Live payouts of 1,000 or more need a code we email to the person sending them.
Bulk payouts (payroll, contractors, affiliates)
Pay many people at once from your balance: Dashboard → Payouts → New bulk payout. Upload a CSV (or paste one) with these columns, in any order:
| Column | Required | Example |
|---|---|---|
name | yes | Ana Lopez |
network | yes | tron, ethereum, solana (USDT) or base, ethereum, arbitrum, polygon, solana (USDC) |
address | yes | the person's wallet address on that network |
amount | yes | 250.00 (a dot for decimals; at least 1.00) |
email | no | we email them when their payout arrives |
reference | no | your note, e.g. October salary; it's on their email too |
- Every row is checked before anything is saved, and problems are listed by row number. Up to 500 payouts per batch, all in one coin (USDT or USDC).
- You get a draft to review. Nothing moves until an owner or admin sends it. Sending is all or nothing: every payout is created, or none is.
- New people are saved as recipients (Payouts → Recipients). Like your own payout addresses, a new recipient can receive live payouts 24 hours after being added, and owners get an email. Live batches of 1,000 or more need a code we email to the person sending them.
- Fee: $1 per payout (shown before you send), on top of the amounts.
- Each row becomes an ordinary payout with
source: "batch"and the batch'sbatch_id, sopayout.*webhooks andGET /payoutscover them too. - Let people add their own wallet. Instead of typing an address, an owner or admin can use Payouts → Recipients → Ask by email. We email the person a one-time link (valid 7 days) where they pick the network and paste their address; no account needed. It's checked like any other address, owners get an email, and the 24-hour hold applies. New address on a recipient asks them for a replacement; the old address is removed once they save it.
Bulk payouts are read-only in the API (POST /payout_batches answers 405):
GET /payout_batches?limit=20&starting_after=pb_…→ a list, newest first, paged like payments.GET /payout_batches/:id→ the batch plus itspayouts(empty while it's a draft).
{
"id": "pb_3kT9…",
"object": "payout_batch",
"livemode": false,
"status": "submitted",
"name": "October payroll",
"currency": "USDT",
"item_count": 3,
"total_amount": "350.5",
"fee_total": "3",
"progress": { "completed": 2, "partially_completed": 0, "failed": 0, "in_progress": 1, "amount_sent": "300.5" },
"created_at": "2026-10-27T09:12:44.120Z",
"submitted_at": "2026-10-27T09:15:02.871Z",
"payouts": [
{ "id": "po_…", "object": "payout", "status": "completed", "amount": "100", "recipient": "Ana Lopez", "reference": "INV-1", "batch_id": "pb_3kT9…", "…": "…" }
]
}status is draft (nothing sent yet), submitted or canceled. Each entry
in payouts is a full payout object plus recipient (the name) and
reference.
Every owner gets an email for each live payout. Test payouts complete within a minute and move nothing real.
source is dashboard or schedule (automatic payouts, set up in
Balances: daily or weekly, the whole available balance above a minimum).
Refunds
POST /refunds refunds a paid payment from your balance.
curl https://wolf-pay-five.vercel.app/api/v1/refunds \
-H "Authorization: Bearer wp_test_…" -H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-1001" \
-d '{"payment_id": "pay_…", "amount": "25.50", "reason": "Out of stock"}'| Field | Required | Notes |
|---|---|---|
payment_id | yes | The payment to refund. |
amount | no | Defaults to everything still refundable. At least 1.00, 2 decimals. |
address | no | Where to send it. Leave it out to send it back to the address the customer paid from. |
reason | no | Up to 200 characters, shown in the dashboard. |
- Back to the payer (no
address): sent right away,status: "pending". - Any other address:
status: "requires_approval". An owner or admin approves or rejects it on the payment's page in the dashboard; in live mode no sooner thanapprovable_after(24 hours later). This way a leaked API key can't redirect refunds to someone else's wallet. Owners are emailed about every live refund. - You can refund up to what the payment received, minus earlier refunds, and up to your available balance. Wolf Pay's fee isn't returned.
GET /refunds/:id, andGET /refunds?payment_id=pay_…&limit=20&starting_after=re_….- Payments show
amount_refunded(delivered so far).
{"id": "re_7Kq…", "object": "refund", "livemode": false, "status": "pending",
"payment_id": "pay_…", "amount": "25.5", "amount_sent": "0", "currency": "USDT",
"network": "tron", "address": "TPay…", "reason": "Out of stock", "source": "api",
"approvable_after": null, "failure_reason": null, "tx_hashes": [],
"created_at": "…", "completed_at": null}Statuses are the payout ones (pending, processing, completed,
partially_completed, failed, canceled) plus requires_approval.
Hosted checkout
checkout_url (/pay/<id>) is a mobile-friendly page with the amount, a QR
code, the address, a countdown, and live status. It updates by itself and sends
the customer to redirect_url five seconds after a successful payment.
The payment id is a 143-bit random token, so the link is safe to share with the customer but should be treated like a receipt link: anyone with it can see the amount and address (not your metadata or the customer's email).
Test mode: the checkout shows buttons to simulate Pay in full, Pay too little, Pay too much and Let it expire. They trigger the same events and webhooks as real payments will.
Paying with other coins
Customers can also pay with BTC, ETH, SOL, TRX, BNB, LTC, DOGE or XRP. Nothing changes for you: you create the payment in USDT or USDC as usual, and that's what you receive.
- At checkout the customer picks a coin and gives an address of theirs for refunds. Wolf Pay gets a quote from NEAR Intents, a conversion network, for exactly the amount still due.
- The customer sends the coin to the address shown. NEAR Intents converts it
and sends the USDT/USDC straight to the payment's own address, so the payment
turns
paidthe usual way, with the usual events and the usual 1% fee (at least $3). - The price includes the network fees. Any coin not needed is sent back to the customer, and so is everything if the conversion can't go through before the quote's deadline (30 minutes, 60 for LTC/DOGE, 90 for BTC). The payment stays open while a conversion is in progress.
- Refunds of these payments need an address from you: the money came from the conversion network, not from the customer's own wallet, so Wolf Pay never refunds "to the sender".
- Owners and admins can turn this off in Settings → Checkout. In live mode it's switched on platform-wide once our custody partner has approved the flow, and a single payment can be at most $10,000.
In test mode the quote uses real market prices but a made-up address, and the checkout shows buttons to simulate the conversion working or failing.
Payment links and invoices (no code)
Merchants without a developer can get paid from Dashboard → Links & invoices:
- Payment link (
/l/plink_…): a fixed price anyone with the link can pay, any number of times. Share it in a bio, a chat or an email. Turn it off at any time. - Invoice (
/i/inv_…): one bill for one customer with line items, a due date and a note. NumberedINV-0001,INV-0002… (TEST-0001in test mode). Live invoices can be emailed to the customer from Wolf Pay; test invoices never are.
The customer picks one of the networks you allowed, and Wolf Pay creates a
normal payment for it and opens the hosted checkout. Everything else is the
same as an API payment: the payment.* webhooks, the ledger, refunds. You can
tell these payments apart by metadata.payment_link (and
metadata.invoice_number for invoices); order_id is the invoice number or
link id.
- A link's checkout lasts 30 minutes. At most 25 live (50 test) payments can be open on one link at a time, so a shared link can't tie up your deposit addresses.
- An invoice has one payment open at a time; reopening the invoice continues it.
It is marked paid, and an
invoice.paidevent is sent, when a payment for it is paid in full. Voiding an invoice cancels any payment still waiting for money.
Subscriptions (memberships, auto-ship)
Stablecoins can't be pulled from a customer's wallet, so a subscription is a schedule of invoices: every period Wolf Pay issues an invoice, emails it to the customer (live mode; test invoices are never emailed) and reminds them once on its due date. They pay it like any invoice. Create them in Dashboard → Links & invoices → Subscriptions, or from your back office with the API (API keys may: a subscription only bills, it never moves money out of your balance).
POST /subscriptions→ create. Send anIdempotency-Key(e.g. your member id) so a retry can't bill the customer twice.GET /subscriptions/:id→ the subscription plus its latest 24invoices.GET /subscriptions?limit=20&starting_after=sub_…&status=active→ a list, newest first.POST /subscriptions/:id/pause,/resume,/cancel.
POST /api/v1/subscriptions
{
"customer_email": "member@example.com",
"customer_name": "Mo Member",
"title": "Monthly auto-ship",
"line_items": [{ "description": "Starter pack", "quantity": 1, "unit_amount": "49.90" }],
"currency": "USDT",
"networks": ["tron"],
"interval": "month",
"metadata": { "distributor_id": "D-1001" }
}| Field | Required | Notes |
|---|---|---|
customer_email, title, line_items, currency, networks, interval | yes | interval: week, month or year. Up to 50 line items; prices in cents. |
interval_count | no | Every 1 to 12 intervals (default 1). |
start_date | no | YYYY-MM-DD, within a year. The first invoice is issued at 09:00 UTC that day; without it, right away. |
days_until_due | no | 0 to 60 (default 7). |
max_invoices | no | Stop (status: "completed") after this many; omit to run until canceled. |
memo, customer_name, metadata | no | memo is printed on every invoice. |
The response is the subscription: status (active, paused, canceled,
completed), amount (each invoice's total), next_invoice_at,
invoices_issued, open_invoices (unpaid), latest_invoice_id, last_error
(why the last attempt to issue an invoice failed, if it did) and
cancel_reason (merchant, or customer when they used the stop link).
- Periods count from the start, so a monthly subscription that starts on January 31 bills on February 28, March 31, April 30… A paused subscription, when resumed, bills the current period at once if its date has passed; it never sends a backlog.
- Every invoice email carries a link where the customer can stop future invoices. Owners get an email when they do.
- Invoices from a subscription have
subscription_idset. Follow them with theinvoice.createdandinvoice.paidwebhooks;payment.*webhooks fire for their payments as for any invoice. - Live subscriptions need a verified business, like live invoices. Up to 2,000 subscription emails a day per business, and 20,000 active subscriptions.
Webhooks
Add an endpoint in Dashboard → Developers → Webhooks. You get a signing
secret (whsec_…) once. Endpoints must be https:// on a public domain name
(no IP addresses, custom ports, or localhost; use a tunnel like ngrok for
local development).
Events
type | When |
|---|---|
payment.created | A payment was created. |
payment.confirming | Money seen, waiting for confirmations (phase 4). |
payment.paid | Paid in full. |
payment.overpaid | Paid more than due. |
payment.underpaid | Paid less than due. |
payment.expired | Expired unpaid. |
payment.canceled | Canceled through the API. |
payout.created | A payout was requested. |
payout.completed | Delivered. |
payout.partially_completed | Partly delivered; the rest is back in your balance. |
payout.failed | Not delivered; back in your balance. |
payout.canceled | Canceled before sending. |
refund.created | A refund was requested (check status for requires_approval). |
refund.completed | Delivered to the customer. |
refund.partially_completed | Partly delivered; the rest is back in your balance. |
refund.failed | Not delivered; back in your balance. |
refund.canceled | Rejected or canceled before sending; back in your balance. |
invoice.created | A subscription issued an invoice (data.object is the invoice, with subscription_id). |
invoice.paid | An invoice was paid in full (data.object is the invoice). |
subscription.created | A subscription was created. |
subscription.paused / subscription.resumed | Paused or resumed. |
subscription.canceled | Canceled by you or by the customer (cancel_reason). |
subscription.completed | Its last invoice (max_invoices) was issued. |
webhook.test | Sent by the dashboard's Send test button. |
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: WolfPay-Webhooks/1.0
Wolf-Event-Id: evt_3kD9…
Wolf-Signature: t=1792500000,v1=5f2b…c9
{"id": "evt_3kD9…", "object": "event", "type": "payment.paid", "livemode": false,
"created_at": "2026-10-20T14:31:02+00:00", "data": {"object": { …payment object… }}}data.object is the payment, payout, refund, invoice or subscription as it was when the event happened.
Verify the signature
v1 is the hex HMAC-SHA256 of <t>.<raw request body> with your signing
secret. Use the raw body exactly as received. Re-serialized JSON won't match.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWolfPay(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false; // reject replays older than 5 minutes
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Delivery and retries
- Events go out within about 10 seconds.
- Answer with any
2xxwithin 10 seconds. Do slow work after responding. - Anything else (non-2xx, timeout, connection error) is retried after 1 min, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h: 8 attempts over about 28 hours. Then the delivery is marked failed.
- Deliveries can arrive more than once or out of order. Use
Wolf-Event-Idto skip duplicates, and fetch the payment (GET /payments/:id) if you need the latest status. - The dashboard shows the last deliveries with their HTTP status.
Limits (current)
- 25 active API keys and 10 webhook endpoints per business.
- 600 requests a minute per API key (
429 rate_limitedabove that, with aRetry-Afterheader). Ask us if you need more.