ePayVista · API Reference

Everything the API does,
and exactly what it returns.

One page. Every endpoint, every field, every error code, the full payment lifecycle and the signed-webhook contract — each one checked against the running engine, not against a design document. Sandbox credentials are self-serve and take one request. You should never need to email anyone to finish an integration.

Live base URL
https://pay.epayvista.com
Sandbox base URL
https://pay.epayvista.com/sandbox
Auth header
API-Key: <your key>
Webhook signature
X-EPV-Signature
Content type
application/json
Body limit
64 kB

1 · Overview

ePayVista turns a USD order amount into a stablecoin payment. You POST an amount; the API returns a reference and a hosted checkout URL. The buyer sends stablecoin to a deposit address that belongs to that order alone. The engine watches the chain, confirms the deposit, pays out to your payout wallet, and POSTs you a signed webhook. You mark the order paid.

There is no card vault, no chargeback window and no settlement delay beyond block confirmations. A percentage fee is deducted from the settled order; the remainder is sent to your payout wallet.

Base URLs

EnvironmentBase URLMoney
Livehttps://pay.epayvista.comReal funds on Base, Polygon and Ethereum mainnet.
Sandboxhttps://pay.epayvista.com/sandboxTestnet only. Nothing here can move real money.

Every path in this reference is relative to one of those two. POST /api/v1/payment means https://pay.epayvista.com/api/v1/payment on live and https://pay.epayvista.com/sandbox/api/v1/payment on sandbox. The request shapes, the auth header, the response fields, the error codes and the webhook signature scheme are identical in both. Only the base URL, the key and the chains change.

Live vs sandbox — the complete list of differences

 LiveSandbox
NetworksBase (8453), Polygon (137), Ethereum (1)Base Sepolia (84532), Polygon Amoy (80002), Ethereum Sepolia (11155111)
CurrenciesUSDC, USDT (Polygon), PYUSD (Ethereum)USDC (all three) plus TUSDC, a dispensable test token
Minimum order$5 on Base and Polygon, $20 on Ethereum, plus a live gas-economics checkNone. Every rail reports minOrderUsd: 0
ConfirmationsBase 6, Polygon 20, Ethereum 12Base 2, Polygon 5, Ethereum 3
Getting a keyMerchant portal → DeveloperPOST /sandbox/keys, self-serve, no account
Key shapeCopy it from the portal. Most live keys are a bare 32-character hex string; some carry an epv_live_ prefix. Both are valid — the key is whatever Developer shows you.Always prefixed epv_sbx_
Test tokensPOST /sandbox/api/v1/faucet mints tUSDC on demand
Link expiry (UI)7 days60 minutes
Hosted checkoutMulti-rail checkout pageSelf-contained /pay/:ref page
Rate limitsPer-endpoint, see §7Same, plus 3 key issuances per address per day and 10 faucet calls per IP per hour
Data retentionPermanentCleared periodically without notice

Getting credentials

Live. Live keys are issued instantly from the merchant portal under Developer — there is no onboarding gate, no review queue and nothing to wait for. A live key is never emailed and is stored encrypted server-side.

Sandbox. One unauthenticated request, no account, no call:

curl -X POST https://pay.epayvista.com/sandbox/keys \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","storeUrl":"https://yourstore.com"}'

Full field list and response in §3 · POST /sandbox/keys.

2 · Authentication

One header, one value:

API-Key: <the key shown in the merchant portal under Developer>

The header name is matched case-insensitively (API-Key and api-key both work). There is no bearer scheme, no signature on requests, no timestamp, no nonce. The key is the whole credential, so it must never reach a browser, a mobile app, or a public repository.

Which endpoints need a key

GroupAuth
Create a payment — POST /api/v1/paymentRequired. There is no keyless creation in any environment.
Merchant self-serve — /merchant/callback, /merchant/profile, /merchant/logoRequired.
Reference reads — /payment/reference/:ref and its siblings, /api/status/:refOptional, and the behaviour changes — see below.
Public data — /api/railsNone.
Sandbox-only — /sandbox/keys, /sandbox/api/v1/faucetNone.

Reference reads are scoped to the account that created them

Sending your API-Key on a reference read confines the read to your own account. A reference that is not yours answers 404 — never 403 — so an authenticated stranger cannot enumerate or inspect another merchant's orders, and cannot tell a reference outside their account from one that does not exist. A key never widens access here; it only narrows it.

Reads without a key stay possible because the hosted checkout page is client-rendered and fetches the reference from the buyer's browser with no credential — for a buyer, the reference UUID is the bearer. That response contains only what the buyer is already being shown: amount, deposit address, chain and state.

RequestResult
No API-Key header200 — public read, unchanged. This is the checkout path.
Your key, your reference200 — the session.
Your key, another account's reference404 not_found — never 403. A reference outside your account is deliberately indistinguishable from one that does not exist, so an authenticated stranger cannot enumerate references.
Unknown or malformed key401 invalid_api_key. The credential itself is what is wrong; this says nothing about whether the reference exists.
Valid key, deactivated account403 merchant_inactive.

Verified against the sandbox: an anonymous read and a keyed read of the same reference both returned 200 with byte-identical bodies; a read with API-Key: epv_sbx_nope returned 401.

Key hygiene

3 · Endpoints

Every example below was run against the sandbox. Keys, references, addresses and transaction hashes in the examples are placeholders — they are shaped like the real thing but are not real values. Substitute https://pay.epayvista.com for the base URL and your live key from the merchant portal to run the same calls on live.

POST/api/v1/paymentAPI-Key required

Create a payment session. This is the only endpoint that mints a deposit address, and the only one you strictly need.

Request body

FieldTypeReq.Constraints
amountInUSDnumber or numeric stringyesFinite and > 0. Rounded to 2 decimal places internally. Must be above the rail's minOrderUsd and above the underpayment tolerance (see amount_too_small).
customerIDstringnoYour order id. This is the idempotency key. A second create with the same customerID, chain and currency returns the existing open session rather than minting a second address. Omit it and every call mints a new session.
customerEmailstringnoStored on the invoice and included in your own paid-notice email. Not emailed to the buyer from this route.
blockchainCodestringnoCase-insensitive. Default base. Aliases accepted: chain, chainCode.
currencyCodestringnoCase-insensitive. Default USDC. Aliases accepted: currency, token.
returnUrlstringnohttp(s) only, must parse as a URL, ≤ 500 chars. Where the buyer returns after paying. Anything else is silently dropped to null.
cancelUrlstringnoSame rules. Where "change payment method" points.
orderSummarystringnoDisplay only. HTML tags and control characters are stripped, whitespace collapsed, truncated to 1200 chars. Rendered in the merchant paid-notice email; never read by the money path. Alias: order_summary.
showstringnoComma-separated checkout method filter appended to the returned URL. Recognised: stripe, coinbase, changelly, exchange, wallet. Unrecognised entries are dropped; if none survive, no show is appended. See Hosted checkout URLs.

Amounts. Send 12.34 or "12.34" — both work. The engine formats to two decimal places and converts to atomic units using the token's decimals (6 for every currently enabled rail). Never send a value with more than 2 decimal places of meaning: the third and beyond are discarded, and the buyer is quoted the rounded figure. Every amount returned by the API is a 2-decimal-place string, never a float.

Request

