OpenPay Public API Documentation
Introduction#
The OpenPay Public API allows merchants to programmatically create payment links, retrieve transaction details, and integrate OpenPay directly into their applications.
It is a REST API: resources are addressed by URL, requests and responses use JSON, and standard HTTP verbs (GET, POST, PUT, DELETE) and status codes carry their usual meaning. All requests must be made over HTTPS — plain-HTTP requests are rejected.
New to OpenPay? You can get a free, fully-featured sandbox account (every payment simulated, no real money moves) at www.openpay.bz — see Test Mode & the Sandbox.
Authentication#
All API requests must be authenticated using an API Key. You can generate API keys in the Merchant Dashboard under Settings > Developer API.
Include your API key in the x-api-key header of each request.
x-api-key: sk_live_...
Base URLs & Environments#
https://api.openpay.bz/v1
All endpoints documented here live under the versioned /v1 prefix shown above.
OpenPay does not use a separate host for testing. The base URL is the same whether you are testing or live — whether a request is simulated or processes a real charge is determined by your account mode (and the API key tied to it), not by a different domain. See Test Mode & the Sandbox for how test mode behaves.
| Environment | Base URL |
|---|---|
| Production / sandbox | https://api.openpay.bz/v1 |
Versioning#
The API is versioned through the URL path. Every endpoint is served under the /v1 prefix, and v1 is the current version.
Backward-compatible changes — adding a new endpoint, a new optional request field, or a new field in a response object — may be rolled out within v1 without a version bump, so your integration should ignore unknown response fields rather than fail on them. Any change that would break existing integrations would be introduced under a new version prefix, leaving /v1 untouched.
Errors#
The API uses standard HTTP status codes to indicate the success or failure of requests.
| Status Code | Description |
|---|---|
200 |
OK - Request succeeded |
201 |
Created - Resource created successfully |
400 |
Bad Request - Invalid parameters or missing fields |
401 |
Unauthorized - Invalid or missing API Key |
402 |
Payment Required - Card verification required |
403 |
Forbidden - Access denied to the resource |
404 |
Not Found - Resource does not exist |
409 |
Conflict - The resource exists but isn't in a state that allows this action |
410 |
Gone - The resource (e.g. a save session) has expired or been exhausted |
429 |
Too Many Requests - Abuse-detection block (card/IP/merchant velocity), or an attempt limit exhausted |
500 |
Internal Server Error - Something went wrong on our end |
502 |
Bad Gateway - A downstream gateway call failed; safe to retry |
Error Response Body:
{
"success": false,
"message": "Description of the error"
}
The Saved Cards and Subscriptions endpoints are the exception: their error bodies are a flatter { "error": "<code>" } shape (sometimes with extra fields, e.g. attempts_remaining, or a message alongside error for invalid_request) with no success/message wrapper. Match on the error string rather than the HTTP status alone where both appear in the tables below.
Saved Cards Error Codes#
| Status | error |
Returned by | Meaning |
|---|---|---|---|
403 |
card_storage_not_supported |
POST /cards/save-sessions, POST /payments (card_token) |
This merchant isn't enabled for card storage. |
404 |
session_not_found |
GET /cards/save-sessions/:id |
Unknown save session, or it belongs to another merchant. |
404 |
card_token_invalid |
DELETE /cards/:token, POST /payments (card_token) |
Unknown token, foreign-merchant token, or an already-removed card. |
400 |
card_token_exclusive |
POST /payments |
Both card_token and a raw card field were sent in the same request. |
409 |
card_not_chargeable |
POST /payments (card_token) |
The card is pending_verification — it must be verified before it can be charged. |
409 |
card_not_pending_verification |
POST /cards/:token/verify |
Unknown token, or the card is already active or removed. |
400 |
amount_mismatch |
POST /cards/:token/verify |
The submitted amount doesn't match the micro-charge. Carries attempts_remaining. |
429 |
verification_attempts_exhausted |
POST /cards/:token/verify |
All verification attempts have been used; the card can't be verified. |
502 |
verification_refund_failed |
POST /cards/:token/verify |
The amount was correct but the micro-charge refund failed on the gateway side. Retriable — no attempt is consumed. |
The hosted capture page (what the save-session url points customers to) returns its own error codes to that page — not to your API-key-authed server — but they're worth knowing when interpreting session state:
| Status | error |
Returned by | Meaning |
|---|---|---|---|
400 |
invalid_request |
POST /api/public/cards/:sessionId |
Malformed submission (missing card fields). The session stays pending and is retriable. |
402 |
card_declined |
POST /api/public/cards/:sessionId |
The gateway declined the card. The session stays pending (or usable) up to its 5-attempt cap. |
410 |
save_session_expired |
POST /api/public/cards/:sessionId |
The session can no longer accept a card — unknown/nonexistent id, expired (30-minute TTL passed), already completed, or its 5-attempt capture budget is exhausted. |
404 |
save_session_not_found |
GET /api/public/cards/:sessionId/info |
Unknown session id. |
Subscriptions Error Codes#
| Status | error |
Returned by | Meaning |
|---|---|---|---|
403 |
card_storage_not_supported |
POST /subscription-plans, POST /subscriptions |
This merchant isn't enabled for card storage/subscriptions. |
400 |
invalid_request |
POST /subscription-plans, POST /subscriptions |
Validation failure — see the message field for specifics (bad amount/currency/interval, missing card_token, both/neither of plan_id and inline terms, etc.). |
404 |
plan_not_found |
POST /subscription-plans/:token/archive, POST /subscriptions |
Unknown token, a foreign-merchant plan, or (for POST /subscriptions) an archived plan. |
404 |
card_token_invalid |
POST /subscriptions |
Unknown card_token, a foreign-merchant card, or a removed card. |
409 |
card_not_chargeable |
POST /subscriptions |
The card is pending_verification — it must be verified before it can back a subscription. |
400 |
currency_not_enabled |
POST /subscriptions |
The plan's (or inline) currency isn't enabled for this merchant. |
404 |
subscription_not_found |
GET /subscriptions/:id, POST /subscriptions/:id/pause|/resume|/cancel |
Unknown id, or it belongs to another merchant. |
409 |
invalid_state |
POST /subscriptions/:id/pause|/resume|/cancel |
The action doesn't apply to the subscription's current status — see Pause, Resume, and Cancel a Subscription. |
The hosted subscribe page (what a plan's subscribe_url points customers to) returns its own error codes to that page — not to your API-key-authed server — but they're worth knowing when interpreting enrollment outcomes:
| Status | error |
Returned by | Meaning |
|---|---|---|---|
404 |
plan_not_found |
GET /api/public/subscribe/:planToken/info, POST /api/public/subscribe/:planToken |
Unknown, foreign, or archived plan token. |
400 |
consent_required |
POST /api/public/subscribe/:planToken |
The consent checkbox wasn't checked — a literal true is required; false, the string "true", or omission all fail. |
400 |
invalid_request |
POST /api/public/subscribe/:planToken |
Missing or malformed customer_email, or a malformed card field. |
402 |
card_declined |
POST /api/public/subscribe/:planToken |
The gateway declined the card. Retriable — the page lets the customer try again. |
409 |
already_subscribed |
POST /api/public/subscribe/:planToken |
This email already has an active or past-due subscription on this plan. A canceled prior subscription does not block re-enrollment. |
429 |
(no error field — { "success": false, "message": "..." }) |
POST /api/public/subscribe/:planToken |
Card-testing abuse gate tripped (5 declines from the same card fingerprint). |
Conventions#
Patterns shared across the API.
Idempotency#
Where OpenPay supports idempotency, it is scoped to the specific resource rather than a global Idempotency-Key header:
- Paylinks use
external_referenceas an idempotency key — at most one active paylink may exist per(merchant, external_reference), so reusing the value while a link is still active returns the existing link instead of creating a duplicate. See Create a Paylink. - Webhook deliveries are at-least-once: deduplicate on the event
idbefore applying business logic. See Webhooks.
Pagination#
List endpoints that return collections — such as List Transactions — accept page and limit query parameters and return a pagination object (total, page, limit, total_pages) alongside the data array. Iterate by incrementing page until you reach total_pages.
Rate limits#
OpenPay does not publish a fixed request-per-minute rate limit. It does apply abuse-detection velocity controls (by card, IP, and merchant) that can return 429 Too Many Requests when tripped — see the Errors table. Build retry/backoff handling for 429 responses.
Payments#
Process payments directly through the API without creating a paylink first. This is ideal for integrating payment processing into your application (e.g., food ordering apps, e-commerce checkouts).
Process a Payment#
Processes a direct payment with card details, or charges a previously saved card by token.
Endpoint: POST /payments
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | Yes | Payment amount (e.g., 50.00) |
currency |
string | Yes | Currency code (e.g., "BZD", "USD") |
card_number |
string | Yes, unless card_token is set |
Full card number (16 digits) |
card_expiry_month |
string | Yes, unless card_token is set |
Card expiry month (e.g., "12") |
card_expiry_year |
string | Yes, unless card_token is set |
Card expiry year (e.g., "2025") |
card_cvv |
string | Yes, unless card_token is set |
Card CVV/CVC code (3-4 digits) |
card_holder_name |
string | Yes, unless card_token is set |
Name on the card |
card_token |
string | No | A saved card token (card_...) to charge instead of raw card fields. Mutually exclusive with every card_* field above — sending both returns 400 card_token_exclusive. |
description |
string | No | Description of the payment |
customer_name |
string | No | Name of the customer |
customer_email |
string | No | Email of the customer for receipts |
Charging a saved card:
curl -X POST https://api.openpay.bz/v1/payments \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 25.00,
"currency": "BZD",
"card_token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"customer_email": "jane@example.com"
}'
The success response has the same shape as a card-fields payment. Token-specific error responses (none carry the usual success wrapper on the 4xx/403 branches):
| Status | error |
Meaning |
|---|---|---|
400 |
card_token_exclusive |
Both card_token and a raw card field were sent. |
403 |
card_storage_not_supported |
This merchant isn't enabled for card storage (see Saved Cards). |
404 |
card_token_invalid |
The token doesn't exist, belongs to another merchant, or was removed. |
409 |
card_not_chargeable |
The card is still pending_verification — complete verification first. |
The currency gate (currency_not_enabled) applies to token charges exactly as it does to card-fields charges.
Example Request:
curl -X POST https://api.openpay.bz/v1/payments \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 50.00,
"currency": "BZD",
"card_number": "4541250000000001",
"card_expiry_month": "12",
"card_expiry_year": "2025",
"card_cvv": "123",
"card_holder_name": "John Doe",
"description": "Order #5678",
"customer_name": "John Doe",
"customer_email": "john@example.com"
}'
Success Response (200 OK):
{
"success": true,
"data": {
"transaction_id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"status": "completed",
"message": "Payment processed successfully",
"gateway_reference": "BPREF-609286",
"response_code": "APPROVED"
}
}
Verification Required Response (402 Payment Required):
When a new card is used for the first time, verification is required:
{
"success": false,
"error_code": "VERIFICATION_INITIATED",
"message": "Card verification required. A small random amount has been charged to your card. Please verify the amount to continue.",
"verification_required": true,
"verification_currency": "USD",
"verification_expires_at": "2026-02-11T10:30:43.473Z"
}
verification_currency ("USD" or "BZD") is the currency the micro-charge
was made in. Show it to the cardholder: banks outside that currency zone
display the charge converted, so the customer must enter the original
verification_currency amount (usually visible in the transaction details of
their banking app), not the converted figure.
verification_expires_at (ISO 8601, when present) is when the temporary
verification charge is automatically voided if the amount hasn't been
confirmed — one hour from the charge. Surface it as a countdown so the
customer knows their window; after it passes, submit will fail and they must
restart the payment to receive a fresh charge. It is included only when the
charge was created on this request (a fresh verification); it may be absent
when resuming a verification already in progress.
Error Response (400 Bad Request):
{
"success": false,
"message": "Invalid card",
"response_code": "INVALID_CARD"
}
Error Response — currency not enabled (400 Bad Request):
{
"success": false,
"error": "currency_not_enabled",
"message": "USD is not enabled for this merchant — no USD gateway configuration"
}
Returned when the requested currency is not configured for your gateway. Check your gateway settings or use a supported currency.
Rate-Limited / Abuse Block (429 Too Many Requests):
OpenPay's abuse-detection layer transparently rejects requests that look like card-enumeration or velocity attacks. The card, IP, or merchant is blocked for a cooldown window; legitimate retries can resume once the window passes. The message describes the specific block (e.g. too many declines on the same card, or too many cards from the same IP).
{
"success": false,
"message": "Too many declined attempts. Please try again later."
}
Test-mode tagging. When the merchant is in test status, every response branch above (200, 402, 400, 429) additionally carries "test_mode": true at the top level. See Test Mode for the simulator behavior.
Paylinks#
Paylinks are secure, shareable URLs that allow customers to make payments.
Create a Paylink#
Creates a new payment link.
Endpoint: POST /paylinks
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | Yes | Payment amount (e.g., 150.00) |
currency |
string | Yes | Currency code (e.g., "BZD", "USD") |
description |
string | No | Description of the payment |
customer_name |
string | No | Name of the customer |
customer_email |
string | No | Email of the customer for receipts |
return_url |
string | No | URL to redirect customer after successful payment |
external_reference |
string | No | Your own reference for this order. Echoed back on the paylink and in every webhook payload (data.external_reference), so you can reconcile and route by it. Also an idempotency key: at most one active paylink may exist per (merchant, external_reference) — reusing the value while a link is still active returns the existing link (200) instead of creating a duplicate. Make it unique per order (e.g., INV-1234); to route webhooks by product, encode the product as a prefix (e.g., WEBSITE-1043, HOSTING-2231) and branch on it in your handler. |
Example Request:
curl -X POST https://api.openpay.bz/v1/paylinks \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 150.00,
"currency": "BZD",
"description": "Invoice #1234",
"customer_name": "John Doe",
"customer_email": "john@example.com",
"return_url": "https://myshop.com/success",
"external_reference": "WEBSITE-1043"
}'
Success Response (201 Created):
{
"success": true,
"data": {
"id": "cafe62fc-11e5-4000-a11b-e95f0f5f54e7",
"url": "https://merchant.openpay.bz/payment/cafe62fc-11e5-4000-a11b-e95f0f5f54e7",
"qr_code": "https://api.openpay.bz/qr/cafe62fc-11e5-4000-a11b-e95f0f5f54e7.png",
"amount": 150.00,
"currency": "BZD",
"status": "active",
"external_reference": "WEBSITE-1043",
"created_at": "2026-02-11T09:30:43.473Z"
}
}
The qr_code field is a URL to a PNG image that encodes the paylink URL — useful for printing on receipts or displaying on a checkout screen.
Error Response — Currency not enabled (400 Bad Request):
The shape of this error differs between the two API surfaces:
/v1 API-key surface (POST /v1/paylinks — this document):
{
"success": false,
"error": "currency_not_enabled",
"message": "USD is not enabled for this merchant — no USD gateway configuration"
}
Authenticated portal surface (POST /api/paylinks, PUT /api/paylinks/:id, PATCH /api/paylinks/:id):
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"currency": "USD is not enabled for this merchant — no USD gateway configuration"
}
}
}
This error is returned when the requested currency does not have a complete gateway credential slot configured for the merchant. Check the enabled_currencies field on the merchant profile (GET /me) to know which currencies are available before creating paylinks.
Retrieve a Paylink#
Retrieves details of a specific paylink.
Endpoint: GET /paylinks/:id
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The ID of the paylink to retrieve |
Example Request:
curl -X GET https://api.openpay.bz/v1/paylinks/cafe62fc-11e5-4000-a11b-e95f0f5f54e7 \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": {
"id": "cafe62fc-11e5-4000-a11b-e95f0f5f54e7",
"url": "https://merchant.openpay.bz/payment/cafe62fc-11e5-4000-a11b-e95f0f5f54e7",
"qr_code": "https://api.openpay.bz/qr/cafe62fc-11e5-4000-a11b-e95f0f5f54e7.png",
"amount": 150.00,
"currency": "BZD",
"status": "active",
"description": "Invoice #1234",
"customer_name": "John Doe",
"external_reference": "WEBSITE-1043",
"created_at": "2026-02-11T09:30:43.473Z"
}
}
Transactions#
List Transactions#
Retrieve a paginated list of your transactions with optional filters. Useful for reconciliation, reporting, and monitoring payment activity.
Endpoint: GET /transactions
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
number | No | Page number (default: 1) |
limit |
number | No | Results per page, max 100 (default: 20) |
status |
string | No | Filter by status: pending, completed, failed, refunded |
currency |
string | No | Filter by currency: BZD or USD |
start_date |
string | No | Filter from date (ISO 8601, e.g., 2026-04-01T00:00:00Z) |
end_date |
string | No | Filter to date (ISO 8601) |
paylink_id |
string | No | Filter by paylink UUID |
transaction_type |
string | No | Filter by type: payment, verification, refund, chargeback |
Example Request:
curl -X GET "https://api.openpay.bz/v1/transactions?status=completed&start_date=2026-04-01&limit=50" \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": [
{
"id": "abc-123-...",
"display_id": "TX1234567890",
"amount": 50.00,
"currency": "BZD",
"status": "completed",
"transaction_type": "payment",
"payment_processor": "plug_n_pay",
"card_type": "Visa",
"card_last_four": "1234",
"reference_number": "TX1234567890",
"created_at": "2026-04-15T14:30:00.000Z",
"updated_at": "2026-04-15T14:30:02.000Z",
"metadata": {
"customer_name": "John Doe",
"authorization_code": "AUTH123",
"gateway_reference": "2026041514300000001"
}
}
],
"pagination": {
"total": 142,
"page": 1,
"limit": 50,
"total_pages": 3
}
}
Get Transaction#
Retrieve details for a single transaction by ID.
Endpoint: GET /transactions/:id
Example Request:
curl -X GET "https://api.openpay.bz/v1/transactions/abc-123-..." \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": {
"id": "abc-123-...",
"amount": 50.00,
"currency": "BZD",
"status": "completed",
"payment_processor": "plug_n_pay",
"card_type": "Visa",
"card_last_four": "1234",
"verification_required": false,
"created_at": "2026-04-15T14:30:00.000Z",
"metadata": {
"customer_name": "John Doe",
"authorization_code": "AUTH123",
"gateway_reference": "2026041514300000001"
}
}
}
verification_required is true when the transaction is a card-verification micro-charge awaiting amount confirmation. It is false (or omitted) for completed payments.
Refunds#
Refund a completed payment — fully or partially — through the API. Refunds are also available from the merchant dashboard; both paths run the same money movement.
Refund a Transaction#
Refunds a completed transaction. Omit the body (or omit amount) for a full refund; supply an amount for a partial refund.
Endpoint: POST /transactions/:id/refund
Headers:
x-api-key: sk_live_...
Content-Type: application/json
Request Body (optional JSON):
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | No | Amount to refund. Omit for a full refund. When supplied, must be 0 < amount <= original amount (rounded to cents) or the request is rejected with 400 invalid_amount. |
reason |
string | No | Free-text reason recorded with the refund and echoed in the refund webhook (data.reason). |
Example Request — full refund (no body):
curl -X POST https://api.openpay.bz/v1/transactions/25501adf-48e0-41b7-bbbc-c831de450dd0/refund \
-H "x-api-key: sk_live_..."
Example Request — partial refund:
curl -X POST https://api.openpay.bz/v1/transactions/25501adf-48e0-41b7-bbbc-c831de450dd0/refund \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 40.00,
"reason": "Partial cancellation"
}'
Success Response (200 OK):
{
"id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"status": "refunded",
"amount_refunded": 40.00,
"currency": "BZD",
"refund_id": "REF-8291038"
}
status is refunded for a full refund and partially_refunded for a partial refund. refund_id is the gateway's refund reference, or null if the gateway did not return one.
Errors:
| Status | error |
Meaning |
|---|---|---|
404 |
transaction_not_found |
Unknown transaction, or it is not owned by your API key's merchant. |
400 |
transaction_not_refundable |
The transaction is not in a refundable state (not completed) or has no gateway reference. Carries a message. |
400 |
invalid_amount |
The supplied amount is not 0 < amount <= original amount. Carries a message. |
402 |
refund_failed |
The gateway declined the refund. Carries a message and, where available, a response_code. |
500 |
internal_error |
Something went wrong on our end. |
Example Error Response (402 Payment Required):
{
"error": "refund_failed",
"message": "Refund declined by gateway",
"response_code": "05"
}
A successful refund fires a webhook: refund.completed for a full refund, or refund.partial for a partial refund. See Webhooks to register an endpoint and verify delivery signatures.
Card Verification#
For security purposes, new cards must be verified before they can be used for payments. The verification process involves charging a small random amount to the card, which the customer must confirm.
The verification amount depends on the merchant's PlugNPay processing currency:
- BZD merchants: random amount between BZ$1.50 and BZ$5.00
- USD merchants: random amount between US$0.75 and US$2.50
The customer can submit either the exact amount charged or the approximate equivalent in the other currency (to account for bank FX conversions on international card statements).
Verification Flow#
- Customer attempts a payment with a new card
- API responds with
402status andVERIFICATION_INITIATEDerror code - A small random amount is charged to the card in the merchant's processing currency
- Customer checks their bank statement for the exact amount
- Customer submits the verification amount via the completion endpoint
- Upon successful verification, the card can be used for future payments
Initiate Card Verification#
Manually initiates the card verification process.
Endpoint: POST /cards/verification/initiate
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
card_number |
string | Yes | Full card number (16 digits) |
card_expiry_month |
string | Yes | Card expiry month (e.g., "12") |
card_expiry_year |
string | Yes | Card expiry year (e.g., "2025") |
card_cvv |
string | Yes | Card CVV/CVC code (3-4 digits) |
card_holder_name |
string | Yes | Name on the card |
customer_name |
string | No | Name of the customer |
customer_email |
string | No | Email of the customer |
currency |
string | No | Currency for the verification micro-charge ("BZD" or "USD"). Must be enabled for your gateway configuration. Defaults to your first enabled currency. |
Example Request:
curl -X POST https://api.openpay.bz/v1/cards/verification/initiate \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"card_number": "4541250000000001",
"card_expiry_month": "12",
"card_expiry_year": "2025",
"card_cvv": "123",
"card_holder_name": "John Doe",
"customer_name": "John Doe",
"customer_email": "john@example.com"
}'
Success Response (200 OK):
{
"success": true,
"verification_required": true,
"message": "Verification initiated. A small random amount has been charged to your card."
}
Response — verification not required (merchant policy):
Some merchants are configured to skip card verification for all cards or for Belize-issued (local) cards. When the card is exempt:
{
"success": true,
"verification_required": false,
"message": "Card verification is not required for this merchant."
}
No charge is made. Proceed directly to payment. When verification IS
required, the response includes "verification_required": true alongside
the existing fields.
Error Response — currency not enabled (400 Bad Request):
{
"success": false,
"error": "currency_not_enabled",
"message": "USD is not enabled for this merchant — no USD gateway configuration"
}
Complete Card Verification#
Completes the card verification by submitting the verification amount.
Endpoint: POST /cards/verification/complete
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
card_number |
string | Yes | Full card number (16 digits) |
card_expiry_month |
string | Yes | Card expiry month (e.g., "12") |
card_expiry_year |
string | Yes | Card expiry year (e.g., "2025") |
amount |
number | Yes | The verification amount from the bank statement (e.g., 3.42 for BZD, 1.71 for USD) |
Example Request:
curl -X POST https://api.openpay.bz/v1/cards/verification/complete \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"card_number": "4541250000000001",
"card_expiry_month": "12",
"card_expiry_year": "2025",
"amount": 3.42
}'
Success Response (200 OK):
{
"success": true,
"message": "Card verified successfully."
}
Error Response - Incorrect Amount (400 Bad Request):
{
"success": false,
"message": "Incorrect amount. Please try again.",
"remaining_attempts": 1
}
Error Response - Card Blocked (400 Bad Request):
After 2 failed verification attempts, the card is blocked:
{
"success": false,
"message": "Too many failed attempts. Card blocked.",
"remaining_attempts": 0
}
Saved Cards#
Saved Cards (card-on-file) let a customer vault a card once and have you charge it again later — subscriptions, repeat customers, pay-on-file checkouts — without your server ever touching the PAN. OpenPay hosts the card-entry form; you only ever see a card_token.
This feature must be enabled per merchant. It requires card_storage_enabled on your account and a branded-card processor of epx — contact OpenPay support to turn it on. While it's off, POST /cards/save-sessions and the card_token branch of POST /payments both return 403 card_storage_not_supported.
How it works#
- Your server creates a save session:
POST /v1/cards/save-sessions. You get back a hostedurl. - Send the customer to that
url(in-app browser, popup, redirect — your choice). OpenPay's hosted page collects the card number, expiry, and CVV directly; your server and your frontend never see them. - The customer submits the form. If the gateway declines the card, the same page lets them retry — up to 5 attempts per session — without starting over.
- On success, the session is marked
completedand a card is created. Depending on your verification policy, the card is either immediatelyactive, orpending_verification(a small micro-charge was made that the customer must confirm — see Verify a Saved Card below). - You find out the session finished one of two ways: poll
GET /v1/cards/save-sessions/:id, or subscribe to thecard.savedwebhook event. - Once a card is
active, charge it any time withPOST /v1/paymentsby passingcard_tokeninstead of raw card fields (see Process a Payment).
A save session is single-use and expires 30 minutes after creation; an expired or exhausted session cannot be resubmitted and a fresh one must be created.
The card object#
{
"token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"customer_reference": "ACC-1002",
"status": "active",
"created": 1759503600
}
| Field | Type | Description |
|---|---|---|
token |
string | Opaque card identifier (card_ + 32 hex chars). Pass this as card_token to charge the card. The underlying gateway vault token is never exposed. |
brand |
string | Card brand: visa, mastercard, amex, discover, or unknown. |
last4 |
string | Last 4 digits of the card number. |
exp_month |
number | Expiry month (1-12). |
exp_year |
number | Expiry year, 4 digits. |
customer_reference |
string | null | The value you passed when creating the save session, or null if you didn't pass one. Use it to look up a customer's cards with GET /v1/cards?customer_reference=.... |
status |
string | pending_verification (awaiting the micro-charge confirmation — not yet chargeable), active (chargeable), or removed (deleted; excluded from list results). |
created |
number | Unix timestamp (seconds) when the card was saved. |
Create a Save Session#
Creates a hosted session for capturing a new card. The returned url is a one-time link to OpenPay's hosted save-card page.
Endpoint: POST /cards/save-sessions
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
customer_reference |
string | No | Your own identifier for the customer this card belongs to (e.g. your internal account/user ID). Stored on the resulting card and filterable via GET /v1/cards?customer_reference=.... |
return_url |
string | No | Where the hosted page redirects the customer after a successful save. If omitted, the page shows a static confirmation and does not redirect. |
Example Request:
curl -X POST https://api.openpay.bz/v1/cards/save-sessions \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"customer_reference": "ACC-1002",
"return_url": "https://myshop.com/account/cards"
}'
Success Response (201 Created):
{
"id": "css_7f3a9c1d2e4b6f805a1c3d9e7b2f4a60",
"url": "https://merchant.openpay.bz/save-card/css_7f3a9c1d2e4b6f805a1c3d9e7b2f4a60",
"expires_at": "2026-10-03T15:00:00.000Z",
"status": "pending"
}
Error Response — card storage not enabled (403 Forbidden):
{
"error": "card_storage_not_supported"
}
Get Save Session Status#
Checks a save session's status. Poll this (or use the card.saved webhook) to find out when a customer has finished the hosted form.
Endpoint: GET /cards/save-sessions/:id
Example Request:
curl https://api.openpay.bz/v1/cards/save-sessions/css_7f3a9c1d2e4b6f805a1c3d9e7b2f4a60 \
-H "x-api-key: sk_live_..."
Success Response — still pending (200 OK):
{
"id": "css_7f3a9c1d2e4b6f805a1c3d9e7b2f4a60",
"status": "pending"
}
Success Response — completed (200 OK):
The card field is only present once status is completed.
{
"id": "css_7f3a9c1d2e4b6f805a1c3d9e7b2f4a60",
"status": "completed",
"card": {
"token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"customer_reference": "ACC-1002",
"status": "pending_verification",
"created": 1759503600
}
}
status on the session itself is one of pending, completed, expired, or failed (the session ran out of its 5-attempt capture budget).
Error Response — unknown or foreign session (404 Not Found):
{
"error": "session_not_found"
}
List Saved Cards#
Lists a merchant's saved cards, optionally filtered to one customer. There is no pagination in v1 — all matching cards are returned.
Endpoint: GET /cards
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_reference |
string | No | Return only cards saved with this customer_reference. Omit to list all of the merchant's cards. |
Example Request:
curl "https://api.openpay.bz/v1/cards?customer_reference=ACC-1002" \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"cards": [
{
"token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"customer_reference": "ACC-1002",
"status": "active",
"created": 1759503600
}
]
}
Removed cards never appear in this list.
Verify a Saved Card#
Confirms a pending_verification card by submitting the micro-charge amount the customer found on their bank statement — the same amount-confirmation flow described in Card Verification, applied at save time instead of at first charge. A card only reaches pending_verification status when your merchant account's verification policy requires it for that card; otherwise cards are saved directly as active and this step is skipped.
Endpoint: POST /cards/:token/verify
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | Yes | The verification amount from the cardholder's bank statement. |
Example Request:
curl -X POST https://api.openpay.bz/v1/cards/card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85/verify \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{"amount": 3.42}'
Success Response (200 OK):
The micro-charge is automatically refunded and the card becomes active.
{
"success": true,
"card": {
"token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"customer_reference": "ACC-1002",
"status": "active",
"created": 1759503600
}
}
Error Response — wrong amount (400 Bad Request):
{
"error": "amount_mismatch",
"attempts_remaining": 2
}
Up to 3 attempts are allowed per card. After the last wrong guess, the next attempt returns 429 instead (see below) and the card remains unusable until a new one is saved.
Error Response — card isn't awaiting verification (409 Conflict):
{
"error": "card_not_pending_verification"
}
Returned for an unknown token, or a card that's already active or removed.
Error Response — attempts exhausted (429 Too Many Requests):
{
"error": "verification_attempts_exhausted"
}
Error Response — refund failed, retriable (502 Bad Gateway):
{
"error": "verification_refund_failed"
}
The submitted amount was correct, but OpenPay couldn't refund the micro-charge on the gateway side. No attempt is consumed — the card stays pending_verification and you can safely retry the same POST (with the same amount).
Remove a Saved Card#
Permanently removes a saved card. The card stops appearing in GET /cards and can no longer be charged. If the card backs any active or past_due subscriptions, each one is automatically paused (not canceled) so it never falls into the normal payment-failure retry schedule for a card that's already gone — see the note under Pause, Resume, and Cancel a Subscription.
Endpoint: DELETE /cards/:token
Example Request:
curl -X DELETE https://api.openpay.bz/v1/cards/card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85 \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true
}
Error Response — unknown, foreign, or already-removed token (404 Not Found):
{
"error": "card_token_invalid"
}
Charging a Saved Card#
Once a card is active, charge it through the same payments endpoint used for raw card numbers — pass card_token instead of card_number/card_expiry_month/card_expiry_year/card_cvv/card_holder_name. See Process a Payment for the full request/response shape and error table (card_token_exclusive, card_storage_not_supported, card_token_invalid, card_not_chargeable); the usual currency_not_enabled gate applies as well.
Subscriptions#
Subscriptions bill a customer's saved card automatically, on a fixed schedule, until canceled — memberships, recurring service fees, anything you'd otherwise have to remember to charge yourself. You define reusable plans (or pass terms inline per subscription), OpenPay hosts the enrollment page where the customer enters their card once, and a background billing engine takes it from there: first charge, every renewal, and a dunning ladder of retries if a charge is declined.
This feature shares the Saved Cards gate. It requires card_storage_enabled on your account and a branded-card processor of epx — contact OpenPay support to turn it on. While it's off, POST /subscription-plans and POST /subscriptions both return 403 card_storage_not_supported.
How it works#
- Create a plan:
POST /v1/subscription-plans. You get back atokenand a ready-to-sharesubscribe_url. - Send the customer to
subscribe_url(email, SMS, your checkout page — your choice). OpenPay's hosted page shows the plan terms, collects the card number/expiry/CVV and the customer's email, and shows them the exact authorization sentence they're agreeing to. Your server and your frontend never see the card. - On submit, OpenPay vaults the card (or defers to verification first, per your policy) and creates the subscription. If the card is immediately chargeable, the first charge runs right away.
- From then on, the billing engine charges the card on schedule, automatically. Every outcome — a successful charge, a decline, a pause — is delivered to you as a webhook event and (for the customer-facing ones) an email.
- You can also create a subscription directly via
POST /v1/subscriptionsagainst a card you already saved through the ordinary save-card flow — no hosted enrollment page needed — passing either aplan_idor inlineamount/currency/interval. - Manage subscriptions (and the plans backing them) at any time with the list/detail/pause/resume/cancel endpoints below, or from the merchant dashboard.
The Plan Object#
{
"token": "plan_2ffa050d75e9d6c58b0f433688dd0876",
"name": "Pro Plan",
"description": "Monthly pro access",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"status": "active",
"subscribe_url": "https://merchant.openpay.bz/subscribe/plan_2ffa050d75e9d6c58b0f433688dd0876",
"created": 1759590000
}
| Field | Type | Description |
|---|---|---|
token |
string | Opaque plan identifier (plan_ + 32 hex chars). Pass this as plan_id when creating a subscription. |
name |
string | Plan name, shown to customers on the hosted subscribe page. |
description |
string | null | Optional longer description, also shown on the hosted page. |
amount |
number | Amount charged every billing period. |
currency |
string | BZD or USD — must be enabled for your merchant account. |
interval |
string | Billing cadence: weekly, monthly, or yearly. |
status |
string | active (subscribable) or archived (hidden from new enrollment, existing subscriptions unaffected). |
subscribe_url |
string | Ready-to-share hosted enrollment link for this plan. Share it as-is — QR code, email, your own checkout page. |
created |
number | Unix timestamp (seconds) when the plan was created. |
Create a Plan#
Endpoint: POST /subscription-plans
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Plan name, 1-120 characters. |
description |
string | No | Longer description shown on the hosted subscribe page. |
amount |
number | Yes | Amount charged every billing period. Must be positive. |
currency |
string | Yes | BZD or USD, and must be enabled for your account. |
interval |
string | Yes | weekly, monthly, or yearly. |
Example Request:
curl -X POST https://api.openpay.bz/v1/subscription-plans \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Pro Plan",
"description": "Monthly pro access",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly"
}'
Success Response (201 Created):
{
"plan": {
"token": "plan_2ffa050d75e9d6c58b0f433688dd0876",
"name": "Pro Plan",
"description": "Monthly pro access",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"status": "active",
"subscribe_url": "https://merchant.openpay.bz/subscribe/plan_2ffa050d75e9d6c58b0f433688dd0876",
"created": 1759590000
}
}
Error Response — validation (400 Bad Request):
{
"error": "invalid_request",
"message": "amount must be a positive number"
}
List Plans#
Lists all of a merchant's plans — active and archived alike, so you can still look up terms for a plan you've since retired.
Endpoint: GET /subscription-plans
Example Request:
curl https://api.openpay.bz/v1/subscription-plans \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"plans": [
{
"token": "plan_2ffa050d75e9d6c58b0f433688dd0876",
"name": "Pro Plan",
"description": "Monthly pro access",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"status": "active",
"subscribe_url": "https://merchant.openpay.bz/subscribe/plan_2ffa050d75e9d6c58b0f433688dd0876",
"created": 1759590000
}
]
}
Archive a Plan#
Archiving a plan removes it from new enrollment (its subscribe_url starts returning plan_not_found) without touching subscriptions that already exist on it — they keep billing normally. There is no un-archive; create a new plan instead.
Endpoint: POST /subscription-plans/:token/archive
Example Request:
curl -X POST https://api.openpay.bz/v1/subscription-plans/plan_2ffa050d75e9d6c58b0f433688dd0876/archive \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"plan": {
"token": "plan_2ffa050d75e9d6c58b0f433688dd0876",
"name": "Pro Plan",
"description": "Monthly pro access",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"status": "archived",
"subscribe_url": "https://merchant.openpay.bz/subscribe/plan_2ffa050d75e9d6c58b0f433688dd0876",
"created": 1759590000
}
}
Error Response — unknown or foreign token (404 Not Found):
{
"error": "plan_not_found"
}
The Subscription Object#
{
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "active",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": "ACC-1002",
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"next_charge_at": 1762182000,
"failure_count": 0,
"created": 1759590000,
"card": { "brand": "visa", "last4": "4242" }
}
| Field | Type | Description |
|---|---|---|
id |
string | Opaque subscription identifier (sub_ + 32 hex chars). |
status |
string | active, past_due (a charge has failed and is being retried — see Dunning & Retries), paused (retries exhausted, or you paused it — not billed, resumable), or canceled (terminal — never bills again). |
amount / currency / interval |
number / string / string | The billing terms, copied from the plan at creation time (or set inline) and fixed for the life of the subscription — changing a plan's terms later does not affect existing subscriptions. |
customer_reference |
string | null | Your own identifier for the customer, if you passed one. |
customer_email |
string | null | Customer email — required on the hosted enroll page; optional via the API. Used for dunning/receipt emails. |
customer_name |
string | null | Customer name, if provided. |
next_charge_at |
number | Unix timestamp (seconds) of the next scheduled charge attempt. |
failure_count |
number | Consecutive billing failures since the last successful charge (or since creation). Resets to 0 on any successful charge or on resume. |
created |
number | Unix timestamp (seconds) when the subscription was created. |
paused_at |
number | Present only when status is paused. |
canceled_at |
number | Present only when status is canceled. |
card |
object | { brand, last4 } of the card being billed — never the underlying card token or gateway vault reference. |
Create a Subscription#
Creates a subscription against a saved card and, if the card is chargeable, runs the first charge immediately through the same billing engine every renewal uses. Specify the billing terms either by plan_id or inline amount/currency/interval — never both, and never neither.
Consent: creating a subscription through this endpoint records consent_source: "api" on the subscription. OpenPay does not collect or store any consent text or timestamp for this path — by calling it, you're attesting that you already obtained the customer's explicit authorization for recurring charges in your own flow (your checkout, your terms-of-service acceptance, whatever your integration looks like), and you're responsible for keeping your own evidence of that authorization. This is different from the hosted subscribe link, where OpenPay itself presents the authorization sentence to the customer and stores the exact consent text and timestamp (consent_source: "hosted").
Endpoint: POST /subscriptions
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
card_token |
string | Yes | A saved card token. Must be active — a pending_verification card returns 409 card_not_chargeable. |
plan_id |
string | One of plan_id or inline terms |
A plan token from Create a Plan. Mutually exclusive with amount/currency/interval. |
amount |
number | One of plan_id or inline terms |
Inline amount, if not using a plan. |
currency |
string | One of plan_id or inline terms |
Inline currency (BZD/USD), if not using a plan. |
interval |
string | One of plan_id or inline terms |
Inline interval (weekly/monthly/yearly), if not using a plan. |
customer_reference |
string | No | Your own identifier for the customer. |
customer_email |
string | No | Customer email, for receipt/dunning emails. |
customer_name |
string | No | Customer name. |
Example Request (by plan):
curl -X POST https://api.openpay.bz/v1/subscriptions \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"card_token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"plan_id": "plan_2ffa050d75e9d6c58b0f433688dd0876",
"customer_reference": "ACC-1002",
"customer_email": "jane@example.com"
}'
Success Response (201 Created) — first charge succeeded:
{
"subscription": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "active",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": "ACC-1002",
"customer_email": "jane@example.com",
"customer_name": null,
"next_charge_at": 1764774000,
"failure_count": 0,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" }
},
"first_charge": {
"success": true,
"transaction_id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"message": "Payment processed successfully"
}
}
A declined first charge still returns 201 — the subscription is created and already in the dunning ladder, not rejected outright:
{
"subscription": {
"id": "sub_...",
"status": "past_due",
"failure_count": 1,
"...": "..."
},
"first_charge": {
"success": false,
"message": "Initial charge was declined (05: DECLINE). The subscription has been created and will retry automatically per the dunning schedule."
}
}
Error Responses:
| Status | error |
Meaning |
|---|---|---|
403 |
card_storage_not_supported |
This merchant isn't enabled for card storage/subscriptions. |
400 |
invalid_request |
Missing card_token, both plan_id and inline terms given, neither given, or an inline field failed validation. |
404 |
plan_not_found |
Unknown plan_id, a foreign-merchant plan, or an archived plan. |
404 |
card_token_invalid |
Unknown card_token, a foreign-merchant card, or a removed card. |
409 |
card_not_chargeable |
The card is pending_verification — verify it first. |
400 |
currency_not_enabled |
The plan/inline currency isn't enabled for your account. |
List Subscriptions#
Endpoint: GET /subscriptions
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_reference |
string | No | Filter to one customer. |
status |
string | No | Filter by status: active, past_due, paused, or canceled. |
Example Request:
curl "https://api.openpay.bz/v1/subscriptions?status=active" \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"subscriptions": [
{
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "active",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": "ACC-1002",
"customer_email": "jane@example.com",
"customer_name": null,
"next_charge_at": 1764774000,
"failure_count": 0,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" }
}
]
}
Get a Subscription#
Returns the subscription plus its last 10 charges, newest first.
Endpoint: GET /subscriptions/:id
Example Request:
curl https://api.openpay.bz/v1/subscriptions/sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85 \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"subscription": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "active",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": "ACC-1002",
"customer_email": "jane@example.com",
"customer_name": null,
"next_charge_at": 1764774000,
"failure_count": 0,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" }
},
"charges": [
{
"id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"display_id": "OPY-00042",
"amount": 19.99,
"currency": "BZD",
"status": "completed",
"transaction_type": "payment",
"payment_processor": "epx",
"card_type": "visa",
"card_last_four": "4242",
"reference_number": null,
"created_at": "2026-10-04T12:00:00.000Z",
"updated_at": "2026-10-04T12:00:00.000Z",
"metadata": {
"subscription_id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"subscription_charge": true
}
}
]
}
Each entry in charges is a normal transaction scoped to this subscription — capped at 10, newest first. metadata is sanitized the same way every other transaction response is: internal gateway fields (epx_bric, gateway_reference) are never included.
Error Response — unknown or foreign id (404 Not Found):
{
"error": "subscription_not_found"
}
Pause, Resume, and Cancel a Subscription#
Endpoints:
POST /subscriptions/:id/pause
POST /subscriptions/:id/resume
POST /subscriptions/:id/cancel
- Pause — valid from
activeorpast_due. Stops billing immediately; the subscription can be resumed later. Already-pausedorcanceledreturns409 invalid_state. - Resume — valid only from
paused. Recomputesnext_charge_atto the next anchor-aligned instant and resetsfailure_countto0— the dunning ladder starts fresh. Resuming anactiveorcanceledsubscription returns409 invalid_state. - Cancel — valid from any non-
canceledstatus. Terminal: oncecanceled, a subscription can never be resumed or billed again. Canceling an already-canceledsubscription returns409 invalid_state.
A subscription is also paused automatically, with no action on your part, if its backing card is removed — you'll get a merchant-notification email (not the customer), and the customer is never sent a payment-failure email for it.
Example Request:
curl -X POST https://api.openpay.bz/v1/subscriptions/sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85/cancel \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"subscription": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "canceled",
"canceled_at": 1762185600,
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": "ACC-1002",
"customer_email": "jane@example.com",
"customer_name": null,
"next_charge_at": 1764774000,
"failure_count": 0,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" }
}
}
Error Responses:
| Status | error |
Meaning |
|---|---|---|
404 |
subscription_not_found |
Unknown id, or belongs to another merchant. |
409 |
invalid_state |
The action doesn't apply to the subscription's current status (see the rules above). |
The Hosted Subscribe Link#
Every plan's subscribe_url points to an OpenPay-hosted enrollment page — the subscription equivalent of the save-card hosted page, tuned for self-serve sign-up rather than a merchant-initiated save. You don't call this endpoint yourself; you hand the link to your customer (email, SMS, a "Subscribe" button on your site) and the subscription shows up in your account once they finish.
- The page loads plan terms and your branding from
GET /api/public/subscribe/:planToken/info(no API key — this is a public capability URL; it exposes plan name/amount/branding only, never customer data). - The customer enters their email and card, and checks a consent box next to the exact sentence they're agreeing to:
"You authorize {your business name} to charge {amount} {currency} every {interval} until canceled."— this text is stored verbatim on the subscription (consent_source: "hosted") and included in their confirmation email. - On submit (
POST /api/public/subscribe/:planToken), OpenPay vaults the card, creates the subscription, and — if your verification policy doesn't require a micro-charge confirmation for this card — runs the first charge immediately, exactly like Create a Subscription. - If your verification policy does apply, the card lands
pending_verificationand the subscription startspast_duewith no charge attempted yet; the response sets"pending_verification": trueand the confirmation email tells the customer to confirm the micro-charge amount (same flow as Verify a Saved Card). The billing engine picks the subscription back up automatically the moment the card activates. - A customer can only have one active-or-past-due subscription per plan per email — resubmitting the form (double-click, a retried page load) gets
409 already_subscribedinstead of a second subscription and a second charge. A previously canceled subscription on the same plan/email doesn't block re-enrolling. - The page is rate-limited against card testing: five declined attempts from the same card fingerprint block further attempts with
429for a cooldown window, before a session is even minted or the gateway is touched.
Hosted-page outcomes (returned to the customer's browser, not to your API-key-authed server, but worth knowing when interpreting what happened):
| Status | Body | Meaning |
|---|---|---|
200 |
{ "success": true, "subscription": {...}, "first_charge": {...} } |
Enrolled, card active, first charge attempted (see first_charge.success for the outcome). |
200 |
{ "success": true, "subscription": {...}, "pending_verification": true } |
Enrolled, card pending verification — no charge yet. |
404 |
{ "error": "plan_not_found" } |
Unknown, foreign, or archived plan token. |
400 |
{ "error": "consent_required" } |
The consent checkbox wasn't checked (literal true required). |
400 |
{ "error": "invalid_request", "message": "customer_email is required" } |
Missing or malformed customer_email, or a malformed card field. |
402 |
{ "error": "card_declined" } |
The gateway declined the card. Retriable — the page lets the customer try again. |
409 |
{ "error": "already_subscribed" } |
This email already has an active/past-due subscription on this plan. |
429 |
{ "success": false, "message": "..." } |
Card-testing abuse gate tripped. |
Dunning & Retries#
When a scheduled charge fails, the billing engine doesn't give up after one try — it retries on a fixed schedule (the "dunning ladder"), then pauses the subscription if every retry fails:
There are three charge attempts total per billing period — the original scheduled date, then two retries — before the subscription is paused:
| Attempt | When | On failure |
|---|---|---|
| Original charge | The subscription's scheduled next_charge_at |
status → past_due, failure_count → 1, retry scheduled for +1 day from the original due date. Customer gets a "payment failed, we'll retry" email. |
| Retry 1 | Original due date +1 day | failure_count → 2, retry scheduled for +3 days from the original due date. Another customer email. |
| Retry 2 | Original due date +3 days | failure_count → 3, the third and final attempt → status → paused. Customer gets a final "payment failed, no more retries" email; you get a "subscription paused" email. |
Both retry offsets (+1/+3) are always anchored to the original scheduled date, never to whichever retry most recently failed — so the schedule never creeps. There is no per-merchant setting to change the number of attempts.
A successful charge at any point resets failure_count to 0 and clears the ladder. Resuming a paused subscription also resets failure_count to 0 and recomputes next_charge_at to the next anchor-aligned instant, giving it a clean slate.
Some failures skip the ladder entirely and pause immediately (failure_count still only reaches 1, not 3) because retrying wouldn't help — a merchant-side gate problem (card storage disabled, currency no longer enabled). These still emit subscription.paused and the merchant-notification email, but skip the customer-facing "we'll retry" email since there's nothing the customer can do to fix a merchant-side gate issue.
A card that's still pending_verification (the verification policy hasn't been satisfied yet) is handled differently again — it isn't a decline, so it never touches the dunning ladder at all. Every day the engine finds the charge due, it quietly defers it by one more day: no customer or merchant email, no webhook, failure_count untouched. This continues until either the customer confirms the micro-charge amount (the card goes active and billing proceeds normally from there), or 7 days pass since the subscription's enrollment anchor with the card still unconfirmed — at which point the engine gives up, pauses the subscription with last_failure_reason: "verification_timeout", and sends you (the merchant) the "subscription paused" notification email. The customer is not emailed for this path — there's nothing a retry would fix for them.
Every state change here fires the matching webhook event (subscription.charged, subscription.payment_failed, subscription.paused) — use those instead of polling if you want to react in real time.
Webhooks#
Webhooks let you receive real-time notifications of payment outcomes without polling the /transactions or /paylinks endpoints. When an event occurs — a paylink is paid, a payment fails, or a refund is issued — OpenPay immediately POSTs a signed JSON payload to each active endpoint you have registered.
How it works#
- Register an HTTPS endpoint using
POST /v1/webhooks. - OpenPay delivers a signed
POSTrequest to that URL whenever a matching event fires. - Your endpoint verifies the signature (see Signature Verification) and returns any
2xxstatus to acknowledge receipt. - If delivery fails (non-2xx or timeout), OpenPay retries with exponential backoff (see Retry Policy).
Event catalog#
OpenPay emits eleven subscribable event types. Each delivery is an event envelope with the shape:
{
"id": "evt_<32 hex chars>",
"type": "<event.type>",
"created": 1754000000,
"data": { ... }
}
| Field | Type | Description |
|---|---|---|
id |
string | Unique event ID (evt_ + 32 lowercase hex chars). Use this to deduplicate — deliveries are at-least-once. |
type |
string | One of the eleven event types below. |
created |
number | Unix timestamp (seconds) when the event was built. |
data |
object | Event-specific payload (see below). |
paylink.paid
Emitted when a customer completes payment through a paylink.
{
"id": "evt_3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"type": "paylink.paid",
"created": 1754001234,
"data": {
"transaction_id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"display_id": "OPY-00042",
"status": "completed",
"amount": 1.00,
"currency": "BZD",
"processed_amount": 0.50,
"processed_currency": "USD",
"paylink_id": "cafe62fc-11e5-4000-a11b-e95f0f5f54e7",
"external_reference": "WEBSITE-1043",
"channel": "web",
"paid_via_wallet": false
}
}
amount and currency represent the amount the customer agreed to pay (the presentment amount).
processed_amount and processed_currency are present when OpenPay converted the charge to the merchant account's settlement currency (e.g., PlugNPay single-MID merchants, BeaconPay USD charges). When conversion occurred, settlement and refunds happen in the processed_currency, not the presentment currency. These fields are null when no conversion occurred.
external_reference is the value you set when creating the paylink (null if you didn't). This is how you route by product: set a product-prefixed reference on the paylink (e.g., WEBSITE-1043, HOSTING-2231) and branch on it in your handler — one endpoint can fulfil many products, and multiple endpoints can each self-filter (ack 200 and drop refs that aren't theirs). A single event type (paylink.paid) is delivered to every subscribed endpoint regardless of product; the product distinction lives in external_reference.
payment.completed
Emitted when a direct API payment (not paylink-sourced) is processed successfully.
{
"id": "evt_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"type": "payment.completed",
"created": 1754001300,
"data": {
"transaction_id": "b7e12abc-0001-4001-8001-a00000000001",
"display_id": "OPY-00043",
"status": "completed",
"amount": 50.00,
"currency": "BZD",
"processed_amount": null,
"processed_currency": null,
"paylink_id": null,
"channel": null,
"paid_via_wallet": false
}
}
amount and currency represent the amount the customer agreed to pay (the presentment amount).
processed_amount and processed_currency are present when OpenPay converted the charge to the merchant account's settlement currency (e.g., PlugNPay single-MID merchants, BeaconPay USD charges). When conversion occurred, settlement and refunds happen in the processed_currency, not the presentment currency. These fields are null when no conversion occurred.
payment.failed
Emitted when a payment attempt is declined or encounters a gateway error. No card data or raw gateway response is included.
{
"id": "evt_9f8e7d6c5b4a3b2c1d0e9f8e7d6c5b4a",
"type": "payment.failed",
"created": 1754001400,
"data": {
"transaction_id": "c3d45678-0002-4002-8002-b00000000002",
"status": "failed",
"amount": 75.00,
"currency": "BZD",
"processed_amount": null,
"processed_currency": null,
"paylink_id": "cafe62fc-11e5-4000-a11b-e95f0f5f54e7",
"response_code": "INSUFFICIENT_FUNDS",
"error_message": "Insufficient funds"
}
}
amount and currency represent the amount the customer agreed to pay (the presentment amount).
processed_amount and processed_currency are present when OpenPay converted the charge to the merchant account's settlement currency (e.g., PlugNPay single-MID merchants, BeaconPay USD charges). When conversion occurred, settlement and refunds happen in the processed_currency, not the presentment currency. These fields are null when no conversion occurred.
paylink_id is omitted from the data object when the payment was not paylink-sourced.
refund.completed
Emitted after a full refund is successfully processed.
{
"id": "evt_2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e",
"type": "refund.completed",
"created": 1754002000,
"data": {
"transaction_id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"status": "refunded",
"amount": 150.00,
"original_amount": 150.00,
"currency": "BZD",
"refund_id": "REF-8291038",
"is_partial": false,
"reason": "Customer request"
}
}
refund.partial
Emitted after a partial refund is successfully processed.
{
"id": "evt_4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f",
"type": "refund.partial",
"created": 1754002100,
"data": {
"transaction_id": "25501adf-48e0-41b7-bbbc-c831de450dd0",
"status": "partially_refunded",
"amount": 50.00,
"original_amount": 150.00,
"currency": "BZD",
"refund_id": "REF-8291039",
"is_partial": true,
"reason": "Partial cancellation"
}
}
card.saved
Emitted once per save session when a customer successfully vaults a card — at most once per session, even across retries. data is the full card object.
{
"id": "evt_5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a",
"type": "card.saved",
"created": 1759503600,
"data": {
"token": "card_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"customer_reference": "ACC-1002",
"status": "pending_verification",
"created": 1759503600
}
}
status reflects whether the merchant's verification policy applied to this card: "pending_verification" when the customer must still confirm the micro-charge (see Verify a Saved Card) before it is chargeable, or "active" when no verification was required.
subscription.created
Emitted once per subscription, right after enrollment — whether created via POST /subscriptions or through the hosted subscribe link. data is the full subscription object; its status may already be past_due if the enrolling card needed verification first.
{
"id": "evt_6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b",
"type": "subscription.created",
"created": 1762182000,
"data": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "active",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": null,
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"next_charge_at": 1764774000,
"failure_count": 0,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" }
}
}
subscription.charged
Emitted every time a scheduled (or immediate first) charge succeeds. data is the subscription object as it stands right after the charge — next_charge_at already advanced to the following period, failure_count reset to 0 — plus the transaction_id of the charge that just landed.
{
"id": "evt_7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
"type": "subscription.charged",
"created": 1764774005,
"data": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "active",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": null,
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"next_charge_at": 1767366000,
"failure_count": 0,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" },
"transaction_id": "25501adf-48e0-41b7-bbbc-c831de450dd0"
}
}
subscription.payment_failed
Emitted when a scheduled charge is declined and the subscription still has retries left on the dunning ladder — status moves to past_due, next_charge_at is the next scheduled retry.
{
"id": "evt_8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d",
"type": "subscription.payment_failed",
"created": 1767366010,
"data": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "past_due",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": null,
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"next_charge_at": 1767452400,
"failure_count": 1,
"created": 1762182000,
"card": { "brand": "visa", "last4": "4242" }
}
}
subscription.paused
Emitted when the dunning ladder is exhausted (failure_count hits the failure limit) or an unretryable condition pauses the subscription immediately (gate disabled, currency no longer enabled, or a card stuck pending_verification too long). Billing stops until the subscription is resumed.
{
"id": "evt_9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e",
"type": "subscription.paused",
"created": 1767625210,
"data": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "paused",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": null,
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"next_charge_at": 1767366000,
"failure_count": 3,
"created": 1762182000,
"paused_at": 1767625210,
"card": { "brand": "visa", "last4": "4242" }
}
}
subscription.canceled
Emitted when a subscription is canceled — by you via POST /subscriptions/:id/cancel, or from your OpenPay dashboard. There is no customer-facing self-serve cancel; cancellation is always something you (the merchant) initiate. Terminal: a canceled subscription never bills or re-fires this event again.
{
"id": "evt_0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
"type": "subscription.canceled",
"created": 1767700000,
"data": {
"id": "sub_4b2f9a1d6c3e8f705b2a1d9e6c3f0a85",
"status": "canceled",
"amount": 19.99,
"currency": "BZD",
"interval": "monthly",
"customer_reference": null,
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"next_charge_at": 1767366000,
"failure_count": 0,
"created": 1762182000,
"canceled_at": 1767700000,
"card": { "brand": "visa", "last4": "4242" }
}
}
Idempotency#
OpenPay delivers events at-least-once. Network retries or delivery worker restarts may cause the same event to arrive more than once. Always deduplicate on the id field before applying business logic.
Signature verification#
Every delivery request carries an X-OpenPay-Signature header. Verify it to confirm the payload came from OpenPay and has not been tampered with.
Header format
X-OpenPay-Signature: t=1754001234,v1=a1b2c3d4...
| Part | Description |
|---|---|
t |
Unix timestamp (seconds) when the request was signed. |
v1 |
HMAC-SHA256 hex digest — see construction below. |
Signature construction
signed_payload = "<t>.<raw_request_body>"
v1 = HMAC_SHA256(signing_secret, signed_payload)
<t>is the literal decimal string from thet=part of the header.<raw_request_body>is the exact bytes received over the network — do not parse and re-serialize, as whitespace differences will break the signature.signing_secretis the fullwhsec_<48 hex chars>string shown when you created or rotated the endpoint.
Replay protection
Reject any delivery whose timestamp t is more than 5 minutes in the past (or future). This prevents replay attacks.
Node.js verification example
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
// Parse header: "t=1754001234,v1=abc123..."
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const t = parts['t'];
const v1 = parts['v1'];
if (!t || !v1) throw new Error('Missing signature parts');
// Replay protection: reject if timestamp is older than 5 minutes
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - parseInt(t, 10)) > 300) {
throw new Error('Timestamp too old — possible replay attack');
}
// Compute expected signature
const signedPayload = `${t}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Constant-time comparison to prevent timing attacks
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(v1, 'hex');
if (
expectedBuf.length !== receivedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
throw new Error('Signature mismatch');
}
return true; // Signature valid
}
// Express usage (use express.raw() for the webhook route, not express.json())
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
verifyWebhook(
req.body.toString('utf8'),
req.headers['x-openpay-signature'],
process.env.OPENPAY_WEBHOOK_SECRET
);
// Process event...
res.sendStatus(200);
});
Important: You must verify over the raw request body bytes, not a re-serialized JSON object. Use a body-parsing middleware that preserves the raw buffer (e.g.,
express.raw()), notexpress.json().
Python verification example
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
# Parse header: "t=1754001234,v1=abc123..."
parts = dict(p.split('=', 1) for p in signature_header.split(','))
t = parts.get('t')
v1 = parts.get('v1')
if not t or not v1:
raise ValueError('Missing signature parts')
# Replay protection: reject if timestamp is older than 5 minutes
now = int(time.time())
if abs(now - int(t)) > 300:
raise ValueError('Timestamp too old — possible replay attack')
# Compute expected signature over raw body bytes
signed_payload = f'{t}.'.encode() + raw_body
expected = hmac.new(
secret.encode('utf-8'),
signed_payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected, v1):
raise ValueError('Signature mismatch')
return True
# Flask usage
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
verify_webhook(
request.get_data(), # raw bytes — do not call request.json
request.headers.get('X-OpenPay-Signature', ''),
os.environ['OPENPAY_WEBHOOK_SECRET']
)
event = request.get_json()
# Process event...
return '', 200
Request headers sent with every delivery
| Header | Description |
|---|---|
Content-Type |
application/json |
X-OpenPay-Signature |
t=<unixSeconds>,v1=<hex> — see above |
X-OpenPay-Event |
The event type, e.g. paylink.paid |
X-OpenPay-Delivery |
Unique delivery ID for this attempt |
Retry policy#
If your endpoint returns a non-2xx status or the connection times out, OpenPay retries the delivery automatically.
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
After 6 failed attempts the delivery is dead-lettered (status: "failed") and no further retries are made. Each POST attempt has a 10-second timeout. If your endpoint is temporarily down, fix it and use POST /v1/webhooks/:id/test to send a fresh test event once it recovers — dead-lettered deliveries are not replayed.
A 2xx response — any status from 200 through 299 — is treated as success. The response body is ignored.
Management API#
All webhook management endpoints are under /v1/webhooks and require your API key in the x-api-key header. Endpoints are scoped to your merchant account; you cannot see or modify another merchant's webhooks.
The signing secret is shown in full only on POST /v1/webhooks (create) and POST /v1/webhooks/:id/rotate-secret. All other read responses return the secret masked as whsec_…<last4>.
Create a webhook endpoint
POST /v1/webhooks
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | HTTPS URL to receive events |
event_types |
array | No | List of event types to subscribe to. Omit or pass [] to subscribe to all events. |
description |
string | No | Human-readable label for this endpoint |
curl -X POST https://api.openpay.bz/v1/webhooks \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://myshop.com/webhooks/openpay",
"event_types": ["paylink.paid", "refund.completed", "refund.partial"],
"description": "Production payment notifications"
}'
Success Response (201 Created):
{
"success": true,
"data": {
"id": "wh_01234567-89ab-cdef-0123-456789abcdef",
"url": "https://myshop.com/webhooks/openpay",
"signing_secret": "whsec_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
"event_types": ["paylink.paid", "refund.completed", "refund.partial"],
"description": "Production payment notifications",
"is_active": true,
"created_at": "2026-08-03T10:00:00.000Z",
"updated_at": "2026-08-03T10:00:00.000Z"
}
}
Save signing_secret immediately — it is not shown again.
List webhook endpoints
GET /v1/webhooks
curl https://api.openpay.bz/v1/webhooks \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": [
{
"id": "wh_01234567-89ab-cdef-0123-456789abcdef",
"url": "https://myshop.com/webhooks/openpay",
"signing_secret": "whsec_…cdef",
"event_types": ["paylink.paid", "refund.completed", "refund.partial"],
"description": "Production payment notifications",
"is_active": true,
"created_at": "2026-08-03T10:00:00.000Z",
"updated_at": "2026-08-03T10:00:00.000Z"
}
]
}
Get a webhook endpoint
GET /v1/webhooks/:id
curl https://api.openpay.bz/v1/webhooks/wh_01234567-89ab-cdef-0123-456789abcdef \
-H "x-api-key: sk_live_..."
Update a webhook endpoint
PATCH /v1/webhooks/:id
All fields are optional; only the fields you send are updated.
| Field | Type | Description |
|---|---|---|
url |
string | New HTTPS destination URL |
event_types |
array | New event type subscription list ([] = all) |
description |
string | New label |
is_active |
boolean | false to pause delivery without deleting the endpoint |
curl -X PATCH https://api.openpay.bz/v1/webhooks/wh_01234567-89ab-cdef-0123-456789abcdef \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{"is_active": false}'
Success Response (200 OK):
{
"success": true,
"data": {
"id": "wh_01234567-89ab-cdef-0123-456789abcdef",
"url": "https://myshop.com/webhooks/openpay",
"signing_secret": "whsec_…cdef",
"event_types": ["paylink.paid", "refund.completed", "refund.partial"],
"description": "Production payment notifications",
"is_active": false,
"created_at": "2026-08-03T10:00:00.000Z",
"updated_at": "2026-08-03T10:15:00.000Z"
}
}
Delete a webhook endpoint
DELETE /v1/webhooks/:id
Returns 204 No Content on success.
curl -X DELETE https://api.openpay.bz/v1/webhooks/wh_01234567-89ab-cdef-0123-456789abcdef \
-H "x-api-key: sk_live_..."
Rotate the signing secret
POST /v1/webhooks/:id/rotate-secret
Generates a new signing secret for the endpoint. The previous secret is immediately invalidated. Update your verification logic before rotating in production.
curl -X POST https://api.openpay.bz/v1/webhooks/wh_01234567-89ab-cdef-0123-456789abcdef/rotate-secret \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": {
"id": "wh_01234567-89ab-cdef-0123-456789abcdef",
"url": "https://myshop.com/webhooks/openpay",
"signing_secret": "whsec_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1",
"event_types": ["paylink.paid", "refund.completed", "refund.partial"],
"description": "Production payment notifications",
"is_active": true,
"created_at": "2026-08-03T10:00:00.000Z",
"updated_at": "2026-08-03T10:30:00.000Z"
}
}
Send a test event
POST /v1/webhooks/:id/test
Enqueues a webhook.test event to the endpoint immediately. Use this to confirm your server is reachable and your signature verification is working. Rate-limited to 5 requests per 60 seconds per merchant.
curl -X POST https://api.openpay.bz/v1/webhooks/wh_01234567-89ab-cdef-0123-456789abcdef/test \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": {
"delivery_id": "dlv_fedcba9876543210fedcba9876543210",
"event_id": "evt_0123456789abcdef0123456789abcdef",
"event_type": "webhook.test",
"status": "pending"
}
}
The test payload your endpoint receives looks like:
{
"id": "evt_0123456789abcdef0123456789abcdef",
"type": "webhook.test",
"created": 1754001234,
"data": {
"message": "This is a test webhook event sent from the OpenPay API."
}
}
webhook.test is not a subscribable event type — it is sent regardless of your event_types subscription list.
List deliveries
GET /v1/webhook-deliveries
Returns recent delivery attempts across all your endpoints. Useful for debugging failed deliveries.
| Query parameter | Description |
|---|---|
endpoint_id |
Filter by a specific endpoint ID |
status |
Filter by status: pending, delivered, or failed |
limit |
Maximum number of results to return |
curl "https://api.openpay.bz/v1/webhook-deliveries?status=failed&limit=20" \
-H "x-api-key: sk_live_..."
Success Response (200 OK):
{
"success": true,
"data": [
{
"id": "dlv_fedcba9876543210fedcba9876543210",
"endpoint_id": "wh_01234567-89ab-cdef-0123-456789abcdef",
"event_id": "evt_3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"event_type": "paylink.paid",
"status": "failed",
"attempts": 6,
"last_http_status": 503,
"last_error": "HTTP 503: Service Unavailable",
"delivered_at": null,
"created_at": "2026-08-03T09:00:00.000Z",
"updated_at": "2026-08-03T15:00:00.000Z"
}
]
}
Test Mode & the Sandbox#
Merchants with status test or sandbox can use all API endpoints normally, but no real charges are processed. All gateway calls are simulated and transactions are tagged with test_mode: true in their metadata.
Getting a sandbox account#
Don't have an OpenPay account yet? Request sandbox access at www.openpay.bz — no paperwork required. You'll receive an invitation email; registering gives you the full merchant dashboard and API (your own API keys, payment links, hosted checkout) with every payment simulated. Sandbox accounts:
- process simulated payments only — no real card is ever charged and no money moves;
- are excluded from settlement and bank reporting;
- pause automatically after 60 days of inactivity (contact us to reactivate);
- can be upgraded in place: apply for a live account from your dashboard — your integration, branding, and payment links carry over through review.
How it works#
- All API responses include
"test_mode": trueat the top level when the merchant is in test mode. - The card verification flow works end-to-end (simulated charge, amount check, card status update) so you can test the full integration.
- Transactions, paylinks, and stored cards created in test mode are permanently deleted when the merchant is activated (status changed from
testtoactive).
Controlling simulated outcomes#
Use specific card number suffixes to trigger different gateway responses:
| Card number suffix | Simulated result |
|---|---|
Default (e.g., 4111111111111111) |
Approved |
*1000 (e.g., 4111111111111000) |
Insufficient Funds |
*2000 (e.g., 4111111111112000) |
Declined |
*3000 (e.g., 4111111111113000) |
CVV Mismatch |
*4000 (e.g., 4111111111114000) |
Expired Card |
*5000 (e.g., 4111111111115000) |
Processing Error |
Example test-mode response#
{
"success": true,
"test_mode": true,
"data": {
"transaction_id": "abc-123-...",
"status": "completed",
"message": "Payment processed successfully",
"gateway_reference": "TEST-1713200000000",
"response_code": "APPROVED"
}
}
Notes#
- Test-mode transactions use
gateway_referencevalues prefixed withTEST-. These are not valid PlugNPay or BeaconPay references. - The
test_modeflag is only present when the merchant is in test mode. Production responses do not include this field. - Verification charges in test mode use a simulated amount. Any amount submitted to the verification-complete endpoint will be accepted (within standard tolerances).
- To enter test mode, an admin must set the merchant status to
testin the admin portal. To go live, the admin clicks "Activate Merchant" which wipes all test data and transitions toactive.