ePayVista · API Reference
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.
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.
| Environment | Base URL | Money |
|---|---|---|
| Live | https://pay.epayvista.com | Real funds on Base, Polygon and Ethereum mainnet. |
| Sandbox | https://pay.epayvista.com/sandbox | Testnet 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 | Sandbox | |
|---|---|---|
| Networks | Base (8453), Polygon (137), Ethereum (1) | Base Sepolia (84532), Polygon Amoy (80002), Ethereum Sepolia (11155111) |
| Currencies | USDC, 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 check | None. Every rail reports minOrderUsd: 0 |
| Confirmations | Base 6, Polygon 20, Ethereum 12 | Base 2, Polygon 5, Ethereum 3 |
| Getting a key | Merchant portal → Developer | POST /sandbox/keys, self-serve, no account |
| Key shape | Copy 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 tokens | — | POST /sandbox/api/v1/faucet mints tUSDC on demand |
| Link expiry (UI) | 7 days | 60 minutes |
| Hosted checkout | Multi-rail checkout page | Self-contained /pay/:ref page |
| Rate limits | Per-endpoint, see §7 | Same, plus 3 key issuances per address per day and 10 faucet calls per IP per hour |
| Data retention | Permanent | Cleared periodically without notice |
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.
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.
| Group | Auth |
|---|---|
Create a payment — POST /api/v1/payment | Required. There is no keyless creation in any environment. |
Merchant self-serve — /merchant/callback, /merchant/profile, /merchant/logo | Required. |
Reference reads — /payment/reference/:ref and its siblings, /api/status/:ref | Optional, and the behaviour changes — see below. |
Public data — /api/rails | None. |
Sandbox-only — /sandbox/keys, /sandbox/api/v1/faucet | None. |
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.
| Request | Result |
|---|---|
No API-Key header | 200 — public read, unchanged. This is the checkout path. |
| Your key, your reference | 200 — the session. |
| Your key, another account's reference | 404 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 key | 401 invalid_api_key. The credential itself is what is wrong; this says nothing about whether the reference exists. |
| Valid key, deactivated account | 403 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.
GET /api/status/:ref or
GET /api/v1/payment/reference/:ref without a key. Both
are designed for exactly that.epv_sbx_, so a
sandbox key that has leaked into a live config is visible in a .env at a glance.
Live keys have no single fixed shape — copy yours from the portal rather than pattern-matching
it. A sandbox key on the live base URL returns 401 invalid_api_key, and vice
versa — they are different databases.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.
Create a payment session. This is the only endpoint that mints a deposit address, and the only one you strictly need.
| Field | Type | Req. | Constraints |
|---|---|---|---|
| amountInUSD | number or numeric string | yes | Finite and > 0. Rounded to 2 decimal places internally. Must be above the rail's minOrderUsd and above the underpayment tolerance (see amount_too_small). |
| customerID | string | no | Your 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. |
| customerEmail | string | no | Stored on the invoice and included in your own paid-notice email. Not emailed to the buyer from this route. |
| blockchainCode | string | no | Case-insensitive. Default base. Aliases accepted: chain, chainCode. |
| currencyCode | string | no | Case-insensitive. Default USDC. Aliases accepted: currency, token. |
| returnUrl | string | no | http(s) only, must parse as a URL, ≤ 500 chars. Where the buyer returns after paying. Anything else is silently dropped to null. |
| cancelUrl | string | no | Same rules. Where "change payment method" points. |
| orderSummary | string | no | Display 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. |
| show | string | no | Comma-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.
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"
}'
{
"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.
| HTTP | error | When |
|---|---|---|
| 401 | api_key_required | No API-Key header. Permanent — do not retry with backoff. |
| 401 | invalid_api_key | Key matches no account in this environment. |
| 403 | merchant_inactive | Key is valid but the account is switched off. |
| 400 | invalid_amount | amountInUSD missing, non-numeric, zero or negative. |
| 400 | amount_too_small | Amount is at or below the underpayment tolerance, so it would auto-complete for free. Below about $0.05. |
| 400 | amount_below_minimum | Below this rail's floor. Body also carries minimum_usd, chain, currency, and the message quotes the figure. Live only. |
| 400 | gas_uneconomic_for_amount | Live 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. |
| 400 | unsupported_rail | That (chain, currency) pair is not enabled. Body carries the chain and currency you sent. Never falls back silently. |
| 400 | bad_json | Body is not valid JSON. Also carries a reference. |
| 413 | payload_too_large | Request body over 64 kB. Also carries a reference. |
| 503 | rail_unavailable | That rail is temporarily unavailable. Choose another chain. |
| 503 | disabled | The whole service is closed to new payments. |
| 500 | server_error | Our fault. No money has moved. Body carries a short reference to quote to support. |
# 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"}
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
{
"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"
}
{
"linkExpired": false,
"paymentState": "FILLED",
"amountInUSD": "3.50",
"confirmationRequired": 1,
"confirmationCurrent": 1,
"filledAmount": "3.50",
"explorerTransaction": "https://sepolia.basescan.org/tx/0xb2519bb6...537701",
...
}
| Field | Type | Meaning |
|---|---|---|
| paymentState | string | OPEN or FILLED. Only these two values are ever returned here. FILLED means paid — the webhook calls the same state CLOSED. |
| linkExpired | boolean | true once the link is dead: unpaid and no longer payable on any rail. A dead link must not be shown a deposit address. |
| amountInUSD | string | The quoted amount, 2 d.p. Compare as a string or parse as decimal — never as a float. |
| confirmationRequired | number | Always 1. This is a UI gate, not the chain's confirmation count. |
| confirmationCurrent | number | 0 while open, 1 the instant the order settles. |
| filledAmount | string or null | The settled amount, or null while open. |
| explorerTransaction | string or null | Block-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 / cancelURL | string | Your returnUrl / cancelUrl, or "". |
| invoiceID / referenceID | string | Both equal the reference. Two names for one value, kept for client compatibility. |
| address | string | The deposit address for this session's own rail. Unique to this order. |
| blockchainCode | string | Upper-cased chain key, e.g. BASE. |
| currencyCode | string | The token this session settles in. |
| chainId | number or null | EVM chain id. |
| url | string | Canonical hosted checkout URL for this reference. |
| payoutHeld | boolean | true 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. |
| payoutHoldReason | string or null | Why 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. |
| HTTP | Body | When |
|---|---|---|
| 401 | invalid_api_key | A key was sent and it is not recognised. |
| 403 | merchant_inactive | Key valid, account switched off. |
| 404 | not_found | You 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. |
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." }
| Field | Meaning |
|---|---|
| paid | Order-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. |
| status | PAID 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_id | Echoed 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.
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.
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.
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 }
| Field | Type | Req. | Constraints |
|---|---|---|---|
| callback_url | string | yes | See the validation rules below. Max 300 characters. Empty string is rejected — there is no "clear" operation. |
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:
| Rejected | Why |
|---|---|
| ftp://example.com/hook | Scheme must be http or https. |
| http://localhost:9000/hook | Loopback. |
| http://10.0.0.5/hook | Private RFC-1918 range. Also 172.16/12, 192.168/16, 169.254/16, CGNAT. |
| http://intranet/hook | Bare hostname with no dotted public domain. |
| https://does-not-exist.invalid/hook | The host must actually resolve, to a public address. DNS is checked at set time. |
| a non-string value | Type-checked before anything else. |
| over 300 characters | Length 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.
| HTTP | error |
|---|---|
| 401 | api_key_required / invalid_api_key |
| 403 | merchant_inactive |
| 400 | invalid_callback_url |
| 500 | server_error |
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 }
| Field | Type | Req. | Constraints |
|---|---|---|---|
| name | string | no | The name buyers see on the payment page. Control characters become spaces, whitespace collapses, truncated to 120 chars. Must be non-empty after cleaning. |
| payoutAddress | string | no | Full 42-character 0x EVM address; checksum-validated. Future settlements only — see below. |
| notifyPaid | boolean or string | no | Paid-notice email opt-out. false, 0, "0", "false", "off" and "no" all mean off; anything else means on. |
| HTTP | error | When |
|---|---|---|
| 401 | api_key_required | No header. This endpoint requires an explicit key in every environment. |
| 401 | invalid_api_key | Unrecognised key. |
| 403 | merchant_inactive | Account switched off. |
| 400 | nothing_to_update | All three fields absent. Verified on {}. |
| 400 | invalid_name | name empty after cleaning. Verified on " ". |
| 400 | invalid_payout_address | Not a valid address. Verified on "0x123". |
| 500 | server_error | Our fault. |
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." }
reason | What it means |
|---|---|
| malformed | Not a 42-character 0x address at all. Verified on "0x123". |
| burn_or_zero | A burn or zero address — no key exists for it. Verified on 0x…dEaD. |
| token_contract | A stablecoin token contract. Verified on 0xA0b86991…eB48 (USDC on Ethereum). |
| example_address | A documentation or example address, such as 0x1111…1111. |
| contract_address | The 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": "" }
| Field | Type | Req. | Constraints |
|---|---|---|---|
| logo_url | string | yes | https only. Must parse as a URL. Host must not be private or loopback. Max 300 characters. "" clears the logo. |
| HTTP | error | When |
|---|---|---|
| 400 | invalid_logo_url | Not a string; not https (verified on http://); unparseable; or a private host (verified on https://192.168.1.5/a.png). |
| 400 | logo_url_too_long | Over 300 characters. Verified. |
| 401 | invalid_api_key | Unrecognised or missing key. |
| 403 | merchant_inactive | Account switched off. |
| 500 | server_error | Our 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.
The Chrome extension and the AI-agent (MCP) endpoint create links the same way; use them or the API, not both.
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"
}'
| Field | Type | Req. | Constraints |
|---|---|---|---|
| string | no | Trimmed, truncated to 200 chars. Used only so we can reach you about the sandbox. | |
| storeUrl | string | no | Trimmed, 300 chars. Defaults to a placeholder. A unique suffix is appended so two developers never merge onto one account. |
| name | string | no | Trimmed, 80 chars. Defaults to "Sandbox developer". A [sbx …] marker is appended. |
| payoutAddress | string | no | Must 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.) |
| callbackUrl | string | no | Must be http(s) and ≤ 300 chars, else replaced with a discard endpoint. Change it later with /merchant/callback. |
{
"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.
| HTTP | error | When |
|---|---|---|
| 400 | invalid_json | Body is not valid JSON. Verified — note this service returns {"error":"invalid_json"} with no message key. |
| 429 | rate_limited | 3 issuances per address per day, plus a global daily cap of 40. Body carries scope: "ip" or "global", and a message. |
| 502 | provisioning_upstream_failed / provisioning_failed | The sandbox engine could not be reached or refused. Retry shortly. |
| 503 | provisioning_disabled | Self-serve provisioning is switched off. |
| 500 | server_error | Our fault. |
A GET on the same URL returns this documentation page, not JSON.
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}'
| Field | Type | Req. | Constraints |
|---|---|---|---|
| address | string | yes | Valid 0x 20-byte EVM address. The zero address is explicitly refused. |
| chain | string | no | Default base. Chains with a tUSDC rail: currently base and ethereum. |
| amount | number | no | Default 100, maximum 10000 per call. Quantised to 6 decimal places. |
{
"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.
| HTTP | error | When |
|---|---|---|
| 400 | invalid_address | Not a valid address, or the zero address. Verified. |
| 400 | unsupported_chain | No tUSDC rail on that chain. Body lists the supported ones. Verified on polygon. |
| 400 | invalid_amount | Not a positive number. Verified on -1. |
| 400 | amount_too_large | Over 10000. Body carries max. Verified on 99999. |
| 429 | rate_limited | 10 per IP per hour, 200 globally per hour. Body carries scope. |
| 503 | chain_unavailable | That chain is temporarily unavailable in the sandbox. Use another one. |
| 500 | faucet_failed | The 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"] }
Two URLs render a checkout. Both take the reference; both accept exactly one query parameter that changes what the buyer sees.
| URL | Parameters | Behaviour |
|---|---|---|
| /pay/:reference_id | show | The self-contained deposit page. show is read client-side from the query string. |
| /payments?reference_id=… | reference_id (required), show | The multi-rail checkout shell. |
reference_idOn /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.
showA comma-separated list that filters the payment-method tiles. Case-insensitive.
| Value | Accepted by the API's show field | Recognised by the checkout page |
|---|---|---|
| wallet | yes | yes — crypto group |
| exchange | yes | yes — crypto group |
| stripe | yes | yes — card group |
| changelly | yes | yes — card group |
| banks | no — dropped by the API | yes — card group |
| coinbase | yes | no — 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.
There are two vocabularies for one lifecycle, and they never converge:
| Layer | Field | Values you will ever see |
|---|---|---|
| Reference read | paymentState | OPEN, FILLED |
| Status read | status | OPEN, PAID, EXPIRED, plus the internal values below |
| Webhook | status | CLOSED — the only value ever sent |
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.
| Status | Meaning |
|---|---|
| OPEN | Awaiting a deposit. In the 15-second detection loop. |
| EXPIRED | Unpaid 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. |
| PAID | Deposit detected and confirmed. Funds are being paid out to your payout wallet. |
| NOTIFIED | Paid out; a webhook delivery has been attempted and failed at least once, and more attempts remain. |
| CLOSED | Terminal success. Paid out and your endpoint answered 2xx. |
| WEBHOOK_FAILED | Paid 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. |
| SUPERSEDED | A 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. |
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)
| Step | Timing |
|---|---|
| Detection poll | Every 15 seconds, for every open session, on every rail. |
| Confirmations required | Live: Base 6, Polygon 20, Ethereum 12. Sandbox: Base 2, Polygon 5, Ethereum 3. Polygon's is deeper because it reorgs deeper. |
| Balance-proof fallback | If 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. |
| Payout | Starts 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. |
| Webhook | Fired once the funds have left for your wallet. Not before. |
| Display expiry | Live 7 days, sandbox 60 minutes. UI-only: the address stays watched. |
| Late-deposit grace | 7 days from creation. After that the link is dead and nothing watches the address. |
linkExpired and dead linkslinkExpired: 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.
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.
| Shortfall | What 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% short | The 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.
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.
explorerTransactionBuilt 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.
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.
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.
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.
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.
Exactly seven fields, in this order. Nothing is ever added or omitted.
| Field | Type | Meaning |
|---|---|---|
| reference_id | string | The 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. |
| status | string | Always "CLOSED". It is the only value this field ever takes; a webhook is only sent for a settled payment. |
| txid | string | Settlement 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_usd | string | The 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_code | string | Upper-cased chain the payment settled on, e.g. "BASE". |
| confirmations | number | The chain's confirmation requirement at delivery — 2 on Base Sepolia, 6 on Base mainnet, and so on. |
| explorer_url | string | Block-explorer link, or "" when there is no real hash. |
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.)
X-EPV-Signature = hex( HMAC_SHA256( raw request body bytes, callback_secret ) )
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Key | Your callback_secret, used as the raw UTF-8 string — not hex-decoded first |
| Message | The exact bytes of the request body |
| Encoding | Lowercase hex, 64 characters |
| Prefix | None. No sha256=, no version marker |
| Timestamp | None. 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 |
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);
});
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
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.
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:
reference_id. It is stable across every retry of one order.200 and do nothing else.reference_id.200 even for a duplicate — a non-2xx makes us retry a webhook you have
already handled.txid as the key: it is "" or an internal sentinel for a
balance-proof settlement.| Phase | Behaviour |
|---|---|
| Burst | One 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 attempts | The order goes WEBHOOK_FAILED. Your funds are already swept and paid out. Only the notification failed. |
| Hourly re-delivery | Every 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 days | Automatic retries stop. The order is still settled and paid out; email us with the reference for a manual re-delivery. |
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.
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.
{
"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.
/payment/reference/:ref returns
401 {"error":{"code":"UNAUTHORIZED","message":"Unauthorised access!"}} — a
nested object, not two strings. Guard your parser: check
typeof body.error === 'string' before using it as a code.DELETE /api/rails →
404, Cannot DELETE /api/rails as HTML. Do not assume every response is
parseable JSON; branch on the status code and the content type first.Every code the engine can return, its HTTP status, and the exact message text.
| error | HTTP | message |
|---|---|---|
| api_key_required | 401 | Send your API key in the API-Key header. You can find it in the merchant portal under Developer. |
| invalid_api_key | 401 | That API key was not recognised. Copy it again from the merchant portal under Developer and send it in the API-Key header. |
| merchant_inactive | 403 | This merchant account is switched off, so it cannot take payments right now. Email [email protected] to have it reactivated. |
| auth_required | 401 | We could not tell which account this request belongs to. Send your API key in the API-Key header. |
| error | HTTP | message |
|---|---|---|
| disabled | 503 | This payment service is not accepting new payments at the moment. Email [email protected] if you expected it to be open. |
| invalid_amount | 400 | The amount must be a number greater than zero, written in dollars — for example 12.50. |
| amount_too_small | 400 | That amount is too small for us to settle. Raise the order amount and try again. |
| amount_below_minimum | 400 | The 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_amount | 400 | Network 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_rail | 400 | We do not support that network and coin together. Call GET /api/rails to see the combinations you can use. |
| rail_unavailable | 400 / 503 | That 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_open | 400 | This 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_found | 404 | 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. |
| error | HTTP | message |
|---|---|---|
| invalid_name | 400 | That business name is not valid. Use between 1 and 100 characters of ordinary text. |
| invalid_payout_address | 400 | That 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_url | 400 | That callback URL must be a public https address that resolves; internal or private hosts are not allowed. |
| invalid_logo_url | 400 | That logo URL must be a public https link to an image file. Internal or private addresses are not allowed. |
| logo_url_too_long | 400 | That logo URL is too long. Use a link of 300 characters or fewer. |
| nothing_to_update | 400 | Nothing in that request would change anything. Include at least one field you want to change, such as name or payout_address. |
| error | HTTP | message |
|---|---|---|
| rate_limited | 429 | Too many requests in a short time. Wait about a minute and try again. |
| server_error | 500 | 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. |
| unavailable | 503 | That information is not available right now. Try again shortly. |
| bad_json | 400 | We could not read that request body — it is not valid JSON. Check the payload and the Content-Type header. |
| payload_too_large | 413 | That request body is too large. Keep it under 64 kB. |
These are returned as a bare {"error": "..."} — no message key.
Verified; handle a missing message gracefully everywhere.
| error | HTTP | Endpoint |
|---|---|---|
| invalid_json | 400 | /sandbox/keys |
| invalid_address / unsupported_chain / amount_too_large / chain_unavailable / faucet_failed | 400 / 503 / 500 | /sandbox/api/v1/faucet — these do carry a message, plus an ok:false |
| provisioning_disabled / provisioning_failed / provisioning_upstream_failed | 503 / 502 | /sandbox/keys |
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.
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.
| Chain | Chain ID | Currency | Contract | Min order | Confirmations | Explorer |
|---|---|---|---|---|---|---|
| base | 8453 | USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | $5 | 6 | basescan.org |
| polygon | 137 | USDC | 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 | $5 | 20 | polygonscan.com |
| polygon | 137 | USDT | 0xc2132D05D31c914a87C6611C10748AEb04B58e8F | $5 | 20 | polygonscan.com |
| ethereum | 1 | USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | $20 | 12 | etherscan.io |
| ethereum | 1 | PYUSD | 0x6c3ea9036406852006290770BEdFcAbA0e23A0e8 | $20 | 12 | etherscan.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.
| Chain | Chain ID | Currency | Contract | Min order | Confirmations | Explorer |
|---|---|---|---|---|---|---|
| base | 84532 | USDC | 0x036CbD53842c5426634e7929541eC2318f3dCF7e | none | 2 | sepolia.basescan.org |
| polygon | 80002 | USDC | 0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582 | none | 5 | amoy.polygonscan.com |
| ethereum | 11155111 | USDC | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 | none | 3 | sepolia.etherscan.io |
| base | 84532 | TUSDC | 0x3764Fac8d919a905EA0Dae18d65fCe5D40e21000 | none | 2 | sepolia.basescan.org |
| ethereum | 11155111 | TUSDC | 0xf93fc68f9aB8cEa8750D45b5fE7956Eb9120F944 | none | 3 | sepolia.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 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.
| Floor | Where | Why |
|---|---|---|
| $5 | Base and Polygon order minimum, live | Gas on these chains is near-zero but not free; below $5 the fixed cost of settling stops being a small share of the order. |
| $20 | Ethereum order minimum, live | L1 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. |
| $2 | Card tile on the hosted checkout | Below $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.05 | Absolute floor, every environment | An amount at or below the underpayment tolerance would auto-complete for free. Refused as amount_too_small. |
| none | Sandbox, every rail | All 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.
| Endpoint | Limit | On breach |
|---|---|---|
| POST /sandbox/keys | 3 per address per day, 40 globally per day | 429 rate_limited with scope |
| POST /sandbox/api/v1/faucet | 10 per IP per hour, 200 globally per hour | 429 rate_limited with scope |
| Everything else | No 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.
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 | |
|---|---|
| 1 | Base URL from config, not hard-coded. One environment variable, two values. Nothing else in your code should differ. |
| 2 | Live 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. |
| 3 | Payout 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. |
| 4 | Callback URL points at production, over https, and is publicly reachable. Set it with POST /api/v1/merchant/callback and confirm changed: true. |
| 5 | Live callback secret is installed — it is not your sandbox secret. Verifying live webhooks with the sandbox secret fails every signature. |
| 6 | Your webhook handler reads the RAW body and is idempotent on reference_id. Both are load-bearing. See §5. |
| 7 | You map both FILLED and CLOSED to paid. The single most common integration bug. |
| 8 | Minimums 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. |
| 9 | Your network picker is built from /api/rails, not from a hard-coded list. Rails come and go with gas funding. |
| 10 | You handle a non-JSON and a nested-error response without throwing. See §6. |
| 11 | You log reference_id on every order and can search by it. It is the only thing support can trace. |
| 12 | Run 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. |
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.
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.
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.
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.
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:
/merchant/callback, which clears the parked state and
re-queues every parked webhook for the next tick./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.
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.
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.
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:
| What | Where |
|---|---|
| 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.
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.
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.
| Date | Change |
|---|---|
| 2026-09-10 | This 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-10 | The 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-10 | Reference 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-10 | Every 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-10 | Webhooks 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-10 | explorerTransaction 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-10 | Sandbox gains Polygon Amoy and Ethereum Sepolia rails, plus tUSDC and its faucet. |
| 2026-09-09 | Underpayment 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. |