Wolf Pay

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_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

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

Errors

{ "error": { "type": "invalid_request", "message": "amount: Use a positive decimal…", "param": "amount" } }
typeHTTPMeaning
invalid_request400A parameter is missing or wrong. param names it.
invalid_api_key401Missing, malformed, revoked, or unknown key.
live_mode_unavailable403Live payments aren't available for this business yet (verification pending), or not on this coin and network.
not_found404No such payment for this key's business and mode, or an unknown endpoint.
method_not_allowed405The endpoint doesn't support this method (e.g. creating payouts with an API key).
rate_limited429More than 600 requests in a minute with one key. Wait for Retry-After seconds.
invalid_state409The 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_error500Our 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

StatusMeaningFinal?
pendingWaiting for the customer to send money.no
confirmingMoney seen on-chain, waiting for confirmations (phase 4).no
paidPaid in full. Ship the order.yes
overpaidPaid more than due. Ship the order; settle the extra with the customer.yes
underpaidPaid less than due. Don't ship; contact the customer.yes
expiredNobody paid before expires_at.yes
canceledYou canceled it.yes

Coins and networks

networkUSDTUSDC
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.

ParameterTypeRequiredNotes
amountstringyes"25.50". More than 0, at most 1,000,000, up to 6 decimals.
currencystringyesUSDT or USDC.
networkstringyesSee the table above.
order_idstringYour reference, up to 200 characters.
descriptionstringShown on the checkout, up to 500 characters.
customer_emailstring
redirect_urlstringhttp(s). Customers are sent back here after paying.
metadataobjectUp to 20 keys (≤ 40 chars); values are strings (≤ 500 chars), numbers, booleans or null.
expires_in_minutesinteger15–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

QueryNotes
limit1–100, default 20.
starting_afterA payment id. Returns the payments created before it (next page).
statusOnly 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"}
  ]
}

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:

{
  "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"
}
statusMeaning
pendingQueued. Can still be canceled in the dashboard.
processingBeing sent.
completedDelivered. tx_hashes has the on-chain transactions.
partially_completedPart arrived (amount_sent); the rest is back in your balance.
failedNothing was sent; the full amount is back in your balance.
canceledCanceled 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:

ColumnRequiredExample
nameyesAna Lopez
networkyestron, ethereum, solana (USDT) or base, ethereum, arbitrum, polygon, solana (USDC)
addressyesthe person's wallet address on that network
amountyes250.00 (a dot for decimals; at least 1.00)
emailnowe email them when their payout arrives
referencenoyour note, e.g. October salary; it's on their email too

Bulk payouts are read-only in the API (POST /payout_batches answers 405):

{
  "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"}'
FieldRequiredNotes
payment_idyesThe payment to refund.
amountnoDefaults to everything still refundable. At least 1.00, 2 decimals.
addressnoWhere to send it. Leave it out to send it back to the address the customer paid from.
reasonnoUp to 200 characters, shown in the dashboard.
{"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.

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.

Merchants without a developer can get paid from Dashboard → Links & invoices:

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.


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

typeWhen
payment.createdA payment was created.
payment.confirmingMoney seen, waiting for confirmations (phase 4).
payment.paidPaid in full.
payment.overpaidPaid more than due.
payment.underpaidPaid less than due.
payment.expiredExpired unpaid.
payment.canceledCanceled through the API.
payout.createdA payout was requested.
payout.completedDelivered.
payout.partially_completedPartly delivered; the rest is back in your balance.
payout.failedNot delivered; back in your balance.
payout.canceledCanceled before sending.
refund.createdA refund was requested (check status for requires_approval).
refund.completedDelivered to the customer.
refund.partially_completedPartly delivered; the rest is back in your balance.
refund.failedNot delivered; back in your balance.
refund.canceledRejected or canceled before sending; back in your balance.
invoice.paidAn invoice was paid in full (data.object is the invoice).
webhook.testSent 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 or invoice 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


Limits (current)