OpenPay API Reference Developer documentation for the OpenPay payment processing API
v1 Last updated: 2026-11-04

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_reference as 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 id before 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 are secure, shareable URLs that allow customers to make payments.

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.

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#

  1. Customer attempts a payment with a new card
  2. API responds with 402 status and VERIFICATION_INITIATED error code
  3. A small random amount is charged to the card in the merchant's processing currency
  4. Customer checks their bank statement for the exact amount
  5. Customer submits the verification amount via the completion endpoint
  6. 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#

  1. Your server creates a save session: POST /v1/cards/save-sessions. You get back a hosted url.
  2. 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.
  3. 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.
  4. On success, the session is marked completed and a card is created. Depending on your verification policy, the card is either immediately active, or pending_verification (a small micro-charge was made that the customer must confirm — see Verify a Saved Card below).
  5. You find out the session finished one of two ways: poll GET /v1/cards/save-sessions/:id, or subscribe to the card.saved webhook event.
  6. Once a card is active, charge it any time with POST /v1/payments by passing card_token instead 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#

  1. Create a plan: POST /v1/subscription-plans. You get back a token and a ready-to-share subscribe_url.
  2. 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.
  3. 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.
  4. 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.
  5. You can also create a subscription directly via POST /v1/subscriptions against a card you already saved through the ordinary save-card flow — no hosted enrollment page needed — passing either a plan_id or inline amount/currency/interval.
  6. 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 active or past_due. Stops billing immediately; the subscription can be resumed later. Already-paused or canceled returns 409 invalid_state.
  • Resume — valid only from paused. Recomputes next_charge_at to the next anchor-aligned instant and resets failure_count to 0 — the dunning ladder starts fresh. Resuming an active or canceled subscription returns 409 invalid_state.
  • Cancel — valid from any non-canceled status. Terminal: once canceled, a subscription can never be resumed or billed again. Canceling an already-canceled subscription returns 409 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).

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.

  1. 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).
  2. 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.
  3. 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.
  4. If your verification policy does apply, the card lands pending_verification and the subscription starts past_due with no charge attempted yet; the response sets "pending_verification": true and 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.
  5. 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_subscribed instead of a second subscription and a second charge. A previously canceled subscription on the same plan/email doesn't block re-enrolling.
  6. The page is rate-limited against card testing: five declined attempts from the same card fingerprint block further attempts with 429 for 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#

  1. Register an HTTPS endpoint using POST /v1/webhooks.
  2. OpenPay delivers a signed POST request to that URL whenever a matching event fires.
  3. Your endpoint verifies the signature (see Signature Verification) and returns any 2xx status to acknowledge receipt.
  4. 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 the t= 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_secret is the full whsec_<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()), not express.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": true at 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 test to active).

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_reference values prefixed with TEST-. These are not valid PlugNPay or BeaconPay references.
  • The test_mode flag 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 test in the admin portal. To go live, the admin clicks "Activate Merchant" which wipes all test data and transitions to active.