RENOVAX Payments API
REST API for merchant operations (payment links, refunds) and reseller operations (user provisioning, credit sales). All responses are JSON. Authentication uses Bearer tokens.
Overview
The API exposes two independent surfaces, each with its own token type:
- Merchant API — scoped to a single merchant. Use for creating payment links, fetching invoices, and issuing refunds. Authenticated with a merchant token.
- Reseller API — scoped to a user with the
reseller,distributor, oradminrole. Use for provisioning end-user accounts and selling credits. Authenticated with a user token.
Base URL: https://payments.renovax.net/api/v1
Endpoints are versioned under /api/v1/. The unversioned
/api/ paths are kept for backward compatibility but new
integrations should use v1.
Authentication
Generate tokens from the dashboard:
- Merchant token: Merchant → Edit → API Tokens. Shown once; store it securely.
- User token: Profile → API Tokens. Shown once; store it securely.
Send the token as a Bearer header on every request:
Authorization: Bearer <your_token>
Accept: application/json
Content-Type: application/json
Merchant tokens can only call /merchant/* endpoints. User tokens can only call
/reseller/* and legacy /invoices endpoints.
Payment methods / drivers
RENOVAX Payments integrates the providers below. Every driver supports charge by default; the Refund and Capture / Void columns indicate whether the matching Merchant API endpoints work end-to-end with that driver. Drivers without automated refund or capture can still be operated manually from the provider's own portal.
Cards & traditional banking (fiat)
| Driver | Type | Refund (API) | Capture / Void (API) |
|---|---|---|---|
| Stripe | Fiat | ||
| PayPal | Fiat | ||
| Braintree | Fiat | ||
| Venmo | Fiat | — | — |
| Revolut | Fiat | ||
| Klarna | Fiat | ||
| Wise | Fiat | — | |
| Skrill | Fiat | — | — |
| Volet | Fiat | — | — |
| Paysera | Fiat | — | |
| Adyen | Fiat | ||
| Amazon Pay | Fiat | — |
LATAM (fiat)
| Driver | Type | Refund (API) | Capture / Void (API) |
|---|---|---|---|
| Mercado Pago | Fiat | — | |
| Asaas (Brasil) | Fiat | — | |
| PIX (Brasil) | Fiat | — | — |
Cuba (fiat)
| Driver | Type | Refund (API) | Capture / Void (API) |
|---|---|---|---|
| EnZona | Fiat | — | |
| Transfermóvil | Fiat | — | — |
Crypto
| Driver | Type | Refund (API) | Capture / Void (API) |
|---|---|---|---|
| Crypto (native on-chain) | Crypto | — | — |
| QvaPay | Crypto | — | |
| NodexPay | Crypto | — | |
| Binance Pay | Crypto | — | |
| Bybit Pay | Crypto | — | |
| Gate Pay | Crypto | — | |
| Coinbase Commerce | Crypto | — | — |
| NOWPayments | Crypto | — |
A dash (—) means the driver does not expose the corresponding API operation in RENOVAX Payments. Native on-chain crypto is irreversible by design — refunds require an out-of-band return transfer to the payer address. For any driver missing a check mark, the operation may still be available from the provider's own portal.
Merchant API
All endpoints below require a merchant token.
GET /merchant/me
Returns the authenticated merchant.
curl https://payments.renovax.net/api/v1/merchant/me \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"id": 7,
"name": "Acme Store",
"default_currency": "USD",
"public_uuid": "9c1f...-...-...",
"customer_pays_fee": false,
"fx_markup_percent": "1.000",
"is_active": true
}
GET /merchant/invoices
Paginated invoice list. Query params: status, per_page (max 100).
curl "https://payments.renovax.net/api/v1/merchant/invoices?status=confirmed&per_page=20" \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"data": [
{
"id": "01J...",
"merchant_id": 7,
"amount": "49.90",
"currency": "USD",
"status": "confirmed",
"client_remote_id": "order-42",
"remote_namespace": "shopify",
"success_url": "https://yoursite.com/thanks",
"cancel_url": "https://yoursite.com/cart",
"metadata": { "source": "checkout" },
"fiat_enabled": false,
"is_flexible": false,
"flexible_suggestions": null,
"expires_at": "2026-04-21T23:59:59+00:00",
"paid_at": "2026-04-21T23:12:03+00:00",
"pay_url": "https://.../pay/01J...",
"created_at": "2026-04-21T23:00:00+00:00"
}
],
"meta": { "current_page": 1, "last_page": 3, "per_page": 20, "total": 48 }
}
POST /merchant/invoices
Creates a payment link. Returns the invoice with a pay_url to share.
Fees and gas: the amount you pass is what the merchant receives net of
any on-chain costs. RENOVAX Payments calculates the platform fee and (for crypto)
the network gas reserve server-side and shows the final total to the customer at checkout.
Webhooks return amount_received (what the customer paid),
fee (platform fee) and amount_net (what the merchant got).
Internal fields like gas_reserve_usdt or expected_amount_usdt are
not exposed in the API — they're applied transparently at the checkout step.
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amount": 49.90,
"currency": "USD",
"client_remote_id": "order-42",
"remote_namespace": "shopify",
"success_url": "https://yoursite.com/thanks",
"cancel_url": "https://yoursite.com/cart",
"metadata": { "source": "checkout" },
"expires_in_minutes": 60,
"fiat_enabled": true
}'
currency defaults to the merchant's default_currency when omitted.
expires_in_minutes ranges 1–43200 (30 days).
fiat_enabled opts the invoice into fiat payment methods (Stripe / PayPal / etc.); defaults to the merchant's setting when omitted.
remote_namespace (optional, max 64 chars): prevents client_remote_id collisions if you migrate platforms.
When omitted, it auto-detects from the domain of success_url.
Optional anti-fraud fields — pass any of these to
improve AVS matching on fiat drivers, prefill the checkout, and have the values echoed back to you
in the webhook payload. All top-level fields are optional. billing.country /
shipping.country must be ISO-3166-1 alpha-2 (two uppercase letters) when sent — we
validate alpha + size:2 and normalize to uppercase server-side.
Required-with rule — if you send ANY of
billing.address_line1, billing.city, billing.postal_code or
billing.country, the four are required together (same rule for shipping.*).
Half-filled addresses are useless for AVS and we'd rather reject them with a 422 than have the
provider 400 the request opaquely. Names and phone stay optional.
shipping_same_as_billing — pass
true at the top level (boolean) and leave shipping empty to copy the
billing object into shipping server-side. If you also send a
shipping object, the explicit object wins (no copy). The flag is consumed at request
time and is not persisted on the invoice.
Whitelist — only the keys listed below are
persisted (first_name, last_name, address_line1,
address_line2, city, state, postal_code,
country, phone). Any other key inside billing or
shipping is silently dropped before persistence.
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amount": 49.90,
"currency": "USD",
"client_remote_id": "order-42",
"remote_namespace": "shopify",
"customer_name": "John Doe",
"customer_email": "[email protected]",
"shipping_same_as_billing": true,
"billing": {
"first_name": "John",
"last_name": "Doe",
"address_line1": "350 5th Ave",
"address_line2": "Suite 7800",
"city": "New York",
"state": "NY",
"postal_code": "10118",
"country": "US",
"phone": "+1-212-555-0142"
}
}'
Recommended minimum for AVS: billing.address_line1, billing.postal_code,
billing.country. Card-not-present processors that don't receive billing data tend to
score the transaction higher on their fraud model — leaving these blank silently increases false
positives. The data is also echoed in every invoice.* webhook so your backend can
reconcile it with your CRM/ERP without an extra GET.
Pass-through to providers — the following drivers forward your fields to the provider's API so they reach the provider's fraud engine and AVS check:
stripe—customer_email+shippingon Checkout Session and Wallet Intent. Billing address is collected by Stripe's UI and cannot be pre-filled via API.adyen—shopperEmail,shopperName,billingAddress,deliveryAddresson Pay by Link.braintree/venmo—customer,billing,shippingon Transaction.sale (AVS + Kount).paypal—payment_source.paypal.email_address+name;purchase_units[].shipping(shipping flips the experience fromNO_SHIPPINGtoSET_PROVIDED_ADDRESS).revolut—customer{full_name,email,phone}+shipping_addresson Orders API.klarna—billing_address+shipping_address(Klarna uses billing for BNPL credit decisioning).mercadopago—payer+shipments.receiver_addresson Preferences API.skrill—pay_from_email,firstname,lastname,address,address2,phone_number,city,state,postal_code,country(ISO-3 — automatic mapping from ISO-2).amazonpay—addressDetails(shipping) on Checkout Session v2. Falls back tobillingwhen noshippingis provided.paysera—p_email,p_firstname,p_lastname,p_street,p_city,p_state,p_zip,p_countrycodeon SCI v1.6 (extra params don't break the signature).nowpayments—customer_email(used for payer confirmation emails; crypto rails don't AVS).volet—ac_email(payer email pre-fill on SCI).
Drivers without a row above (wise, asaas, pix,
enzona, transfermovil, and the pure-crypto drivers crypto,
qvapay, nodexpay, binancepay, bybitpay,
gatepay, coinbase_commerce) still persist the data and
echo it in the webhook payload, but their provider API does not accept customer-data pre-fill at
checkout creation (the customer enters it on the hosted page, the rail is offline, or the gateway
doesn't expose those fields).
Response 201:
{
"id": "01J...",
"merchant_id": 7,
"amount": "49.90",
"currency": "USD",
"status": "pending",
"client_remote_id": "order-42",
"remote_namespace": "shopify",
"success_url": "https://yoursite.com/thanks",
"cancel_url": "https://yoursite.com/cart",
"metadata": { "source": "checkout" },
"fiat_enabled": true,
"is_flexible": false,
"flexible_suggestions": null,
"expires_at": "2026-04-21T23:59:59+00:00",
"paid_at": null,
"pay_url": "https://.../pay/01J...",
"created_at": "2026-04-21T23:00:00+00:00"
}
Idempotency key: (merchant_id, remote_namespace, client_remote_id).
If a pending invoice with the same key exists, it is returned with status 200 (retry safe).
If an invoice exists in any other status (confirmed, expired, cancelled, etc.),
it is reactivated back to pending with a fresh expiry — no duplicates.
Concurrent requests for the same key get 429
concurrent_request. If the same merchant has another pending invoice
with the same amount+currency within the dedup window you'll get
409 duplicate_amount. If the merchant's user balance is below zero
you'll get 402 negative_balance — top up credits to continue.
Flexible (open-amount) invoices
Set flexible: true to create an invoice without a fixed amount — the customer
types the amount on the checkout page (donations, tips, "pay what you want", recharges, etc.). The amount
the customer types is what the merchant receives; if customer_pays_fee is on,
the platform fee is added on top before the customer signs.
Optional suggestions renders quick-pick buttons in the checkout (max 10 entries; each
must be ≥ 1 or ≥ merchant.min_flexible_amount when set). When omitted,
the customer just sees a free-input field.
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"flexible": true,
"currency": "USD",
"client_remote_id": "donation-2026-q2",
"suggestions": [5, 10, 25, 50],
"metadata": { "campaign": "annual-donation" }
}'
Response 201:
{
"id": "01J...",
"amount": null,
"currency": "USD",
"status": "pending",
"is_flexible": true,
"flexible_suggestions": [5, 10, 25, 50],
"pay_url": "https://.../pay/01J...",
...
}
Notes for flexible invoices:
amountstaysnulluntil the customer types it on the checkout. Once they submit, the invoice updates to that value and the flow continues identically to a fixed-amount invoice.- Idempotency for flexible reuses any
pendinginvoice with the sameclient_remote_idandis_flexible: true(the typed amount can change between visits as long as no on-chain payment was detected yet). - The
duplicate_amountcheck is skipped at creation; it kicks in when the customer submits the amount in the checkout (so two pending flexible invoices can coexist before either is paid). - You can also pre-fill the amount via the share URL by appending
?amount=N:https://payments.renovax.net/api/v1/pay/01J...?amount=25. - The customer can leave an optional message that arrives in the webhook as
customer_note(max 500 chars, plain text).
Errors: 422 invalid_suggestion if a suggestion is below the merchant's
minimum or non-numeric. The minimum is the global floor of 1 unless the merchant raised
it via min_flexible_amount.
GET /merchant/invoices/{id}
Fetches a single invoice by id.
curl https://payments.renovax.net/api/v1/merchant/invoices/01J... \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"id": "01J...",
"merchant_id": 7,
"amount": "49.90",
"currency": "USD",
"status": "confirmed",
"client_remote_id": "order-42",
"success_url": "https://yoursite.com/thanks",
"cancel_url": "https://yoursite.com/cart",
"metadata": { "source": "checkout" },
"fiat_enabled": false,
"is_flexible": false,
"flexible_suggestions": null,
"expires_at": "2026-04-21T23:59:59+00:00",
"paid_at": "2026-04-21T23:12:03+00:00",
"pay_url": "https://.../pay/01J...",
"created_at": "2026-04-21T23:00:00+00:00"
}
POST /merchant/invoices/{id}/refund
Refunds a confirmed payment. Leave amount empty for a full refund.
Drivers with automated refund support via API:
stripe, paypal, revolut, klarna,
wise, adyen, nowpayments, paysera,
amazonpay, braintree, qvapay, nodexpay,
binancepay, asaas, bybitpay, gatepay,
mercadopago, enzona. For drivers without automated refund (native
on-chain crypto, coinbase_commerce, etc.) the merchant refunds manually from the
provider's portal.
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices/01J.../refund \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "amount": 10.00 }'
Response 200:
{
"status": "success",
"provider_refund_id": "re_1N...",
"amount": "10.00",
"currency": "USD",
"message": "Refunded successfully"
}
Returns 422 with one of:
no_confirmed_payment (the invoice has no captured payment yet),
unsupported_driver (refunds aren't available for the original
payment driver — e.g. crypto), or
amount_exceeds (requested amount is greater than the captured
amount). The provider may also return 422 if it rejects the refund (in that
case status is "failed" and message
explains why).
POST /merchant/invoices/{id}/capture
Captures funds for an invoice in authorized state
(manual-capture flow on Stripe / PayPal / Revolut / Klarna / Adyen / Braintree). Leave
amount empty to capture the full authorized amount, or send
a smaller amount for a partial capture (e.g. partial shipment).
Pass an Idempotency-Key header to safely retry
the same request — if the capture already succeeded under that key, you'll
get the cached response back without a second call to the provider.
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices/01J.../capture \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 9f3a-0c1e-4b2d" \
-H "Content-Type: application/json" \
-d '{ "amount": 80.00 }'
Response 200:
{
"status": "captured",
"provider_capture_id": "pi_3N...",
"amount": "80.00",
"currency": "USD"
}
Errors:
409 invalid_state (invoice not in authorized),
409 capture_in_progress (concurrent capture in flight),
422 amount_exceeds,
422 no_payment / unsupported_driver,
424 provider_error (the provider rejected the capture — see message),
501 if the driver does not support capture from RENOVAX Payments (capture from the provider portal instead).
After a successful capture, the merchant receives invoice.paid via webhook
when the provider sends the capture event. The HTTP response confirms RENOVAX Payments initiated
the call — the invoice transitions to confirmed on webhook arrival.
POST /merchant/invoices/{id}/void
Voids the authorization for an invoice in authorized
state. The funds held on the customer's payment method are released back —
the customer never sees a charge. Optionally include a structured
reason and free-text reason_note for your audit trail
(these travel in the outbound invoice.voided webhook).
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices/01J.../void \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 9f3a-0c1e-4b2d" \
-H "Content-Type: application/json" \
-d '{ "reason": "out_of_stock", "reason_note": "SKU 12345 sold out" }'
Response 200:
{
"status": "voided",
"provider_id": "pi_3N..."
}
Allowed reason values:
customer_request, out_of_stock,
fraud_suspected, other. Errors mirror the capture endpoint
(409 invalid_state, 422 no_payment/unsupported_driver,
424 provider_error, 501).
After a successful void, the merchant receives invoice.voided via webhook
once the provider confirms the void. To distinguish from natural auth-expiry
(where invoice.expired fires instead), the webhook payload includes
voided_via = "api" and the void_reason you sent.
POST /merchant/invoices/{id}/sync
Polling pull — when the inbound webhook never arrived (typically because
you didn't configure the webhook secret on the provider's side, the provider's notification
endpoint is throttled, or your server rejected our delivery), call this endpoint and we will query
the provider's API for the real payment state and apply it to the invoice. If the provider says
the payment is captured, the invoice transitions to confirmed and you receive the
regular outbound invoice.paid webhook (now signed with your own webhook_secret so
you can verify it).
curl -X POST https://payments.renovax.net/api/v1/merchant/invoices/01J.../sync \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"status": "synced",
"invoice_status": "confirmed",
"provider_status": "confirmed",
"amount": "49.90",
"currency": "USD"
}
Errors:
404 no_payment (customer never selected a payment method on the checkout — there's
nothing to sync yet),
422 unsupported_driver (the underlying driver doesn't implement polling — currently
supported: stripe; more drivers being added).
If the invoice is already terminal (confirmed/failed/expired/
voided/refunded), the endpoint is a no-op and returns the current state.
When to call: recommended cadence is polling every 60–120s for at most 30 minutes after the customer returned from the provider's hosted checkout. Avoid hot loops — the provider rate-limits and Stripe in particular returns 429 quickly. For volumes > 10k invoices/day, prefer configuring the webhook secret properly (this endpoint is the safety net, not the primary path).
Polling supported on: stripe, paypal, adyen,
revolut, klarna, mercadopago, braintree,
venmo. Other drivers return 422 sync_unsupported.
Automatic synchronous fallback (Return URL interceptor)
You usually don't need to call /sync manually. RENOVAX Payments
automatically intercepts the customer's return from the provider's hosted checkout to close the
loop synchronously when the inbound webhook didn't arrive (typically because you didn't configure
the webhook secret on the provider's dashboard):
- The success/cancel URL we hand to providers is our own interceptor
(
/r/{invoice}/{driver}/returnor/r/{invoice}/{driver}/cancel) — not yoursuccess_url/cancel_urldirectly. - When the customer returns from the provider, the interceptor checks the invoice state:
- Already terminal (webhook arrived first): redirects immediately to
your real
success_url— zero overhead. - Still pending and you have
webhook_secretconfigured: short grace period (up to 3s, polling every 500ms) waiting for the async webhook to land. If it arrives, redirect immediately. If the grace expires, fallback to step 4. - Still pending and no
webhook_secret: skip the grace, go to step 4 directly.
- Already terminal (webhook arrived first): redirects immediately to
your real
- Step 4 — call
syncStatus()on the driver, persist the result as a synthesized webhook event, and run it through the regular inbound pipeline. This closes the invoice and fires the outboundinvoice.paidwebhook to you (now signed with your own secret if you configured one — same payload shape as a regular webhook). - Finally, redirect the customer to your real
success_url/cancel_url.
Net effect: the moment the customer returns to your site, the invoice is already
confirmed and your backend has received the invoice.paid webhook —
even if the provider never delivered theirs. Cancel paths skip the polling and just redirect
(customer abandoned; we let the invoice expire naturally).
Reseller API
All endpoints below require a user token for an account with role
reseller, distributor, or admin.
GET /reseller/me
Returns the authenticated reseller (balance + fee percent).
curl https://payments.renovax.net/api/v1/reseller/me \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"id": 4,
"username": "mario",
"email": "[email protected]",
"role": "distributor",
"credits": 398.5,
"fee_percent": 15
}
GET /reseller/users
Lists users created by you. Supports per_page.
curl "https://payments.renovax.net/api/v1/reseller/users?per_page=20" \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"data": [
{ "id": 123, "username": "alice42", "email": "[email protected]", "name": "Alice", "role": "user", "credits": 98.0, "created_at": "2026-04-21T..." }
],
"meta": { "current_page": 1, "last_page": 1, "total": 1 }
}
POST /reseller/users
Creates a new user. If password is omitted, a random one is generated and
returned once in the response.
curl -X POST https://payments.renovax.net/api/v1/reseller/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"username": "alice42",
"email": "[email protected]",
"name": "Alice",
"role": "user"
}'
role is optional and defaults to user. Resellers may also
create accounts with role reseller (sub-resellers) when their own
role allows it.
Response 201:
{
"user": {
"id": 123,
"username": "alice42",
"email": "[email protected]",
"name": "Alice",
"role": "user",
"credits": 0,
"created_at": "2026-04-21T..."
},
"password": "Xk3p9..." // only present when generated on the server
}
POST /reseller/credits/sell
Transfers credits from your balance to another user. A percent fee is deducted according to your role. Recipient accepts username or email.
curl -X POST https://payments.renovax.net/api/v1/reseller/credits/sell \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"recipient": "alice42",
"amount": 100,
"note": "initial load"
}'
Response 200:
{
"gross": 100,
"fee": 2.0,
"net": 98.0,
"balance_after": 398.5,
"recipient": {
"id": 123,
"username": "alice42",
"email": "[email protected]",
"name": "Alice",
"role": "user",
"credits": 98.0,
"created_at": "2026-04-21T..."
}
}
GET /reseller/credits/history
Returns your transfer ledger (transfer_out, transfer_in,
transfer_fee).
curl "https://payments.renovax.net/api/v1/reseller/credits/history?per_page=30" \
-H "Authorization: Bearer $TOKEN"
Response 200:
{
"data": [
{ "id": 501, "reason": "transfer_out", "delta": -100.0, "balance_after": 398.5, "note": "initial load", "created_at": "2026-04-21T..." }
],
"meta": { "current_page": 1, "last_page": 1, "total": 1 }
}
Webhooks (outbound to your server)
When an invoice changes state, RENOVAX Payments POSTs a JSON body to the
webhook_url configured in your merchant settings. Every request is
signed with your merchant webhook_secret so you can verify it came
from us.
Headers
Every delivery carries these headers:
Content-Type: application/json
X-Renovax-Event-Id: evt_8f3a0c1e4b2d47a9b5e6f1c2d3e4a5b6
X-Renovax-Event-Type: invoice.paid
X-Renovax-Signature: sha256=3f2c9a1b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a
Event types
| Event | When it fires |
|---|---|
invoice.paid | Payment confirmed on-chain (crypto) or captured by provider webhook (Stripe/PayPal/Klarna/Revolut). Status confirmed. |
invoice.authorized | Customer authorized payment but funds were not yet captured. Applies only when the merchant configured the payment method with auto_capture=false (manual capture flow on Stripe / PayPal / Revolut / Klarna / Adyen / Braintree). Status authorized. The merchant captures from RENOVAX Payments panel, API, Telegram bot, or the provider portal — once captured, invoice.paid fires with the same invoice_id. |
invoice.partial | Payment received but amount is below expected (within configured tolerance). Status confirmed. |
invoice.overpaid | Payment received but amount exceeds expected. Status confirmed. Payload is identical to invoice.paid — amount_received and amount_net reflect the actual received amount (including the excess), which is forwarded to the merchant. For forwarder wallets (unique address per invoice), overpayments are always accepted regardless of the accept_overpayments setting. |
invoice.failed | Invoice auto-cancelled by anti-fraud (Tor/VPN/datacenter/Scamalytics). Status failed. |
invoice.expired | TTL reached without payment, or the provider's authorization window expired before capture. Status expired. |
invoice.partial_settled | An accumulated partial payment was forwarded/settled to the merchant's destination (HD wallet or external forward driver). Status partially_settled. Payload includes the partial block with cumulative transfers. |
invoice.refunded | Terminal event when a previously confirmed payment was fully refunded via POST /merchant/invoices/{id}/refund or from the merchant panel. Status refunded. |
invoice.partially_refunded | Terminal event when a previously confirmed payment was partially refunded (refunded amount < captured amount). Status partially_refunded. |
invoice.voided | Merchant actively voided an authorized payment from the RENOVAX Payments panel/API/Telegram. Differs from invoice.expired (which is provider timeout). Payload includes void_reason and voided_via. Status voided. |
Payload example (invoice.paid)
{
"event_type": "invoice.paid",
"invoice_id": "019dbc6b-33a9-7145-82c7-a53cfe533dc8",
"merchant_id": 42,
"status": "confirmed",
"invoice_amount": "100.00",
"invoice_currency": "USD",
"amount_received": "100.00000000",
"currency": "USDT",
"fee": "2.50000000",
"amount_net": "97.50000000",
"amount_received_fiat":"1800.00",
"amount_net_fiat": "1755.00",
"fx_rate_used": "18.000000",
"fx_pair": "USD/MXN",
"customer_paid_fee": false,
"driver": "crypto:USDT:ethereum",
"tx_hash": "0x7a8b9c…",
"network": "ethereum",
"confirmations": 12,
"provider_id": null,
"paid_at": "2026-04-23T15:29:58Z",
"confirmed_at": "2026-04-23T15:29:58Z",
"authorized_at": "2026-04-23T15:24:11Z",
"auth_expires_at": "2026-04-30T15:24:11Z",
"captured_at": "2026-04-23T15:29:58Z",
"captured_via": "ui",
"voided_at": null,
"voided_via": null,
"void_reason": null,
"void_reason_note": null,
"payer_ip": "203.0.113.42",
"payer_asn": 15169,
"payer_country": "MX",
"metadata": { "user_id": 4321 },
"customer_name": "John Doe",
"customer_email": "[email protected]",
"billing": { "first_name": "John", "last_name": "Doe", "address_line1": "350 5th Ave", "city": "New York", "state": "NY", "postal_code": "10118", "country": "US" },
"shipping": { "first_name": "John", "last_name": "Doe", "address_line1": "350 5th Ave", "city": "New York", "state": "NY", "postal_code": "10118", "country": "US" },
"is_flexible": false,
"customer_note": null
}
tx_hash / network / confirmations are only set for crypto payments;
provider_id is set for Stripe/PayPal/Revolut/Klarna/Adyen/Braintree captures.
amount_received_fiat, amount_net_fiat, fx_rate_used, fx_pair
are only present when the merchant's currency differs from the payment currency.
amount_net depends on your customer_pays_fee setting.
For invoice.expired / invoice.failed most payment fields will be null.
customer_name, customer_email, billing and shipping
are echoed verbatim from what the merchant sent at invoice creation (all null when
none were sent).
Timeline & audit fields
Set only for fiat drivers with deferred-capture support (stripe, paypal,
revolut, klarna, adyen, braintree).
Crypto and other drivers report null.
Use these to show your own countdown for pending captures, audit who captured/voided,
and persist the void reason in your CRM.
| Field | Type | Description |
|---|---|---|
authorized_at | ISO-8601|null | When the customer authorized the payment. Set on the first authorized webhook event. |
auth_expires_at | ISO-8601|null | When the provider's authorization window times out (Stripe ~7d, PayPal 3d, Revolut variable, Klarna 28d). Use to show your own deadline UI. |
captured_at | ISO-8601|null | When the funds were actually captured. |
captured_via | enum|null | "ui" (RENOVAX Payments panel), "api" (your backend integration via POST /merchant/invoices/{id}/capture), "telegram" (one-click capture from the bot), "provider_portal" (you captured from Stripe Dashboard / PayPal / etc.), or "auto_capture" (atomic capture by the provider when auto_capture=true). |
voided_at | ISO-8601|null | When the authorization was voided. null for invoices that reached confirmed. |
voided_via | enum|null | "ui", "api", or "telegram" when the merchant actively voided. null when the provider expired the auth on its own — in that case the invoice is expired, not voided. |
void_reason | enum|null | "customer_request", "out_of_stock", "fraud_suspected", or "other". Sent only on invoice.voided events. |
void_reason_note | string|null | Free-text note (max 500 chars) the merchant attached when choosing "other" as the reason. |
Payload example (invoice.voided)
Fired when the merchant actively voids an authorized payment via panel / API / Telegram.
Differs from invoice.expired (which is the provider expiring the auth on its own).
{
"event_type": "invoice.voided",
"invoice_id": "019dbc6b-33a9-7145-82c7-a53cfe533dc8",
"merchant_id": 42,
"status": "voided",
"invoice_amount": "100.00",
"invoice_currency": "USD",
"currency": "USD",
"driver": "stripe",
"provider_id": "pi_3N…",
"authorized_at": "2026-04-23T15:24:11Z",
"auth_expires_at": "2026-04-30T15:24:11Z",
"voided_at": "2026-04-23T18:05:00Z",
"voided_via": "ui",
"void_reason": "out_of_stock",
"void_reason_note": "SKU 12345 sold out",
"captured_at": null,
"captured_via": null,
"paid_at": null,
"metadata": { "user_id": 4321 }
}
is_flexible is true when the invoice was created with flexible: true
(open amount, customer typed it on the checkout). For flexible invoices, invoice_amount still
reflects the final value the customer chose. customer_note contains an optional message the
customer left at checkout (max 500 chars, plain text); null when none was provided or for
non-flexible invoices.
Partial transfers block
When the customer pays in multiple on-chain transfers
(common with crypto: customer sends 40 USDT, then 60 USDT to reach a 100
USDT invoice), every detected transfer is recorded on the invoice. The
partial block exposes that history to the merchant.
It is null when no partial transfers were
accumulated (single-transfer payment or no payment at all). It is populated
on these events: invoice.partial,
invoice.paid (when the final amount was reached by accumulation),
invoice.expired (with partial funds pending — customer left
money under the threshold) and invoice.partial_settled.
"partial": {
"total": "100.00000000",
"missing": "0.00000000",
"count": 2,
"transfers": [
{
"tx_hash": "0x7a8b9c…",
"amount": "40.00000000",
"block": 19482301,
"from": "0xPayerAddress…",
"detected_at": "2026-04-23T15:24:11Z"
},
{
"tx_hash": "0x1d2e3f…",
"amount": "60.00000000",
"block": 19482358,
"from": "0xPayerAddress…",
"detected_at": "2026-04-23T15:29:58Z"
}
]
}
| Field | Type | Description |
|---|---|---|
total | string | Sum of all detected partial amounts, in the payment currency. Decimal string with 8 fractional digits. |
missing | string | How much is still missing to reach invoice_amount. "0" once the invoice is fully covered. On invoice.expired, this is what the customer never sent. |
count | integer | Number of detected transfers. Always equals transfers.length. |
transfers[] | array | Each detected on-chain transfer, in detection order. |
transfers[].tx_hash | string|null | Transaction hash on the network. |
transfers[].amount | string|null | Amount of this single transfer, in the payment currency. Decimal string. |
transfers[].block | integer|null | Block height where the transfer was mined. |
transfers[].from | string|null | Sender address as reported by the network. |
transfers[].detected_at | string|null | ISO-8601 timestamp of when our crypto detector picked up the transfer. |
Payer fields
Forensic data captured from the payer's first genuine
interaction with the checkout (page load / scan / method selection). Frozen
on the invoice from that point onward — so retries and re-deliveries always
carry the same values. Source: MaxMind GeoIP2 Country and ASN
databases. All three may be null when no payer interaction was
ever recorded (e.g. invoice.expired with no scan).
| Field | Type | Description |
|---|---|---|
payer_ip | string|null | IPv4 or canonical IPv6 of the payer. |
payer_asn | integer|null | Autonomous System Number (e.g. 15169 = Google). null for unassigned ranges. |
payer_country | string|null | ISO-3166-1 alpha-2 country code in uppercase (e.g. MX, US). |
Signature verification
The signature is
sha256=HMAC_SHA256(webhook_secret, raw_body). Compute it over
the raw request body (not the parsed JSON) and compare with
X-Renovax-Signature using a constant-time comparison.
Node.js (Express):
import crypto from 'crypto';
import express from 'express';
const app = express();
const SECRET = process.env.RENOVAX_WEBHOOK_SECRET;
// IMPORTANT: capture the raw body — body-parser JSON would stringify it back.
app.post('/webhooks/renovax',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('X-Renovax-Signature') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(400).json({ error: 'bad_signature' });
}
const event = JSON.parse(req.body.toString());
// handle event.event, event.invoice, event.payment …
res.json({ ok: true });
});
PHP:
$secret = getenv('RENOVAX_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_RENOVAX_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($expect, $sig)) { http_response_code(400); exit; }
$event = json_decode($body, true);
// …
Python (Flask):
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['RENOVAX_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/renovax')
def hook():
body = request.get_data()
expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get('X-Renovax-Signature', '')):
abort(400)
# request.get_json() is safe now
return {'ok': True}
Retries & idempotency
- Respond with any 2xx within 10 seconds to acknowledge. Anything else (non-2xx, timeout, connection error) triggers a retry.
- Retries follow exponential backoff:
30s, 2m, 10m, 1h, 6h, 24h(6 attempts total). After that the delivery is marked failed — inspect it in your merchant dashboard under Webhooks. - The
X-Renovax-Event-Idis stable across retries. Use it as an idempotency key on your side: persist the event-id the first time you process the event and skip duplicates. - We never send the same event twice successfully — but network retries, user-initiated redeliveries, and the Test webhook button can all replay the same event-id.
Rotate your secret anytime from the merchant's Webhooks tab. The next delivery after the rotation is signed with the new key.
Errors
Errors return a JSON body with an error key and appropriate HTTP status:
{ "error": "invalid_token_type", "message": "This endpoint requires a merchant token." }
401— missing or invalid token402— negative credit balance403— token type or role not authorized404— resource not found409— duplicate (e.g. pending invoice with same amount)422— validation or business-rule failure