curl -X POST https://pay.epayvista.com/sandbox/api/v1/payment \
  -H "API-Key: $EPV_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "customerID":     "doc_order_1",
    "customerEmail":  "[email protected]",
    "amountInUSD":    12.34,
    "returnUrl":      "https://example.com/thanks",
    "cancelUrl":      "https://example.com/cart",
    "orderSummary":   "Order doc_order_1 x1",
    "blockchainCode": "base",
    "currencyCode":   "TUSDC"
  }'

200 — captured response

{
  "url": "https://pay.epayvista.com/sandbox/pay/11111111-2222-4333-8444-555555555555",
  "reference_id": "11111111-2222-4333-8444-555555555555"
}

That is the entire success body. reference_id is a UUID v4 — store it on your order, it is what every later read and the webhook are keyed on. url is the hosted checkout page; send the buyer there. The deposit address is not in this response; read it from GET /api/v1/payment/reference/:ref if you are building your own payment UI.

Errors

HTTPerrorWhen
401api_key_requiredNo API-Key header. Permanent — do not retry with backoff.
401invalid_api_keyKey matches no account in this environment.
403merchant_inactiveKey is valid but the account is switched off.
400invalid_amountamountInUSD missing, non-numeric, zero or negative.
400amount_too_smallAmount is at or below the underpayment tolerance, so it would auto-complete for free. Below about $0.05.
400amount_below_minimumBelow this rail's floor. Body also carries minimum_usd, chain, currency, and the message quotes the figure. Live only.
400gas_uneconomic_for_amountLive gas prices make this order cost more to settle than the fee covers. Body carries gas_usd and min_amount_usd. Live only; retry later or pick a cheaper chain.
400unsupported_railThat (chain, currency) pair is not enabled. Body carries the chain and currency you sent. Never falls back silently.
400bad_jsonBody is not valid JSON. Also carries a reference.
413payload_too_largeRequest body over 64 kB. Also carries a reference.
503rail_unavailableThat rail is temporarily unavailable. Choose another chain.
503disabledThe whole service is closed to new payments.
500server_errorOur fault. No money has moved. Body carries a short reference to quote to support.

Captured error responses

