VoltPay integration guide
Review the API before requesting an invite. Create a website, obtain a server-side API key, implement the signed webhook receiver, then verify delivery and order processing separately.
Website Routing
Merchant model: one merchant account can own many websites. API keys and the webhook secret stay shared at the merchant level.
API key rotation: rotate one selected key in API Keys. The previous key remains valid for up to 24 hours (or its earlier expiry). Update every integration using it, verify authentication, then revoke the previous key. Existing permissions and website bindings are preserved.
What separates your websites: the websiteId you send with each request, not the credential. The key authenticates the account; the websiteId decides which website owns the payment, and each website keeps its own webhook URL, checkout configuration, and reporting on that basis. Send the correct value from every website you integrate.
Where to manage websites: add or rename websites in Dashboard → Websites.
Where to get websiteId: every website card and edit panel on the Websites page shows a copyable websiteId. Use that exact UUID in payment creation requests.
Where to configure webhook URLs: set each website's webhook URL in Developers → Webhooks.
What websiteId does: it assigns the payment to a website, controls which webhook URL receives status updates, and powers dashboard filters for payments, customers, subscriptions, and stats.
Without websiteId: the payment is attached to your oldest active website, and its webhooks go to that website's webhook URL. Older integrations may still identify the website through the reserved customData keys listed below, but this is a compatibility path only. Always send websiteId, even if you have only one website today.
Create Payment
Authorization: Bearer YOUR_API_KEY Content-Type: application/json
{
"websiteId": "550e8400-e29b-41d4-a716-446655440001", // string (UUID), strongly recommended: without it the payment goes to your oldest active website
"amount": 100, // number, required, always USD, 1.00-1000.00, up to 2 decimals
"customerEmail": "[email protected]", // string, required, valid email (legacy alias: "email" — send either, not both)
"customerName": "Jane Doe", // string, optional, max 200 chars
"description": "Order #123", // string, optional, max 500 chars
"customData": { "orderId": "123" }, // object, optional, max serialized size 5 KB; some keys are reserved (see below)
"providerCode": "XXXXXX", // string, optional, 6-character code provided by your account manager
"paymentType": "ONE_TIME" // string, optional: ONE_TIME (default). RECURRING is currently not available
}Amounts are always in USD. There is no currency field, and fields not listed here are ignored.
{
"paymentId": "550e8400-e29b-41d4-a716-446655440000", // string (UUID)
"websiteId": "550e8400-e29b-41d4-a716-446655440001", // string (UUID) | null
"websiteName": "Main Store", // string | null
"amount": 100, // number
"currency": "USD", // string
"status": "CREATED", // string
"description": "Order #123", // string | null
"customerEmail": "[email protected]", // string | null
"customData": { "orderId": "123" }, // object | null
"paymentType": "ONE_TIME", // string
"intervalDays": null, // number | null
"redirectUrl": "https://api.voltpay.cash/r/AbCdEf12", // string
"reusedExistingPayment": false, // boolean, true when an active payment was returned
"reuseReason": "ACTIVE_PAYMENT_SAME_CUSTOMER_AMOUNT", // string, only when reusedExistingPayment is true
"directProvider": { // object, only when providerCode was sent
"code": "XXXXXX",
"eligible": true
},
"expiresAt": "2024-01-15T12:00:00Z" // string (ISO 8601)
}The payment fields are returned at the top level of the JSON body. There is no data wrapper.
Checkout redirect: send the customer to redirectUrl to continue payment. The link stays valid for 24 hours.
No return URL: there is no success or return URL. After paying, the customer stays on the payment page. Open redirectUrl in a new tab, or keep your own "check payment" page, and grant access only from the webhook or the status endpoint. A redirect alone never proves payment.
Checkout expiry: expiresAt is 60 minutes after creation by default, and 6 hours once the customer chooses a card payment. An expired payment can still become PAID later, so always credit a late PAID.
Duplicate protection: if the same website already has a payment for the same customer email and amount that is in progress (PENDING or PROCESSING) and was created within the last 10 minutes, the API returns that payment instead of creating a new one. The response then has "reusedExistingPayment": true and the existing paymentId, so always store the paymentId from the response against your order.
Website routing: when you send websiteId, the payment is attached to that website and later status webhooks are delivered to that website's configured webhook URL. Without it, the payment goes to your oldest active website.
Direct provider intent: providerCode values are provided by your account manager; the code in the example is a placeholder. The code is checked against global, merchant, and website switches when the link is created. Final country, amount, currency, risk, capacity, and session eligibility is checked after buyer capture. A merchant required checkout flow has higher priority, so always inspect directProvider.eligible and keep the standard checkout fallback.
Recurring billing: automatic renewals are currently not available. The "paymentType": "RECURRING" and intervalDays fields (whole days, required together with RECURRING) are still accepted, but do not build on renewals. Treat every payment as one-time, and read paymentType from the webhook as the final value.
customData is echoed back to you in webhooks, but a few keys are read by the platform and change how the payment is routed. Don't use them for other data:
siteHostHostname of your website. Without websiteId it selects the website, and a host that is not registered to an active website is rejected.appNameLegacy website selector: without websiteId and siteHost it must match an active website name (case-insensitive), or the request is rejected.productType, productIdReserved for built-in integrations. Some values change the webhook description or delivery path.orderReference + userIdIf orderReference has the form ORDER-<userId>-<n> and userId is also sent, the two must match, or the request is rejected with 400. connectionId is treated like userId.Get Payment Status
Server-side reconciliation for a payment created with your API key. Send the same Authorization: Bearer YOUR_API_KEY header. Payments of other merchants return 404. Payments stay queryable after their checkout expires. Webhooks remain the primary signal; use this endpoint to recover a missed event, not for tight polling. It is also the only way to see CREATED, PENDING, PROCESSING and never-paid CANCELLED, which are not sent as webhooks.
{
"paymentId": "550e8400-e29b-41d4-a716-446655440000", // string (UUID)
"status": "PAID", // string
"refundStatus": null, // null | PARTIALLY_REFUNDED | REFUNDED | CHARGEBACK
"refundedAmount": 0, // number, in `currency`
"amount": 100, // number
"currency": "USD", // string
"websiteId": "550e8400-e29b-41d4-a716-446655440001", // string (UUID) | null
"paymentType": "ONE_TIME", // string: ONE_TIME | RECURRING
"intervalDays": null, // number | null
"subscriptionId": null, // string (UUID) | null
"createdAt": "2024-01-15T12:00:00.000Z", // string (ISO 8601)
"updatedAt": "2024-01-15T12:05:00.000Z", // string (ISO 8601)
"expiresAt": "2024-01-15T13:00:00.000Z" // string (ISO 8601)
}Refunds keep status PAID. A refund or chargeback never changes status — integrations treat PAID as final, so it stays PAID. Detect money that went back to the buyer with refundStatus: null (not refunded), PARTIALLY_REFUNDED (part of amount), REFUNDED (all of it) or CHARGEBACK (the buyer's bank reversed it; takes precedence over a refund). refundedAmount is the amount reversed so far, in currency, and 0 when refundStatus is null.
There is no refund webhook yet. If you need to revoke access or stop a renewal after a refund, check this endpoint — for example before each renewal or when a buyer contacts support. Both fields are additive; clients that ignore unknown fields keep working.
Errors and Rate Limits
Successful responses return the resource at the top level. Errors use a separate shape with a non-2xx HTTP status:
{
"success": false,
"error": {
"code": "INVALID_TOKEN", // string, stable machine-readable code
"message": "Invalid API key", // string, human-readable
"details": {} // object, extra context when available
},
"meta": {
"timestamp": "2024-01-15T12:00:00.000Z",
"path": "/api/payments/create",
"method": "POST",
"version": "v1"
}
}For request-body validation errors, the field-level problems are in error.details.errors[]. Each item has a path (the field) and a message:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"errors": [
{ "code": "too_small", "path": ["amount"], "message": "Amount must be at least $1" }
]
}
},
"meta": { ... }
}VALIDATION_ERRORRequest body or parameters are invalid; see error.details.errors[]WEBSITE_REQUIREDThis API key is bound to several websites and the request does not say which one; send websiteIdWEBSITE_NOT_ALLOWEDThe websiteId is not allowed for this API keyUNAUTHORIZED / INVALID_TOKENAPI key is missing, invalid or expiredFORBIDDENThe API key is not allowed from this IP addressFORBIDDENThe API key lacks the required permission ("This API key does not permit this operation")PAYMENT_NOT_FOUNDNo such payment for your accountRATE_LIMIT_EXCEEDEDToo many requests; retry after the Retry-After headerRate limits (per API key): POST /api/payments/create 300 requests per minute; GET /api/payments/:paymentId/status 300 requests per minute. Every response on these endpoints reports that limit in X-RateLimit-Limit-api-key, X-RateLimit-Remaining-api-key and X-RateLimit-Reset-api-key.
A 429 response carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Wait at least Retry-After seconds before retrying.
Webhook Format
Configure each website webhook URL in Developers → Webhooks. We send a signed POST request to that URL for the events below. Switch on eventType, and acknowledge (HTTP 200) event types you do not handle.
payment.status.changedSent when a payment becomes PAID or FAILED, and with CANCELLED only when a PAID payment is reversed. CREATED, PENDING, PROCESSING and the cancellation or expiry of a never-paid payment are not sent; poll GET /api/payments/:paymentId/status if you need them.subscription.canceledA subscription was cancelled. Sent with "paymentId": "", oldStatus PAID and newStatus CANCELLED; identify it by subscriptionId.webhook.endpoint.testSent by the "Send test" button in Developers → Webhooks. Diagnostic only: it has no paymentId and must never fulfil an order.Refund and dispute events are not sent by webhook: a refunded payment stays PAID. Read refundStatus and refundedAmount from GET /api/payments/:paymentId/status.
Only PAID is final: a PAID event can arrive after FAILED or CANCELLED, hours and sometimes days later. Always credit it, and never drop an event because the order was already marked failed or expired. FAILED and CANCELLED mean "not paid yet", not a final outcome, and FAILED can be sent more than once for the same payment when the customer retries.
Successful delivery: only HTTP 200, 201, 202 or 204. Redirects are not followed and count as failures. A 2xx response whose JSON body contains "success": false, "ok": false, "acknowledged": false or a status of error, failed, failure or rejected also counts as a failure. Respond within 20 seconds; slower responses time out.
Retries: failed deliveries are retried automatically with increasing delays for up to 72 hours (about 20 attempts). A Retry-After header on your response is honoured. Your handler must be idempotent.
Deduplication: X-Webhook-Id stays the same across automatic retries, but a manual resend from the dashboard gets a new X-Webhook-Id. Deduplicate business actions on paymentId + newStatus (for subscription.canceled, on subscriptionId), not only on X-Webhook-Id.
Pausing an endpoint: events that occur while a website's webhook is paused are queued, not dropped, and pending retries wait with them. When you resume, queued events are delivered in occurredAt order to the endpoint's current URL, each keeping its X-Webhook-Id. Events queued for more than 7 days are not delivered automatically; resend them from the dashboard if you still need them. A manual resend goes to the website's current webhook URL.
Ordering: compare paymentId, newStatus, and occurredAt instead of assuming events arrive in order.
Recurring: renewals are currently not available. If paymentType is present, read the webhook value as the final outcome.
Shared secret: all website webhook deliveries are signed with the same merchant webhook secret. URLs are website-specific; signature verification remains merchant-scoped.
X-Webhook-Signature: sha256=<hex HMAC-SHA256 of timestamp + "." + raw body> X-Webhook-Timestamp: <milliseconds since epoch> X-Webhook-Id: <delivery id; same across automatic retries, new on a manual resend> X-Webhook-Version: 1.0 X-Event-Type: <eventType, e.g. payment.status.changed> X-Payment-ID: <paymentId; absent when the event has no payment> X-Subscription-ID: <subscriptionId; only on subscription events> X-Webhook-Retry: <delivery attempt number; may be absent on the first attempt>
{
"eventType": "payment.status.changed", // string
"paymentId": "550e8400-e29b-41d4-a716-446655440000", // string (UUID)
"websiteId": "550e8400-e29b-41d4-a716-446655440001", // string (UUID)
"websiteName": "Main Store", // string
"oldStatus": "PENDING", // string
"newStatus": "PAID", // string: PAID | FAILED | CANCELLED (CANCELLED only after a PAID payment is reversed)
"amount": 100, // number, USD
"currency": "USD", // string
"occurredAt": "2024-01-15T12:05:00.000Z", // string (ISO 8601)
"customerEmail": "[email protected]", // string
"description": "Order #123", // string
"customData": { "orderId": "123" }, // object | null
"paymentType": "ONE_TIME", // string: ONE_TIME | RECURRING
"intervalDays": 30, // number, only when paymentType is RECURRING
"subscriptionId": "7f9c2b1e-4a3d-4f5e-9b8a-1c2d3e4f5a6b" // string, only for subscription payments
}Fields without a value are omitted rather than sent as null (except customData). A payment with referral attribution also carries a referral object. Ignore fields you do not recognise.
{
"eventType": "subscription.canceled", // string
"paymentId": "", // always an empty string for this event
"oldStatus": "PAID", // string
"newStatus": "CANCELLED", // string
"occurredAt": "2024-02-14T09:00:00.000Z", // string (ISO 8601)
"subscriptionId": "7f9c2b1e-4a3d-4f5e-9b8a-1c2d3e4f5a6b", // string
"cancelReason": "Cancelled by customer", // string, optional
"customData": { "orderId": "123" } // object | null, from the first payment of the subscription
}{
"eventType": "webhook.endpoint.test", // string
"diagnostic": true, // boolean, always true
"websiteId": "550e8400-e29b-41d4-a716-446655440001", // string (UUID)
"websiteName": "Main Store", // string
"occurredAt": "2024-01-15T12:00:00.000Z" // string (ISO 8601)
}Verify Signature (Node.js)
import crypto from "crypto";
function verifyWebhook(body, signatureHeader, timestamp, secret) {
// X-Webhook-Timestamp is Unix milliseconds. Keep the original raw body.
if (typeof signatureHeader !== "string" || typeof timestamp !== "string" ||
!/^\d+$/.test(timestamp) || !secret) return false;
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt) || Math.abs(Date.now() - sentAt) > 5 * 60_000) return false;
const signedPayload = `${timestamp}.${body}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
// X-Webhook-Signature is normally `sha256=<hex>` but during a 24h secret-rotation
// overlap it can be `sha256=<hex>, sha256=<hex>` — accept either match.
const candidates = signatureHeader
.split(",")
.map((s) => s.trim().replace(/^sha256=/i, ""))
.filter((signature) => /^[a-f0-9]{64}$/i.test(signature));
return candidates.some((received) => {
try {
return crypto.timingSafeEqual(
Buffer.from(received, "hex"),
Buffer.from(expected, "hex"),
);
} catch {
return false;
}
});
}During a webhook-secret rotation, the header carries both the new and previous signatures separated by a comma. Verifiers must accept either match for the 24h overlap window. Verify before parsing the body. Reject stale timestamps, then durably enqueue the event before acknowledging it. Apply each business operation once per paymentId + newStatus: a manual resend has a new delivery ID. Handle out-of-order events; a redirect alone never proves payment. The webhook.endpoint.test event is diagnostic only and must not fulfill an order.
Payment Statuses
Only PAID is final. Every other status can still change, including to PAID.