# no key
HTTP 401  {"error":"api_key_required","message":"Send your API key in the API-Key header.
           You can find it in the merchant portal under Developer."}

# {"amountInUSD":"abc"}
HTTP 400  {"error":"invalid_amount","message":"The amount must be a number greater than zero,
           written in dollars — for example 12.50."}

# {"amountInUSD":0.04}
HTTP 400  {"error":"amount_too_small","message":"That amount is too small for us to settle.
           Raise the order amount and try again."}

# {"amountInUSD":10,"blockchainCode":"polygon","currencyCode":"TUSDC"}
HTTP 400  {"error":"unsupported_rail","chain":"polygon","currency":"TUSDC",
           "message":"We do not support that network and coin together.
           Call GET /api/rails to see the combinations you can use."}

# malformed JSON
HTTP 400  {"error":"bad_json","message":"We could not read that request body — it is not valid
           JSON. Check the payload and the Content-Type header.","reference":"1a2b3c4d"}

# 70 kB body
HTTP 413  {"error":"payload_too_large","message":"That request body is too large.
           Keep it under 64 kB.","reference":"5e6f7a8b"}
GET/api/v1/payment/reference/:reference_idkey optional

The full session read. This is what you poll, and what the hosted checkout reads from the buyer's browser. Auth behaviour is described in §2.

Before answering, an OPEN session gets one short, read-only on-chain balance check, so a deposit that has landed but has not yet been through the settlement tick is often reported as paid a few seconds early. That check never mutates anything and fails open.

curl https://pay.epayvista.com/sandbox/api/v1/payment/reference/$REF

200 — captured, session OPEN

{
  "linkExpired": false,
  "paymentState": "OPEN",
  "amountInUSD": "12.34",
  "confirmationRequired": 1,
  "confirmationCurrent": 0,
  "filledAmount": null,
  "explorerTransaction": null,
  "successURL": "https://example.com/thanks",
  "cancelURL": "https://example.com/cart",
  "invoiceID": "11111111-2222-4333-8444-555555555555",
  "referenceID": "11111111-2222-4333-8444-555555555555",
  "address": "0x1111111111111111111111111111111111111111",
  "blockchainCode": "BASE",
  "currencyCode": "TUSDC",
  "chainId": 84532,
  "url": "https://pay.epayvista.com/sandbox/pay/11111111-2222-4333-8444-555555555555"
}

200 — captured, same session after settlement

{
  "linkExpired": false,
  "paymentState": "FILLED",
  "amountInUSD": "3.50",
  "confirmationRequired": 1,
  "confirmationCurrent": 1,
  "filledAmount": "3.50",
  "explorerTransaction": "https://sepolia.basescan.org/tx/0xb2519bb6...537701",
  ...
}

Response fields

FieldTypeMeaning
paymentStatestringOPEN or FILLED. Only these two values are ever returned here. FILLED means paid — the webhook calls the same state CLOSED.
linkExpiredbooleantrue once the link is dead: unpaid and no longer payable on any rail. A dead link must not be shown a deposit address.
amountInUSDstringThe quoted amount, 2 d.p. Compare as a string or parse as decimal — never as a float.
confirmationRequirednumberAlways 1. This is a UI gate, not the chain's confirmation count.
confirmationCurrentnumber0 while open, 1 the instant the order settles.
filledAmountstring or nullThe settled amount, or null while open.
explorerTransactionstring or nullBlock-explorer URL for the settlement transaction, or null. Also null when the payment was confirmed by balance proof rather than by a transfer log, in which case the deposit hash is genuinely unknown to us.
successURL / cancelURLstringYour returnUrl / cancelUrl, or "".
invoiceID / referenceIDstringBoth equal the reference. Two names for one value, kept for client compatibility.
addressstringThe deposit address for this session's own rail. Unique to this order.
blockchainCodestringUpper-cased chain key, e.g. BASE.
currencyCodestringThe token this session settles in.
chainIdnumber or nullEVM chain id.
urlstringCanonical hosted checkout URL for this reference.
payoutHeldbooleantrue when this order settled but the payment to you is being withheld because the payout wallet on your account cannot receive money (see Payout-address safety). The funds are safe — they stay on this order's own deposit address and are sent automatically on the next cycle once you save a wallet that passes the check. Normally false.
payoutHoldReasonstring or nullWhy the payout is held — the same reason codes as invalid_payout_address (burn_or_zero, token_contract, contract_address, …). Returned only when you send your own API-Key: it describes your account configuration, so a keyless (buyer) read never sees it, and the field is absent rather than null on such a read.

Errors

HTTPBodyWhen
401invalid_api_keyA key was sent and it is not recognised.
403merchant_inactiveKey valid, account switched off.
404not_foundYou sent a valid key and the reference is not yours.
401{"error":{"code":"UNAUTHORIZED","message":"Unauthorised access!"}}Unknown or malformed reference, with no key. See the warning below.
GET/api/status/:reference_idkey optional

The minimal poll. Three fields, no on-chain call, no passthrough. This is the right endpoint for a status widget or a background job that only needs paid / not paid.

curl https://pay.epayvista.com/sandbox/api/status/$REF

# => HTTP 200, captured while open
{ "status": "OPEN", "paid": false, "reference_id": "11111111-…" }

# => HTTP 200, captured after settlement
{ "status": "PAID", "paid": true, "reference_id": "99999999-…" }

# => HTTP 404, unknown reference
{ "error": "not_found", "message": "We could not find that. If you sent an API key, note that
  a payment can only be read by the account that created it." }
FieldMeaning
paidOrder-level. true once the order has settled on any rail, including when the funds cleared but your webhook has not been delivered yet. To the customer that is paid, so treat it as paid or they will see "waiting" forever and may pay twice.
statusPAID when paid, otherwise the invoice's internal status, except that an unpaid OPEN invoice past the display expiry window reports EXPIRED so the page stops prompting. See §4.
reference_idEchoed back.

Poll this, do not hammer it. The settlement loop ticks every 15 seconds, so polling faster than that tells you nothing new. Webhooks are the primary signal; polling is the fallback.

What the hosted checkout page calls

The hosted checkout page calls a few additional read-only endpoints on the customer's behalf — the payment-method list, the per-network deposit-address rows, the branding header and a currency helper. They are not part of the merchant integration surface, they are not documented here, and they may change without notice. Everything you need to take a payment and confirm it is on this page: create the session, read it back, and receive the webhook.

GET/api/railsno auth

Every (chain, currency) pair this environment will actually accept, with its minimum. This is the source of truth for blockchainCode and currencyCode — build your network picker from it rather than hard-coding a list, and use it as a health probe.

The list is filtered live: a rail that is temporarily unavailable is absent, not listed-and-broken. So an empty or short list is meaningful information, not an error.

curl https://pay.epayvista.com/api/rails

# => HTTP 200 — LIVE, captured
{"rails":[
 {"chain":"base",    "chainId":8453,"chainName":"Base",    "currency":"USDC", "minOrderUsd":5, "decimals":6},
 {"chain":"polygon", "chainId":137, "chainName":"Polygon", "currency":"USDC", "minOrderUsd":5, "decimals":6},
 {"chain":"polygon", "chainId":137, "chainName":"Polygon", "currency":"USDT", "minOrderUsd":5, "decimals":6},
 {"chain":"ethereum","chainId":1,   "chainName":"Ethereum","currency":"USDC", "minOrderUsd":20,"decimals":6},
 {"chain":"ethereum","chainId":1,   "chainName":"Ethereum","currency":"PYUSD","minOrderUsd":20,"decimals":6}
]}

# => HTTP 200 — SANDBOX, captured
{"rails":[
 {"chain":"base",    "chainId":84532,   "chainName":"Base Sepolia",    "currency":"USDC", "minOrderUsd":0,"decimals":6},
 {"chain":"polygon", "chainId":80002,   "chainName":"Polygon Amoy",    "currency":"USDC", "minOrderUsd":0,"decimals":6},
 {"chain":"ethereum","chainId":11155111,"chainName":"Ethereum Sepolia","currency":"USDC", "minOrderUsd":0,"decimals":6},
 {"chain":"base",    "chainId":84532,   "chainName":"Base Sepolia",    "currency":"TUSDC","minOrderUsd":0,"decimals":6},
 {"chain":"ethereum","chainId":11155111,"chainName":"Ethereum Sepolia","currency":"TUSDC","minOrderUsd":0,"decimals":6}
]}

No error responses: this endpoint takes no input.

POST/api/v1/merchant/callbackAPI-Key required

Set or re-assert where paid-webhooks are delivered. Your signing secret is not changed by this call — it changes where we deliver, never the key the receiver verifies with.

curl -X POST https://pay.epayvista.com/sandbox/api/v1/merchant/callback \
  -H "API-Key: $EPV_KEY" -H 'Content-Type: application/json' \
  -d '{"callback_url":"https://example.com/hooks/epv"}'

# => HTTP 200 (captured, first time)
{ "ok": true, "callback_url": "https://example.com/hooks/epv", "changed": true }

# => HTTP 200 (captured, same URL again — idempotent)
{ "ok": true, "callback_url": "https://example.com/hooks/epv", "changed": false }
FieldTypeReq.Constraints
callback_urlstringyesSee the validation rules below. Max 300 characters. Empty string is rejected — there is no "clear" operation.

Callback URL validation rules

This is a server-side POST target, so it is gated hard. All of the following are rejected with 400 invalid_callback_url — each verified:

RejectedWhy
ftp://example.com/hookScheme must be http or https.
http://localhost:9000/hookLoopback.
http://10.0.0.5/hookPrivate RFC-1918 range. Also 172.16/12, 192.168/16, 169.254/16, CGNAT.
http://intranet/hookBare hostname with no dotted public domain.
https://does-not-exist.invalid/hookThe host must actually resolve, to a public address. DNS is checked at set time.
a non-string valueType-checked before anything else.
over 300 charactersLength cap.

All of these return the same code and the same message, so you cannot tell which rule you hit from the response — check the URL against the list above.

Calling this endpoint also resumes deliveries that were paused because your callback had stopped answering: anything undelivered is re-queued and sent on the next settlement tick. See §5.

HTTPerror
401api_key_required / invalid_api_key
403merchant_inactive
400invalid_callback_url
500server_error
POST/api/v1/merchant/profileAPI-Key required

Change your display name, your payout wallet, or your paid-notice email preference. All three fields are optional; omitted fields are left untouched. At least one must be present.

curl -X POST https://pay.epayvista.com/sandbox/api/v1/merchant/profile \
  -H "API-Key: $EPV_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"Acme Widgets","notifyPaid":false}'

# => HTTP 200 (captured)
{ "id": 1234, "name": "Acme Widgets",
  "payout_address": "0x9041da1e1515d427B38858C83162778DD923CbdC",  // a fresh sandbox address unless you sent one
  "active": true, "notify_paid": 0 }
FieldTypeReq.Constraints
namestringnoThe name buyers see on the payment page. Control characters become spaces, whitespace collapses, truncated to 120 chars. Must be non-empty after cleaning.
payoutAddressstringnoFull 42-character 0x EVM address; checksum-validated. Future settlements only — see below.
notifyPaidboolean or stringnoPaid-notice email opt-out. false, 0, "0", "false", "off" and "no" all mean off; anything else means on.
HTTPerrorWhen
401api_key_requiredNo header. This endpoint requires an explicit key in every environment.
401invalid_api_keyUnrecognised key.
403merchant_inactiveAccount switched off.
400nothing_to_updateAll three fields absent. Verified on {}.
400invalid_namename empty after cleaning. Verified on " ".
400invalid_payout_addressNot a valid address. Verified on "0x123".
500server_errorOur fault.

Payout-address safety

curl -X POST https://pay.epayvista.com/sandbox/api/v1/merchant/profile \
  -H "API-Key: $EPV_KEY" -H 'Content-Type: application/json' \
  -d '{"payoutAddress":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}'

# => HTTP 400 (captured)
{ "error": "invalid_payout_address",
  "reason": "token_contract",
  "message": "That is the address of a token contract, not a wallet. Money sent there cannot be recovered. Enter the wallet address where you want to be paid." }
reasonWhat it means
malformedNot a 42-character 0x address at all. Verified on "0x123".
burn_or_zeroA burn or zero address — no key exists for it. Verified on 0x…dEaD.
token_contractA stablecoin token contract. Verified on 0xA0b86991…eB48 (USDC on Ethereum).
example_addressA documentation or example address, such as 0x1111…1111.
contract_addressThe address carries contract code on one of the settlement networks; the message names the network.

The same check runs on every surface that can set your payout wallet, so an address refused here is refused in the merchant portal and in the WooCommerce plugin too.

Set the logo shown on the hosted checkout header. Pass an empty string to clear it.

curl -X POST https://pay.epayvista.com/sandbox/api/v1/merchant/logo \
  -H "API-Key: $EPV_KEY" -H 'Content-Type: application/json' \
  -d '{"logo_url":"https://cdn.example.com/logo.png"}'

# => HTTP 200 (captured)
{ "ok": true, "logo_url": "https://cdn.example.com/logo.png" }

# => HTTP 200 (captured, cleared with "")
{ "ok": true, "logo_url": "" }
FieldTypeReq.Constraints
logo_urlstringyeshttps only. Must parse as a URL. Host must not be private or loopback. Max 300 characters. "" clears the logo.
HTTPerrorWhen
400invalid_logo_urlNot a string; not https (verified on http://); unparseable; or a private host (verified on https://192.168.1.5/a.png).
400logo_url_too_longOver 300 characters. Verified.
401invalid_api_keyUnrecognised or missing key.
403merchant_inactiveAccount switched off.
500server_errorOur fault.

Note that http:// is refused here even though it is accepted for the callback URL. The logo is loaded in the buyer's browser, so it must be https.

Chrome extension and AI-agent (MCP) links

The Chrome extension and the AI-agent (MCP) endpoint create links the same way; use them or the API, not both.

POST/sandbox/keyssandbox onlyno auth

Self-serve sandbox credentials. No account, no email confirmation, no call. This endpoint exists only under /sandbox; there is no live equivalent.

curl -X POST https://pay.epayvista.com/sandbox/keys \
  -H 'Content-Type: application/json' \
  -d '{
    "email":    "[email protected]",
    "storeUrl": "https://yourstore.com",
    "name":     "Acme Widgets"
  }'
FieldTypeReq.Constraints
emailstringnoTrimmed, truncated to 200 chars. Used only so we can reach you about the sandbox.
storeUrlstringnoTrimmed, 300 chars. Defaults to a placeholder. A unique suffix is appended so two developers never merge onto one account.
namestringnoTrimmed, 80 chars. Defaults to "Sandbox developer". A [sbx …] marker is appended.
payoutAddressstringnoMust match ^0x[0-9a-fA-F]{40}$. Anything else — including omission — gets a freshly generated sandbox address, which is correct for a sandbox. (It is no longer the burn address: the payout guard refuses burn and zero addresses everywhere, so the sandbox cannot default to one either.)
callbackUrlstringnoMust be http(s) and ≤ 300 chars, else replaced with a discard endpoint. Change it later with /merchant/callback.

200 — captured (secrets elided)

{
  "sandbox": true,
  "network": "base-sepolia",
  "chain_id": 84532,
  "base_url": "https://pay.epayvista.com/sandbox",
  "auth_header": "API-Key",
  "merchant_id": 1234,
  "api_key": "epv_sbx_…",              // 56 chars: prefix + 48 hex
  "callback_secret": "…",             // 64 hex chars — shown ONCE
  "callback_url": "https://sandbox-callbacks.epayvista.com/discard",
  "payout_address": "0x9041da1e1515d427B38858C83162778DD923CbdC",
  "docs": "https://pay.epayvista.com/sandbox",
  "notice": "Test funds only (Base Sepolia). This key cannot process live payments."
}

The callback_secret is shown once and is not recoverable. Save it before you close the terminal; if you lose it, issue a new sandbox key.

HTTPerrorWhen
400invalid_jsonBody is not valid JSON. Verified — note this service returns {"error":"invalid_json"} with no message key.
429rate_limited3 issuances per address per day, plus a global daily cap of 40. Body carries scope: "ip" or "global", and a message.
502provisioning_upstream_failed / provisioning_failedThe sandbox engine could not be reached or refused. Retry shortly.
503provisioning_disabledSelf-serve provisioning is switched off.
500server_errorOur fault.

A GET on the same URL returns this documentation page, not JSON.

POST/api/v1/faucetsandbox onlyno auth

Mints tUSDC — "ePayVista Test USD", our own testnet ERC-20 — to any address you name, and returns the transaction hash. Its mint() is unrestricted, so the dispenser cannot run dry and you never have to hunt a public faucet to finish a test.

This route refuses to mount at all on a mainnet engine. There is no live equivalent and there cannot be one.

curl -X POST https://pay.epayvista.com/sandbox/api/v1/faucet \
  -H 'Content-Type: application/json' \
  -d '{"address":"0xYourTestWallet","chain":"base","amount":250}'
FieldTypeReq.Constraints
addressstringyesValid 0x 20-byte EVM address. The zero address is explicitly refused.
chainstringnoDefault base. Chains with a tUSDC rail: currently base and ethereum.
amountnumbernoDefault 100, maximum 10000 per call. Quantised to 6 decimal places.

200 — captured

{
  "ok": true,
  "sandbox": true,
  "chain": "base",
  "chain_id": 84532,
  "chain_name": "Base Sepolia",
  "token": "tUSDC",
  "token_contract": "0x3764Fac8d919a905EA0Dae18d65fCe5D40e21000",
  "decimals": 6,
  "to": "0x2222222222222222222222222222222222222222",
  "amount": "3.5",
  "amount_units": "3500000",
  "tx_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "status": "confirmed",
  "explorer_url": "https://sepolia.basescan.org/tx/0xaaaaaaaa…aaaaaa",
  "notice": "Test tokens with no value. Sandbox only."
}

status is confirmed once mined, or pending if it is still in the mempool after 60 seconds — the hash is valid either way.

HTTPerrorWhen
400invalid_addressNot a valid address, or the zero address. Verified.
400unsupported_chainNo tUSDC rail on that chain. Body lists the supported ones. Verified on polygon.
400invalid_amountNot a positive number. Verified on -1.
400amount_too_largeOver 10000. Body carries max. Verified on 99999.
429rate_limited10 per IP per hour, 200 globally per hour. Body carries scope.
503chain_unavailableThat chain is temporarily unavailable in the sandbox. Use another one.
500faucet_failedThe mint transaction failed. message carries a truncated reason.

Every faucet response — success and failure alike — carries an ok boolean alongside error/message. That extra field is unique to this endpoint.

A GET on the same URL returns a machine-readable summary of the limits and supported chains, which is handy in a smoke test:

curl https://pay.epayvista.com/sandbox/api/v1/faucet

# => HTTP 200 (captured)
{ "ok": true, "endpoint": "POST /sandbox/api/v1/faucet",
  "body": { "address": "0x... (required)", "chain": ["base","ethereum"],
            "amount": "optional, default 100, max 10000" },
  "token": "tUSDC (ePayVista Test USD, 6 decimals)",
  "limits": { "per_ip_per_hour": 10, "global_per_hour": 200, "max_amount": 10000 },
  "chains": ["base","ethereum"] }

Hosted checkout URLs and their query parameters

Two URLs render a checkout. Both take the reference; both accept exactly one query parameter that changes what the buyer sees.

URLParametersBehaviour
/pay/:reference_idshowThe self-contained deposit page. show is read client-side from the query string.
/payments?reference_id=…reference_id (required), showThe multi-rail checkout shell.

reference_id

On /payments this must match a canonical UUID v4 exactly. Anything else returns 404 with a branded "we could not find this payment" page — verified. The same page is returned by /pay/:ref for an unknown reference.

show

A comma-separated list that filters the payment-method tiles. Case-insensitive.

ValueAccepted by the API's show fieldRecognised by the checkout page
walletyesyes — crypto group
exchangeyesyes — crypto group
stripeyesyes — card group
changellyyesyes — card group
banksno — dropped by the APIyes — card group
coinbaseyesno — matches neither group

Omit show entirely and every tile renders — that is the all-in-one checkout and it is the right default. Send show with only card values and the crypto sections are hidden; send only crypto values and the card tile is hidden. Sending show=coinbase alone matches neither group and hides both, which is almost certainly not what you want — pass it alongside a value the page recognises, or leave show off. The card tile additionally requires a USDC order of at least $2 on Base or Polygon; below that it never renders, whatever show says.

On this sandbox /payments?reference_id=…&show=wallet answers 302 to /sandbox/pay/<ref>?show=wallet — captured. On live it renders the multi-rail shell directly. Either way, use the url the create call returned rather than constructing one; it is already correct for the environment.

4 · Payment lifecycle

There are two vocabularies for one lifecycle, and they never converge:

LayerFieldValues you will ever see
Reference readpaymentStateOPEN, FILLED
Status readstatusOPEN, PAID, EXPIRED, plus the internal values below
WebhookstatusCLOSED — the only value ever sent

Internal statuses

These are the values the engine stores. GET /api/status/:ref surfaces them verbatim when the order is not yet paid, so you may observe them.

StatusMeaning
OPENAwaiting a deposit. In the 15-second detection loop.
EXPIREDUnpaid and dropped out of the hot loop after 4 hours. Detection is not abandoned — a slower batched pass keeps checking the address for 7 days, and a late deposit still settles normally.
PAIDDeposit detected and confirmed. Funds are being paid out to your payout wallet.
NOTIFIEDPaid out; a webhook delivery has been attempted and failed at least once, and more attempts remain.
CLOSEDTerminal success. Paid out and your endpoint answered 2xx.
WEBHOOK_FAILEDPaid out, but every delivery attempt in the burst was spent. Your money is safe and already paid out — only the notification failed. Hourly re-delivery continues for 7 days.
SUPERSEDEDA sibling session on another rail, retired because the order settled elsewhere. This is the double-settle guard: only one rail can ever fire a webhook for one order.

Transitions and timings

  create
    │
    ▼
  OPEN ──── no deposit, 4 h ────────▶ EXPIRED ──── 7 d ────▶ dead link
    │                                      │              (linkExpired: true)
    │  deposit ≥ threshold                 │  late deposit re-opens it
    │  + chain confirmations               │
    ▼                                      ▼
  PAID ──── paid out to your wallet ────────▶ deliver webhook
    │                                              │
    │                             2xx ─────────────┴──────▶ CLOSED   (terminal)
    │                             non-2xx / 3xx / timeout
    │                                              │
    └──────────────────────────────────────▶ NOTIFIED ──▶ … ──▶ WEBHOOK_FAILED
                                                              (hourly retry, 7 days)
StepTiming
Detection pollEvery 15 seconds, for every open session, on every rail.
Confirmations requiredLive: Base 6, Polygon 20, Ethereum 12. Sandbox: Base 2, Polygon 5, Ethereum 3. Polygon's is deeper because it reorgs deeper.
Balance-proof fallbackIf the deposit is provably on-chain but the transfer log cannot be read, the payment settles on the balance alone after an 8-minute grace. This is why a temporary node problem can slow settlement but cannot strand a paid order. In that case explorerTransaction is null: the deposit hash is genuinely unknown to us.
PayoutStarts on the next tick after settlement — typically seconds. Median observed lag from settlement to funds leaving for your wallet is around 22 minutes at load, occasionally longer if a chain is congested.
WebhookFired once the funds have left for your wallet. Not before.
Display expiryLive 7 days, sandbox 60 minutes. UI-only: the address stays watched.
Late-deposit grace7 days from creation. After that the link is dead and nothing watches the address.

linkExpired and dead links

linkExpired: true means the session is unpaid and no longer payable on any rail. When it is true the hosted checkout will not render a deposit address for it, and any attempt to open a new rail on that order is refused. Show your customer an "expired — start checkout again" state and issue a fresh session. Never invite a payment to a dead link's address.

Underpayment

The threshold that counts as paid is the quoted amount minus a tolerance, and the tolerance is the larger of a flat $0.05 and 5% of the order.

ShortfallWhat happens
Within 5% (or within 5 cents, whichever is more generous)The order settles and completes normally. Full pipeline: payout, webhook, CLOSED. You receive slightly less than the quote. A $133 order arriving 6 cents light settles; so does a $100 order arriving $4 light.
More than 5% shortThe order is not completed. No settlement, no webhook, paymentState stays OPEN. The shortfall is recorded and, if it clears an alert floor of max($1, 2% of the order), you are emailed once. The buyer can top up to the same address and a full balance settles automatically. If no top-up arrives within 48 hours, the partial amount is forwarded to your payout wallet anyway — the funds are never held — but the order still does not complete, because releasing goods for less money than the order is a business decision, not one an API gets to make.
Dust (below max($1, 2% of the order))Recorded for support, but no email — a rounding remainder is not an underpayment.

Your integration should therefore treat a session that stays OPEN after a plausible payment window as "possibly underpaid", not as "unpaid". There is no partial-payment webhook and no partial-payment field in the read API.

Overpayment

An overpayment settles the order normally — the threshold is a floor, not a range — and the full received amount is swept to your payout wallet. A receipt of 1.5× the quote or more is flagged internally as a probable double payment and looked at by a person, because that is usually a customer who paid twice and is owed a refund. Nothing is deducted or withheld automatically; the refund decision is yours.

explorerTransaction

Built from the settling chain's own explorer and the deposit transaction hash, so the read API and the webhook's explorer_url can never disagree about a transaction. Live: basescan.org, polygonscan.com, etherscan.io. Sandbox: sepolia.basescan.org, amoy.polygonscan.com, sepolia.etherscan.io. It is null until settlement, and stays null for a balance-proof settlement.

Verified end to end

The following was run against the sandbox while writing this page. Times are from the captured log.

# 1. create a $3.50 TUSDC session on Base Sepolia
POST /sandbox/api/v1/payment  →  { "reference_id": "99999999-…",
                                   "url": "…/sandbox/pay/99999999-…" }
GET  …/payment/reference/…    →  address 0x2222222222222222222222222222222222222222

# 2. mint exactly 3.5 tUSDC to that address
POST /sandbox/api/v1/faucet   →  tx 0xaaaaaaaa…aaaaaa  status "confirmed"

# 3. t + ~15 s — first poll after the mint
GET  /sandbox/api/status/…    →  { "status":"PAID", "paid":true }
GET  …/payment/reference/…    →  paymentState "FILLED", confirmationCurrent 1,
                                 filledAmount "3.50",
                                 explorerTransaction "https://sepolia.basescan.org/tx/0xb2519bb6…"

# 4. t + ~45 s — signed webhook delivered, accepted on the first attempt
POST https://example.com/hooks/epv
     → { "reference_id":"99999999-…", "status":"CLOSED", "amount_in_usd":"3.50",
         "blockchain_code":"BASE", "confirmations":2, … }   listener answered 200

Detection, confirmation, payout and webhook delivery completed inside a minute on Base Sepolia, on the first delivery attempt.

5 · Webhooks

One webhook exists: the order was paid. There is no webhook for creation, expiry, underpayment, refund, or any other event. If you need those, poll /api/status/:ref.

Registration

Your callback URL and signing secret are issued together with your API key. Change the URL — and only the URL — with POST /api/v1/merchant/callback. The secret is never changed by that call and there is no self-serve way to rotate it; email [email protected].

Validation rules for the URL are listed in full under that endpoint: http(s) only, a real public domain that resolves, no private or loopback hosts, 300 characters maximum.

Delivery

POST <your callback_url>
Content-Type: application/json
X-EPV-Signature: <64 lowercase hex characters>

{ …the body below… }

We expect a 2xx. Anything else is a failure, and that explicitly includes redirects: a 3xx is never followed and never counts as a delivery. (A 307 or 308 preserves method and body, so following one would deliver a signed POST wherever the response pointed — the URL validation would be worthless.) Answer 200 quickly and do your work afterwards; the delivery timeout is 8 seconds.

Body schema

Exactly seven fields, in this order. Nothing is ever added or omitted.

FieldTypeMeaning
reference_idstringThe order reference — the one the create call returned. If the buyer paid on a different rail than the order was opened on, this is still the original reference, never the internal id of the rail that settled. Match your order on this.
statusstringAlways "CLOSED". It is the only value this field ever takes; a webhook is only sent for a settled payment.
txidstringSettlement transaction hash, or "". For a balance-proof settlement it is a non-empty internal sentinel that is not a chain hash — check that it starts with 0x before linking it.
amount_in_usdstringThe quoted amount, 2 d.p., e.g. "3.50". Not the received amount. Compare as a string or a decimal, never as a float.
blockchain_codestringUpper-cased chain the payment settled on, e.g. "BASE".
confirmationsnumberThe chain's confirmation requirement at delivery — 2 on Base Sepolia, 6 on Base mainnet, and so on.
explorer_urlstringBlock-explorer link, or "" when there is no real hash.

A real captured webhook

Delivered to a listener during the end-to-end run in §4. This is the verbatim body and the verbatim header:

X-EPV-Signature: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Content-Type: application/json
content-length: 331

{"reference_id":"99999999-8888-4777-8666-555555555555","status":"CLOSED","txid":
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","amount_in_usd":
"3.50","blockchain_code":"BASE","confirmations":2,"explorer_url":
"https://sepolia.basescan.org/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}

(Line breaks added here for the page — the real body is a single line with no whitespace.)

Signature

X-EPV-Signature = hex( HMAC_SHA256( raw request body bytes, callback_secret ) )
PropertyValue
AlgorithmHMAC-SHA256
KeyYour callback_secret, used as the raw UTF-8 string — not hex-decoded first
MessageThe exact bytes of the request body
EncodingLowercase hex, 64 characters
PrefixNone. No sha256=, no version marker
TimestampNone. There is no timestamp header and none in the body, so there is no replay window to validate. Idempotency is your replay defence — see below

Node / Express

Verified against the captured webhook above: returned true. With "3.50" altered to "9.99" in the body, it returned false.

const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/hooks/epayvista',
  express.raw({ type: 'application/json' }),        // keep the RAW bytes
  (req, res) => {
    const expected = crypto
      .createHmac('sha256', process.env.EPV_CALLBACK_SECRET)
      .update(req.body)                             // req.body is a Buffer here
      .digest('hex');
    const got = String(req.get('X-EPV-Signature') || '');

    const a = Buffer.from(got), b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // mark the order paid, idempotently — see below
    res.sendStatus(200);
  });

Python / Flask

Verified against the same captured webhook: returned True; tampered body False.

import hashlib, hmac, os, json
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['EPV_CALLBACK_SECRET'].encode()

@app.post('/hooks/epayvista')
def epayvista_hook():
    raw = request.get_data()                        # the RAW bytes, unparsed
    got = request.headers.get('X-EPV-Signature', '')
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(got, expected):
        abort(401)

    event = json.loads(raw)
    # mark the order paid, idempotently
    return '', 200

PHP

Verified against the same captured webhook on PHP 8.2: hash_equals returned true.

<?php
$raw = file_get_contents('php://input');            // the RAW bytes, unparsed
$got = $_SERVER['HTTP_X_EPV_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('EPV_CALLBACK_SECRET'));

if (!hash_equals($expected, $got)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
// mark the order paid, idempotently
http_response_code(200);

In WordPress, read the raw body the same way — $request->get_body() inside a REST route, never $request->get_json_params() re-encoded.

Idempotency

Assume every webhook will arrive more than once. Retries are normal, and a delivery your server processed but answered slowly is retried anyway. Make the handler idempotent:

Retry schedule

PhaseBehaviour
BurstOne attempt per settlement tick — so roughly every 15 seconds — up to 30 attempts. There is no internal backoff loop inside a single attempt; the tick is the spacing, which is what stops one slow store delaying settlement for everyone else.
After 30 attemptsThe order goes WEBHOOK_FAILED. Your funds are already swept and paid out. Only the notification failed.
Hourly re-deliveryEvery WEBHOOK_FAILED order is re-queued for one more attempt once per hour, for 7 days from settlement. A store that comes back online is caught up automatically, with no action from you and no action from us.
After 7 daysAutomatic retries stop. The order is still settled and paid out; email us with the reference for a manual re-delivery.

If your callback stops answering

WordPress routes. If your callback URL is a WordPress route (it contains /wp-json/ or ?rest_route=) and it returns 404, we treat that as the plugin having been switched off rather than as a broken site: deliveries for your account are paused instead of burning retries. They resume automatically when the route answers again, and immediately when you call POST /api/v1/merchant/callback — which is why "reactivate the plugin, then save its settings" clears a backlog of missing orders in one step.

Every other callback simply retries on the schedule above: up to 30 attempts, then once an hour for 7 days.

Nothing is lost either way. The money was paid out before the first delivery attempt; only the notification is delayed.

The practical consequence: "the money arrived but my store still shows the order unpaid" is, almost every time, a deactivated plugin. See the FAQ.

6 · Errors

Every JSON error from every endpoint has the same two keys:

{
  "error":   "amount_below_minimum",   // stable machine code — BRANCH ON THIS
  "message": "The minimum order is $20.00 on this network. Raise the amount
              or choose another network."   // plain English — SHOW THIS, never branch on it
}

error is a contract and never changes. Codes are only ever added, never renamed or repurposed, so branching on them is safe across releases. message is for humans — your logs, your admin screen, your support ticket. The wording is revised whenever it can be made clearer, so a client that matches on message text will break.

Some errors carry extra context keys alongside the two — minimum_usd, chain, currency, gas_usd, min_amount_usd, scope, max. They are additive; ignore any you do not recognise.

5xx responses

{
  "error": "server_error",
  "message": "Something went wrong on our side while handling this request. No money
              has moved. Try again in a moment — if it keeps happening, email
              [email protected] with the time and the reference.
              Quote reference 9c0d1e2f if you contact us.",
  "reference": "9c0d1e2f"
}

The extra reference is an 8-character id for that one failure. Quote it and we can find the exact failure. Every 5xx is recorded on our side automatically, so you do not have to report it for us to know — but the reference is what makes it findable. A 4xx is not recorded that way: a wrong key or an under-minimum amount is a client error that already explains itself. bad_json (400) and payload_too_large (413) also carry a reference.

Two shapes that are not this

Complete error-code table

Every code the engine can return, its HTTP status, and the exact message text.

Authentication and account

errorHTTPmessage
api_key_required401Send your API key in the API-Key header. You can find it in the merchant portal under Developer.
invalid_api_key401That API key was not recognised. Copy it again from the merchant portal under Developer and send it in the API-Key header.
merchant_inactive403This merchant account is switched off, so it cannot take payments right now. Email [email protected] to have it reactivated.
auth_required401We could not tell which account this request belongs to. Send your API key in the API-Key header.

The payment request

errorHTTPmessage
disabled503This payment service is not accepting new payments at the moment. Email [email protected] if you expected it to be open.
invalid_amount400The amount must be a number greater than zero, written in dollars — for example 12.50.
amount_too_small400That amount is too small for us to settle. Raise the order amount and try again.
amount_below_minimum400The order is below the minimum for this network. Raise the amount, or choose another network. (When the body carries the figure, the message names it: "The minimum order is $20.00 on this network…")
gas_uneconomic_for_amount400Network fees on that network are currently too high for an order this size — settling it would cost more than it is worth. Raise the amount, or choose a cheaper network such as Base. (With a figure: "…The smallest order worth settling there right now is about $39.00…")
unsupported_rail400We do not support that network and coin together. Call GET /api/rails to see the combinations you can use.
rail_unavailable400 / 503That network is not available for payments right now. Choose one of the other networks shown. If none of them work for you, email [email protected] and we will help you finish this order.
payment_not_open400This payment is no longer open — it has already been paid, cancelled or expired. Create a new payment link for your customer. (With a known state: "…it has already been paid." / "…has expired." / "…has been cancelled.")
not_found404We could not find that. If you sent an API key, note that a payment can only be read by the account that created it.

Merchant profile and settings

errorHTTPmessage
invalid_name400That business name is not valid. Use between 1 and 100 characters of ordinary text.
invalid_payout_address400That payout address is not valid. Use the full 42-character address that starts with 0x — this is where your money is sent, so check it character by character.
invalid_callback_url400That callback URL must be a public https address that resolves; internal or private hosts are not allowed.
invalid_logo_url400That logo URL must be a public https link to an image file. Internal or private addresses are not allowed.
logo_url_too_long400That logo URL is too long. Use a link of 300 characters or fewer.
nothing_to_update400Nothing in that request would change anything. Include at least one field you want to change, such as name or payout_address.

Transport and our side

errorHTTPmessage
rate_limited429Too many requests in a short time. Wait about a minute and try again.
server_error500Something went wrong on our side while handling this request. No money has moved. Try again in a moment — if it keeps happening, email [email protected] with the time and the reference.
unavailable503That information is not available right now. Try again shortly.
bad_json400We could not read that request body — it is not valid JSON. Check the payload and the Content-Type header.
payload_too_large413That request body is too large. Keep it under 64 kB.

Codes with no plain-English message

These are returned as a bare {"error": "..."} — no message key. Verified; handle a missing message gracefully everywhere.

errorHTTPEndpoint
invalid_json400/sandbox/keys
invalid_address / unsupported_chain / amount_too_large / chain_unavailable / faucet_failed400 / 503 / 500/sandbox/api/v1/faucet — these do carry a message, plus an ok:false
provisioning_disabled / provisioning_failed / provisioning_upstream_failed503 / 502/sandbox/keys

How to handle errors, in one rule

if (res.status >= 500) {
  // our fault. Retry with backoff. No money has moved.
  // Log body.reference — it is how we find your exact failure.
} else if (res.status === 429) {
  // back off and retry.
} else if (res.status >= 400) {
  // PERMANENT client error. Do NOT retry the identical request.
  // Branch on body.error. Show body.message to a human.
}

The one trap: 401 api_key_required is permanent, not transient. A missing header will never fix itself, so a retry-with-backoff loop on it runs forever and never surfaces the misconfiguration.

7 · Rails & limits

A rail is one (chain, currency) pair. It is the unit everything else is parameterised by: the deposit address, the confirmation depth, the minimum, the explorer and the payout are all per-rail. Read the live list from GET /api/rails rather than hard-coding these tables — a rail that is temporarily unavailable disappears from that endpoint, and this page cannot.

Live rails

ChainChain IDCurrencyContractMin orderConfirmationsExplorer
base8453USDC0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913$56basescan.org
polygon137USDC0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359$520polygonscan.com
polygon137USDT0xc2132D05D31c914a87C6611C10748AEb04B58e8F$520polygonscan.com
ethereum1USDC0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48$2012etherscan.io
ethereum1PYUSD0x6c3ea9036406852006290770BEdFcAbA0e23A0e8$2012etherscan.io

Captured from GET https://pay.epayvista.com/api/rails. Every enabled rail is a 1:1-USD stablecoin with 6 decimals, so a USD amount converts to token units with no price feed and no rounding surprise.

Sandbox rails

ChainChain IDCurrencyContractMin orderConfirmationsExplorer
base84532USDC0x036CbD53842c5426634e7929541eC2318f3dCF7enone2sepolia.basescan.org
polygon80002USDC0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582none5amoy.polygonscan.com
ethereum11155111USDC0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238none3sepolia.etherscan.io
base84532TUSDC0x3764Fac8d919a905EA0Dae18d65fCe5D40e21000none2sepolia.basescan.org
ethereum11155111TUSDC0xf93fc68f9aB8cEa8750D45b5fE7956Eb9120F944none3sepolia.etherscan.io

The USDC rails are Circle's official testnet deployments, kept exactly as they are so what you test against matches the real contract.

tUSDC — the dispensable test token

Name
ePayVista Test USD
Symbol
tUSDC
Decimals
6 — same as USDC
Chains
Base Sepolia, Ethereum Sepolia

tUSDC uses 6 decimals deliberately, exactly like USDC, so every amount calculation your integration performs is the same code path it will run on a live rail. Its mint() is unrestricted and the faucet dispenses from it, so it cannot run dry. It is a valueless test token and exists nowhere but the sandbox.

There is no tUSDC on Polygon Amoy, and there will not be while the caveat below holds.

Minimums — why they exist and where they bite

FloorWhereWhy
$5Base and Polygon order minimum, liveGas on these chains is near-zero but not free; below $5 the fixed cost of settling stops being a small share of the order.
$20Ethereum order minimum, liveL1 gas is real money — $1–3 per transaction, and settling an order takes several. A $20 floor keeps that a small percentage of the order.
$2Card tile on the hosted checkoutBelow $2 the card tile never renders, on any chain. This is a display rule, not an order rule — a $1.50 order is still perfectly payable from a wallet.
~$0.05Absolute floor, every environmentAn amount at or below the underpayment tolerance would auto-complete for free. Refused as amount_too_small.
noneSandbox, every railAll sandbox rails report minOrderUsd: 0 — verified. Test your under-minimum handling against live figures, not sandbox behaviour.

The merchant pays the network fee. Settling an order costs gas on the chain it settles on, and that cost comes out of the merchant's side of the order — which is exactly why Ethereum's floor is $20 and Base's and Polygon's are $5. On top of the static floor, live orders also pass a gas-economics check at creation: an order that current network fees would make uneconomic is refused with gas_uneconomic_for_amount rather than settled at a loss. The practical consequence is that the effective Ethereum minimum can rise above $20 during a fee spike. Read min_amount_usd from the error body and surface it to your customer.

Rate limits

EndpointLimitOn breach
POST /sandbox/keys3 per address per day, 40 globally per day429 rate_limited with scope
POST /sandbox/api/v1/faucet10 per IP per hour, 200 globally per hour429 rate_limited with scope
Everything elseNo per-endpoint quota

The core payment API is not rate limited. POST /api/v1/payment, the reference reads, /api/status, /api/rails and the merchant self-serve endpoints carry no quota. Be reasonable — polling faster than the 15-second settlement tick gains you nothing — but you will not be throttled for normal integration traffic.

8 · Going live

The request shapes, the auth header, the response fields, the error codes and the signature scheme are identical between sandbox and live. In principle the only things that change are the base URL and the key.

Getting the live key takes one visit to the portal. Sign in, open Developer, and your live key and callback secret are there immediately — there is no onboarding gate, no review queue, no call and nobody to email. In practice, check these before you switch:

 Check
1Base URL from config, not hard-coded. One environment variable, two values. Nothing else in your code should differ.
2Live key installed, sandbox key removed. The live key is the one the merchant portal shows under Developer — copy it from there. If your .env still has a key starting epv_sbx_, live calls return 401 invalid_api_key.
3Payout wallet is correct and you control it. Check it character by character with POST /api/v1/merchant/profile and read the response back. This is where your money goes; a wrong address is not recoverable.
4Callback URL points at production, over https, and is publicly reachable. Set it with POST /api/v1/merchant/callback and confirm changed: true.
5Live callback secret is installed — it is not your sandbox secret. Verifying live webhooks with the sandbox secret fails every signature.
6Your webhook handler reads the RAW body and is idempotent on reference_id. Both are load-bearing. See §5.
7You map both FILLED and CLOSED to paid. The single most common integration bug.
8Minimums are handled. Sandbox enforces none; live enforces $5 / $20 plus a live gas check. Decide what your checkout shows for amount_below_minimum and gas_uneconomic_for_amount before a customer meets it.
9Your network picker is built from /api/rails, not from a hard-coded list. Rails come and go with gas funding.
10You handle a non-JSON and a nested-error response without throwing. See §6.
11You log reference_id on every order and can search by it. It is the only thing support can trace.
12Run one real payment at the smallest live amount your rail allows, end to end, and confirm the webhook lands and the order flips to paid.

FAQ

Where does my money actually go?

To the payout wallet on your account, minus the fee, sent from the order's own deposit address once the deposit confirms. Set it with POST /api/v1/merchant/profile and read the value back from the response. You hold the keys to that wallet; ePayVista never custodies your funds beyond the moments between confirmation and payout.

I changed my payout wallet. What happens to orders already open?

They settle to the old address. Every session snapshots its destination at creation and settlement reads that snapshot, so a wallet change applies only to sessions created afterwards. This is deliberate — it is what stops a stolen key redirecting a checkout that is already in flight. If you need an in-flight order redirected, email us.

How do I change my callback URL?

POST /api/v1/merchant/callback with your API key. The URL must be a public http(s) host that resolves; loopback and private ranges are refused. Your signing secret does not change. As a side effect, the call clears any parked-webhook state and re-queues undelivered webhooks.

How do I rotate my API key or callback secret?

There is no self-serve rotation for either. Email [email protected] and a new credential is issued with the old one deactivated at the same time. Plan a short window where both may be in play and make sure your webhook handler can accept a signature from either secret across the switch.

The money arrived but my store still shows the order unpaid.

Almost always a deactivated plugin. Your funds were swept and paid out before the first delivery attempt — that part completed. What failed is the notification: your callback answered 404 twice in a row, so the engine parked your webhooks rather than burning retries against a route that no longer exists.

The fix, in order:

  1. Reactivate the ePayVista plugin on your store.
  2. Open its settings and save them — that calls /merchant/callback, which clears the parked state and re-queues every parked webhook for the next tick.
  3. If you are not on a plugin, call /merchant/callback yourself with the correct URL. Same effect.

If nothing arrives within a few minutes after that, check that the URL is publicly reachable and answers 2xx to a POST, then email us with the reference_id. Confirm with GET /api/status/:ref: if it says paid: true, the money is with you and only the notification is outstanding.

Can I use a sandbox key on live, or the other way round?

No. They are separate databases. A sandbox key on the live base URL, or a live key on the sandbox base URL, returns 401 invalid_api_key. Every sandbox key is prefixed epv_sbx_ precisely so that mistake is visible in a config file at a glance; live keys are not prefix-guaranteed, so keep the two in clearly named variables.

Am I going to get rate limited?

Not on the core payment API — it carries no quota. The only limits that apply to you are on the two sandbox-only endpoints: sandbox key issuance and the tUSDC faucet. See §7. If you are polling, one request per session per 15 seconds is the most that can tell you anything new.

Where do I get testnet gas and testnet USDC?

For tUSDC, nowhere — use our faucet, and if you mint straight to the deposit address you do not need gas at all. For the Circle USDC rails and for the native gas you need to send your own transfer:

WhatWhere
Testnet USDC (Base Sepolia, Ethereum Sepolia, Polygon Amoy)faucet.circle.com — Circle's own faucet
Base Sepolia ETH (gas on Base Sepolia — the rail you should test on)Coinbase Developer Platform, Alchemy or QuickNode
Sepolia ETH (gas on Ethereum Sepolia)The same three providers also run Ethereum Sepolia faucets
Amoy POL (gas on Polygon Amoy)faucet.polygon.technology

Testnet gas is the developer's responsibility — our faucet mints tUSDC and nothing else. Public gas faucets rate-limit, sometimes ask for a mainnet balance, and are sometimes simply down, which is the whole reason tUSDC exists: mint the exact order amount straight to the deposit address and you need no gas at all.

Is there a webhook for anything other than "paid"?

No. Creation, expiry, underpayment and refunds have no webhook. Poll /api/status/:ref for those, and read §4 for what an order that stays OPEN might mean.

Can I take a payment without using the hosted checkout?

Yes. Create the session, read address, amountInUSD, currencyCode and chainId from /payment/reference/:ref, and render those yourself. Poll /api/status/:ref from the browser without a key. Everything the hosted page does, those two endpoints expose.

9 · Changelog

DateChange
2026-09-10This reference now covers the merchant integration surface only. The endpoints the hosted checkout page calls on the customer's behalf are no longer documented here — they are internal to that page and may change. No merchant-facing endpoint, field, error code or limit changed.
2026-09-10The API is free to use. No per-call charge and no quota on the core payment endpoints. Fees are taken from settled payments only.
2026-09-10Reference reads are merchant-scoped when a key is sent. Anonymous reads are unchanged, so the hosted checkout is unaffected. A key that does not own the reference now gets 404, closing an enumeration path. See §2.
2026-09-10Every error carries a plain-English message. The machine error code, the HTTP status and every other key are unchanged — one key added, nothing altered. Existing integrations are unaffected. See §6.
2026-09-10Webhooks are parked, not burned, when a store's plugin is deactivated. Two consecutive 404s on a WordPress-shaped callback pause delivery instead of spending 30 retries; the backlog is re-queued automatically when the route answers again, or immediately when the callback URL is set. See §5.
2026-09-10explorerTransaction is now populated on a settled reference read, built from the same explorer base the webhook uses, so the two can never disagree.
2026-09-10Sandbox gains Polygon Amoy and Ethereum Sepolia rails, plus tUSDC and its faucet.
2026-09-09Underpayment tolerance became proportional. The larger of $0.05 or 5% of the order now settles and completes; beyond that the funds are still forwarded, but the order is not completed. See §4.