# Merchant integration guide

Use Wegopay hosted checkout to collect a payment without sending card data through your server. Your server creates a payment, redirects the customer to its `checkoutUrl`, and fulfills the order after checking verified payment state. The success redirect is navigation only; it is never proof of payment.

## Integration model: server-to-server API + hosted checkout

The merchant API is server-to-server (S2S), with browser-based hosted checkout:

1. Your server creates a payment using its secret API key.
2. Your customer's browser opens the returned `checkoutUrl` and completes payment through Wegopay hosted checkout.
3. Wegopay processes and verifies the payment server-side.
4. Your server retrieves the payment status and receives signed webhooks once its webhook endpoint is configured.

Merchants never submit raw card details to Wegopay. Hosted checkout is the default; an explicitly configured custom checkout can submit a PCI Vault token through the server API described below. Keep API keys on your server. A browser redirect is not proof of payment; verify `status: paid` and match the payment to your order before fulfilling it.

## Onboarding and environments

Request account activation and a sandbox API key from Wegopay. Configure your own
receiver in **Dashboard → Settings → Webhook**, select **Sandbox**, enter its public
HTTPS URL on port 443, and copy the one-time signing secret into your server's
secret manager. Sandbox and live configurations and secrets are separate. The
same panel lets you rotate or disable a webhook. Configure it before creating
acceptance payments; a URL change does not reroute events already queued.
Confirm the outbound worker and provider verification/reconciliation are enabled
on the deployment. Copy your full API key from **Dashboard → API Keys**. Its list
is masked; full-key retrieval is authenticated, merchant-scoped, non-cacheable,
and audited. Webhook secrets are shown once; if lost, rotate through Settings.
Never store keys or secrets in browser code, URLs, source control, or logs.

The same API base URL supports both environments. A `wgp_test_…` key is restricted to sandbox; a `wgp_live_…` key is restricted to live. The server derives the environment from the stored key record. Omit `environment` when creating a payment; if supplied it must match the key. Reads and lists are scoped to that key's merchant and environment. Sandbox and live use separate webhook configurations and secrets. Existing keys remain live-only; request a new sandbox key for testing. Do not use a dashboard session token as a merchant API key.

Live onboarding is a separate approval. Before switching server secrets, agree the live API coordinates, supported payment methods, transaction limits, settlement arrangements, and operational contact with Wegopay. Deployment/provider/PCI approvals and a real sandbox checkout must be verified; the existence of this guide does not establish production approval. Current public creation supports USD, with an API range of 500–100000000 cents ($5–$1,000,000); provider/merchant limits may be lower. Eligible paid USD card/wallet payments support full or partial refunds through the dashboard and the server-side refund API. Do not infer availability of a payment method from its presence in a response enum.

## Sandbox test card

Create a USD 5.00 payment (`amountCents: 500`) with a `wgp_test_…` key, open the returned checkout URL, and choose **Pay by card**. Choose the card for the scenario you want to test. Use these synthetic details only with a sandbox key and when checkout displays the sandbox notice:

| Sandbox scenario | Card number | Expected flow |
| --- | --- | --- |
| 3DS authentication | `4000 0025 0000 3155` | Complete the customer challenge in the browser, then verify payment status. |
| Successful payment without 3DS | `4242 4242 4242 4242` | No customer challenge; verify the final payment status. |

For either card, use card holder `TEST TEST`, expiry `12/2030` (or another future date), and CVV `123`. Create a separate payment and capture for each scenario. For merchant backend token payments, submit raw test card details **directly to PCI Vault**, then send only the returned token and reference to Wegopay. When the payment returns `requiresAction`, open the supplied action URL in the customer browser and complete 3DS without collecting the card again. A merchant backend alone cannot complete the challenge.

An additional previously verified success fixture remains available:

| Field | Test value |
| --- | --- |
| Card number | `4111 1111 1111 1111` |
| Card holder | `TEST TEST` |
| Expiry | `12/2030` |
| CVV | `123` |

The additional successful-payment fixture was verified with the sandbox payment services on September 9, 2026. All cards above are synthetic sandbox fixtures; no real money is charged. Never use them with live credentials or enter real card details in sandbox. Retrieve the payment after checkout and confirm `status: paid`, `environment: sandbox`, and the expected amount, currency, and reference. Challenge completion or a success redirect alone is not proof of payment. Verify signed webhook delivery separately. Request approved fixtures from Wegopay for declines or failed/canceled challenges.

## Create a payment from your server

Set `WEGOPAY_API_BASE_URL` to the exact API origin supplied during onboarding (without a trailing slash), and `WEGOPAY_API_KEY` to your sandbox key. Do not guess production hostnames. Generate and persist a unique idempotency key **before** sending each new order's request; reuse it with the identical body after timeouts. The following values are examples, not credentials:

```sh
curl --fail-with-body "$WEGOPAY_API_BASE_URL/v1/payments" \
  -H "Authorization: Bearer $WEGOPAY_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-20260908-0001' \
  --data '{"amountCents":1500,"currency":"USD","reference":"order-0001","successUrl":"https://merchant.example/orders/order-0001","cancelUrl":"https://merchant.example/cart","metadata":{"orderId":"order-0001"}}'
```

A `201` response has a `data` object containing `id`, `status: "pending"`, `checkoutUrl`, `expiresAt`, and environment. Persist `data.id` against your order, then redirect the customer to the returned `data.checkoutUrl` exactly as supplied. Treat that URL as a secret. Hosted checkout handles card tokenization, provider actions, and status polling. Sessions last approximately 30 minutes and allow five card attempts. Keep the order pending if payment verification is still in progress.

Required fields are `amountCents` (integer), `currency` (`USD`), `reference` (1–255 nonblank characters), `successUrl`, and `cancelUrl`. Redirects must be absolute HTTPS URLs without credentials or fragments in production. Optional `customer` accepts `email` and/or `name`; optional `metadata` is a JSON object with at most 100 keys and 16384 canonical UTF-8 JSON bytes. Avoid sensitive data in metadata. Unknown request fields are rejected. `reference` is your order label, not a uniqueness constraint: protect order uniqueness in your database and reuse the persisted idempotency key for retries.

If the customer already selected a method on your website, optionally include
`"paymentMethod":"card"` or `"paymentMethod":"wallet"`. Checkout opens that method
directly and does not allow switching to another method. The selection must be
enabled by the effective API-key settings and the merchant's payment-type policy;
otherwise creation returns HTTP 422 `validation_failed` with
`error.field: "paymentMethod"`. Unknown or empty method values are rejected too.
Omitting the field preserves the existing checkout choices configured for the API
key. Custom card capture accepts only `card`. The selection participates in
idempotency: retry with the same body, including the same method. It is a checkout
restriction, not evidence that the payment used or completed that method.

Checkout keeps checking current permissions before each new attempt. If the
selected method is later disabled, no alternative method is offered; the existing
attempt can still complete or recover under its original authorization.

## Merchant-owned card checkout

Payment-scoped capture is available to enabled merchants; request activation for
your merchant and environment before using these routes. Sandbox and live access
are configured separately. This opt-in, backend-only integration lets your backend submit card data directly
to PCI Vault. Raw cards never go through a Wegopay endpoint. Keep your card
collection infrastructure within the compliance scope agreed during onboarding.
Wegopay retains vault account credentials, key passphrases and payment credentials.
You receive only bounded write-only capture access for one existing payment.
There are no permanent shared capture credentials, saved cards or recurring payments.

1. Create a payment with your merchant API key and persisted `Idempotency-Key`,
   adding `"checkoutMode":"custom"`. Amount, currency, redirects and order details
   follow the normal create contract. Custom mode returns 422 until enabled for
   your merchant and environment. Hosted mode remains the default.
2. Call `POST /v1/payments/{id}/capture` from your backend, without a request body.
   The response `data.capture` contains `url`, `secret`, `reference` and `expiresAt`.
   Capture access expires at most ten minutes after payment creation, or at checkout
   expiry if earlier. Retrying preparation returns the same access. Keep the entire
   response private and out of logs, traces and browser responses.
3. POST card JSON from your backend directly to `capture.url` with
   `Content-Type: application/json` and `X-PCIVault-Capture-Secret: capture.secret`.
   Send string fields `card_number`, `card_cvv`, `card_expiry_month` (MM), and
   `card_expiry_year` (YY). Do not use a four-digit year or substitute order numbers
   for the capture reference. Do not send raw card data to Wegopay. The endpoint
   uses a dedicated vault key selected by Wegopay for this payment. Check that
   the returned reference matches the server-issued reference; changing a reference
   does not change the assigned key. [PCI Vault capture API](https://docs.pcivault.io/api/capture).
4. Retain the returned token and reference encrypted against this payment in
   your backend before submitting them. Keep them only for recovery of this
   payment under the agreed retention policy, and never log them. Submit to
   Wegopay from your backend:

```http
POST /v1/payments/{id}/pay
Authorization: Bearer <your merchant API key>
Content-Type: application/json

{"cardToken":"opaque_token_from_your_assigned_capture_endpoint","captureReference":"server_issued_capture_reference"}
```

Wegopay checks merchant ownership and environment, revokes capture access, and
verifies the opaque token/reference within this payment's dedicated vault key
using its authenticated vault account, without retrieving card data. Wegopay keeps
the encryption key and passphrase private; neither is returned to you. A caller's
reference is never proof of ownership. Payment
amount and routing remain server-authoritative. Tokens from another payment,
merchant or environment are rejected. Only one card binding is accepted for this
payment; access cannot be refreshed to replace the card. A definitive retryable
failure permits retrying the same bound token within access expiry and the normal
attempt limit. For a different card, establish a definitive non-paid outcome before
creating a new payment for the order. Never replace a payment with an unknown outcome.

On `data.status: "requiresAction"`, redirect the customer browser to the
`data.actionUrl` returned by capture preparation or `GET /v1/payments/{id}/checkout`.
The Wegopay browser page resumes the existing customer challenge and confirmation
without collecting a card again. A backend cannot complete that challenge. Browser
completion or a successful redirect is not payment proof. If your approved browser
integration handles action material itself, submit `{}` to
`POST /v1/payments/{id}/confirm` only after the customer action; further actions
must be completed too. Prefer the supplied action page.

Poll `GET /v1/payments/{id}/status` through your backend to recover outstanding
actions and processing state. Use backoff (for example 2s, 5s, then 10s), respect
429, and stop polling on a terminal checkout or customer action. Retrieve
`GET /v1/payments/{id}` for authoritative payment status. A 409, 503 or network
failure can leave a charge processing; preserve this payment and poll before
retrying. Same-token recovery retains the original provider operation key even if
capture access expires. Do not submit a new token after an uncertain result.
A lost capture-preparation response stays fenced; retry may return 503 until
operator review. No charge is initiated by preparation. Do not repeatedly create
payments to work around provisioning failure.

All capture, pay, merchant confirm and merchant status routes require a server
API key. An unknown, foreign, wrong-environment, ordinary hosted or unconfigured
payment returns 404. Capture secrets and card tokens are sensitive; never send
API keys or capture credentials to the customer browser. Only the action URL goes
to that browser. Use HTTPS and disable request/response body logging on your backend.

The [sanitized Node.js backend example](token-payment.mjs)
covers creation, scoped capture, token submission, browser actions, confirmation,
polling and verified fulfillment. Its separate `capture` and `pay` methods let
you store the opaque result before submission. `captureAndPay` requires a
`retainToken(paymentId, captured)` callback that durably stores the identifiers
encrypted; a storage failure prevents payment submission. After an uncertain
result, poll first and use `pay` with those original identifiers if recovery is
needed. It makes no requests when imported. Receive
signed webhooks using the [exact-byte verifier](webhook.mjs) below,
with the environment-specific secret, timestamp checks and durable event dedupe.
Fulfill exactly once only after the expected ID, environment, amount, currency
and order reference match a verified `paid` API response or signed event.

On 2026-10-06, external staging checks passed payment-key isolation against key,
passphrase and reference overrides, metadata-only lookup, revocation and expiry.
The earlier shared-key/reference design failed isolation and is not used for new
captures. The authenticated merchant API, direct capture, payment-key proxy charge,
authenticated status reconciliation and signed paid webhook passed with the
documented success fixture on 2026-10-06. A separate required-action sandbox payment
also passed the real browser challenge, reload/resume, server confirmation,
authenticated paid verification and signed webhook checks. The refactored candidate repeated payment-key capture isolation and a real 3DS
challenge on 2026-10-07: browser re-entry resumed the original action, authenticated
verification reached paid, and the signed webhook and single-attempt replay checks passed. Additional action cases and external callback
networking remain onboarding checks. Production
onboarding and compliance evidence are separate.

## Customer details, wallets, and language

For `customer.name`, join your nonblank first and last names with a space. The
field accepts 1–255 Unicode code points with at least one non-whitespace
character; there is no Latin-only or mandatory two-part-name rule. Send the
payer's actual name and valid email when available. These fields and `metadata`
are stored with the payment, but are not passed in the current card-charge
request. No extra public creation parameters are documented to improve approval
rates. Do not put card data or identity documents in metadata.

The payer's `customer.name`, `customer.email`, `customer.documentNumber` (11–14 digits), and
`customer.phone` (10–15 digits) are optional for hosted wallet checkout. Supplied values
must be valid and belong to the actual payer. Saved values are forwarded server-side when present;
selecting Wallet opens the provider checkout directly even when all four are absent. This applies
to existing and new sandbox/live payments. Document number and phone are not returned in checkout
or merchant payment responses. The custom hosted checkout URL allowlist is unchanged.
Provider use of these fields for fraud/risk scoring and their effect on approval rates require
provider confirmation; Wegopay does not promise either.

Set optional `locale` to `en`, `es`, or `pt` on payment creation for English,
Spanish, or Portuguese Wegopay checkout. Omission selects English. The selection
is persisted with the session across reloads and required actions. Unsupported
values return 422 with `error.field: "locale"`. Provider-hosted wallet pages,
card fields, and issuer authentication surfaces retain their own language policy;
Wegopay does not promise to translate third-party content.

## Read status and reconcile

```sh
curl --fail-with-body "$WEGOPAY_API_BASE_URL/v1/payments/$PAYMENT_ID" \
  -H "Authorization: Bearer $WEGOPAY_API_KEY"
curl --fail-with-body "$WEGOPAY_API_BASE_URL/v1/payments?status=paid&page=1&perPage=20" \
  -H "Authorization: Bearer $WEGOPAY_API_KEY"
```

Detail returns `data`; list returns `data` plus `meta` (`page`, `perPage`, `total`). List supports `status`, inclusive UTC `from`/`to` dates (`YYYY-MM-DD`), `page` (minimum 1), and `perPage` (1–100). Results are newest first. An unknown payment, another merchant's payment, or the other environment's payment returns 404.

Before fulfillment, fetch the payment on your server with the matching environment key. Require `status === "paid"`, expected payment ID, order reference, exact amount, currency, and environment, then atomically mark the order fulfilled once. `pending` and `requiresAction` are not successful payments. Other statuses are `failed`, `refunded`, `partiallyRefunded`, `disputed`, `disputeWon`, and `disputeLost`; handle them in your post-payment workflow. Periodically reconcile pending orders via this endpoint so your business does not depend solely on webhook delivery. A canceled or expired checkout does not by itself prove payment failure.

### Checkout expiry and polling

Checkout session/status reads include an optional `terminalReason` when closed:
`checkout_expired` is a local timeout, `attempts_exhausted` closes exhausted
attempts, and `provider_failed` means an authenticated provider failure closed
this checkout. The customer page distinguishes provider failure from link expiry.
Neither an elapsed timer nor an unavailable page establishes that no charge occurred.
Status reads for unresolved known-ID checkouts request a coalesced worker status
verification, with a 30-second cooldown. They never submit or confirm a payment.


Use the returned `expiresAt` for the checkout deadline; the default session
lifetime is 30 minutes. Checkout expiry prevents further checkout use but does
not automatically change the payment to `failed`. There is no public `expired`
payment status, expiry webhook, or guaranteed maximum time for `pending` or
`requiresAction`. Provider actions have provider-specific deadlines. Unresolved
payments require continued reconciliation and operator investigation, including
after the customer leaves or the checkout expires.

As an integration starting point, poll `GET /v1/payments/{id}` every 10 seconds
while the customer is actively waiting, then back off to 30–60 seconds with
jitter. This is a recommendation, not a resolution SLA. Limit aggregate polling
across all payments and honor `Retry-After` on 429. Stop interactive polling once
resolved or the customer leaves; continue background reconciliation for unresolved
orders. GET reads stored state and does not force a new provider status check.

## Approval rate

The dashboard approval rate measures successful live payments against selected
bank/provider rejections. It is calculated as:

**Approval rate = approved payments / (approved payments + eligible bank/provider
rejections) × 100.**

Each payment counts once, regardless of how many attempts it has. A successful
retry counts as approved. Refunds and disputes retain the original approval in
this metric. The selected reporting window uses payment creation time and UTC
calendar boundaries; sandbox payments are excluded. When no payments qualify,
the dashboard displays an em dash rather than an approval percentage.

| Outcome or reason | Included in approval rate? |
| --- | --- |
| Successful payment, including one later refunded or disputed | Yes, as approved. |
| `card_declined`, `issuer_declined`, `declined`, `do_not_honor`, `generic_decline` | Yes, as a bank/provider rejection. |
| `try_again_later`, `fraudulent`, `transaction_not_allowed`, `lost_card`, `stolen_card`, `pickup_card`, `card_velocity_exceeded` | Yes, as a bank/provider rejection. A risk code does not prove customer fraud. |
| Incorrect card number or security code, insufficient funds, expired card | No. Card details and account conditions are outside this metric. |
| Authentication or 3DS failure, technical or processing error | No. These are tracked outside this metric. |
| Unused or expired checkout without an eligible rejection, ongoing processing, unresolved outcome | No. Expiry or missing confirmation alone is not a bank/provider rejection. |
| Any other or missing failure code, including `provider_declined` | No. Only the explicit rejection codes above are counted. |

A rejection counts when the payment is definitively failed with an eligible
code. A pending payment also counts as a rejection when its **latest** attempt
failed with an eligible code and its checkout is expired or past its deadline,
provided the checkout is not processing. This reporting classification does not
change the payment's pending status. An earlier eligible rejection is not counted
if the latest attempt has an excluded code or remains unresolved.

For example, 100 approved payments, 10 eligible rejections and 20 incorrect-card
or account-condition failures produce **100 / (100 + 10) = 90.9%**. The 20 excluded
failures do not enter either side of the calculation.

This is a bank/provider approval metric, not the percentage of all created
payments that complete or a measure of platform reliability. Excluded outcomes
remain visible in transaction history with their actual statuses and reasons.

## Payment statuses and failure mapping

Use this reference to match Wegopay results to your own statuses. Status values
are case-sensitive. Keep payment state, checkout availability, failure reason,
and request errors as separate fields in your integration.

### Payment status: the order's payment outcome

Read `data.status` from `GET /v1/payments/{id}`. Signed payment webhooks include
the same status in `data.status`; retrieve current state because webhook snapshots
can arrive out of order. The customer status column below is a suggested mapping,
not an additional Wegopay enum.

| Wegopay payment status | Suggested customer status | Meaning / action |
| --- | --- | --- |
| `pending` | Pending | No verified final outcome. Continue reconciliation. |
| `requiresAction` | Awaiting customer action | Complete the returned checkout action; keep the order unfulfilled. |
| `paid` | Paid | Match ID, reference, amount, currency and environment, then fulfill once. |
| `failed` | Failed | A definitive failed payment is recorded. Do not fulfill. Use `failureCode` for the reason. |
| `refunded` | Refunded | The full payment amount was refunded. |
| `partiallyRefunded` | Partially refunded | Part of the amount was refunded; use `refundedCents`. |
| `disputed` | Disputed | Apply your dispute workflow. |
| `disputeWon` | Dispute won | Apply your dispute-resolution workflow. |
| `disputeLost` | Dispute lost | Apply your dispute-resolution workflow. |

There is no `expired`, `canceled`, or `declined` payment status. Closing a browser,
following the cancel redirect, a 3DS error, a timeout, or an HTTP error does not
establish a failed payment. Map unresolved outcomes to Pending / Under review
and reconcile them.

### Checkout state and terminal reasons

For merchant-owned checkout, read `data.status` from
`GET /v1/payments/{id}/status` (also returned by checkout configuration reads).
These are checkout states, distinct from the payment status above.

| Checkout status | Meaning / action |
| --- | --- |
| `open` | Checkout is available for a payment attempt. |
| `processing` | An attempt or its verification is in progress. Poll the same payment. |
| `requiresAction` | Complete the outstanding action before confirmation or another attempt. |
| `paid` | Checkout reflects verified payment success; verify the payment against your order. |
| `failed_retryable` | A definitive attempt failed; another attempt may be allowed if attempts and time remain. The payment can still be `pending`. |
| `expired` | Checkout is closed. Inspect `terminalReason` and reconcile the payment separately. |

| `terminalReason` | Meaning | Customer mapping |
| --- | --- | --- |
| `checkout_expired` | The local deadline elapsed or the provider confirmed session expiry. | Checkout expired; payment remains whatever the payment GET reports. |
| `attempts_exhausted` | No further attempts remain. | Checkout closed; use payment GET for the payment outcome. |
| `provider_failed` | An authenticated provider failure closed checkout. | Use payment GET and its failure details for the current payment outcome. |

Pay/confirm can return HTTP 200 with `data.status: "failed"`, `failureCode`,
`message`, and `attemptsLeft`. This is an **attempt result**, not automatically
the payment's final status. Read the checkout status and payment GET before
changing the order to Failed or offering another attempt. A later attempt can
succeed. Do not retry a charge after an unknown result merely because attempts
remain. Pay/confirm can also return `paid` or `requiresAction`; wallet initiation
uses `walletReady` to provide a redirect, which is not payment success.

### Failure codes: the reason, not the status

Payment GET and signed payment webhook snapshots expose nullable `failureCode`
and `failureMessage`. A failed pay/confirm attempt exposes `failureCode` and
`message`. Match codes, not message text; the messages may differ between these
surfaces. A retryable attempt's reason need not appear in the payment's failure
fields while the payment remains pending.

The current checkout adapter recognizes all of the following business-decline
codes and returns fixed, safe explanations. The checkout UI selects reviewed
English, Spanish or Portuguese guidance by code, never arbitrary provider text:

| Failure code(s) | Suggested reason category | Meaning |
| --- | --- | --- |
| `card_declined`, `issuer_declined`, `declined`, `do_not_honor`, `generic_decline` | Card / issuer decline | Card declined; another payment method may be needed. |
| `insufficient_funds` | Insufficient funds | The card has insufficient funds. |
| `expired_card` | Expired card | The card has expired. |
| `incorrect_cvc`, `invalid_cvc` | Invalid security code | The card's security code is invalid. |
| `incorrect_number` | Invalid card number | The card number is incorrect. |
| `transaction_not_allowed` | Transaction restricted | The card does not support this transaction type. |
| `fraudulent` | Risk rejection | Provider risk checks declined the payment. |
| `lost_card`, `stolen_card`, `pickup_card` | Issuer restriction | Card declined; the customer should contact the issuer. |
| `invalid_account` | Invalid account | Use another card or contact the issuer. |
| `payment_intent_authentication_failure` | Authentication failure | Card verification failed; try verification again or another method. |
| `try_again_later` | Temporary issuer rejection | Wait or use another payment method; no automatic charge retry. |
| `card_velocity_exceeded` | Card payment limit | Wait or contact the issuer. |
| `test_mode_live_card` | Test-card configuration | This test checkout requires a test card. |
| `expired` | Payment session expired | Request a fresh link; excluded from approval declines. This differs from `expired_card`. |
| `processing_error` | Provider processing failure | The provider could not process the payment. |

Additional fallback / technical codes:

| Code | Meaning / handling |
| --- | --- |
| `provider_declined` | Generic sanitized provider failure when its code is absent or unsuitable. |
| `card_token_rejected` | The card token was rejected. Checkout can expose this as HTTP 422 `error.code`; correct capture/tokenization before another permitted attempt. |
| `processor_unavailable` | Generic checkout failure fallback. Check current payment and checkout state before retrying. |

**`failureCode` is an open string, not a closed enum.** Authenticated provider
status verification can retain other sanitized provider codes, including
`error`, `failed`, `cancelled`, or `canceled` when the provider returns no separate
code. Unknown business-decline codes in decoded HTTP 2xx checkout results fall back
to `card_declined`; unknown HTTP 422 codes remain unresolved. Status verification
may preserve a different sanitized code.
The tables list Wegopay's recognized codes and fallbacks, not every possible
issuer/provider code. Always support an unknown-code category, retain the code
for support, and derive the payment outcome from the payment status. A reason
such as `processing_error` alone does not authorize retrying a charge.

For example: an `insufficient_funds` attempt with checkout
`failed_retryable` and payment `pending` maps to **Pending payment**, with last
attempt reason **Insufficient funds**. An `expired` checkout with
`terminalReason: checkout_expired` and payment `pending` maps to **Checkout
expired / payment unresolved**. Only a payment GET with `status: failed` maps
the payment itself to **Failed**.

### Request errors

The complete payment/checkout `error.code` list, HTTP statuses and recovery
actions are in [Idempotency and errors](#idempotency-and-errors) below. Request
errors describe the API operation; none is a replacement for payment status.
Sandbox simulation statuses and `sandbox.simulation.*` events belong to separate
test resources and must not drive real order fulfillment.

## Idempotency and errors

The required `Idempotency-Key` is 16–255 visible ASCII characters. Creation records are retained for 24 hours and scoped to merchant plus operation. Use distinct idempotency keys across sandbox and live: reuse across environments conflicts. The same key and canonical request replay the original 201 and response; changed input returns 409 `idempotency_key_reused`. A concurrent request can return 409 `idempotency_in_progress`: retry with the same key and body after a delay. Maintain your own durable order uniqueness beyond the retention period; never create another payment merely because a response was lost. If a response remains unknown near the 24-hour window, reconcile your stored order/payment records and contact Wegopay before attempting another creation.

Errors use `{"error":{"code":"…","message":"…"}}`; match the machine-readable code instead of parsing message text. Validation errors may also identify `error.field`.

| HTTP | Code | Merchant action |
| --- | --- | --- |
| 400 | `invalid_request` / `invalid_query` | Correct the request JSON, headers, or filters. |
| 401 | `unauthorized` | Check the full server-side key and merchant activation with Wegopay. Missing, malformed, revoked, or disabled-merchant keys deliberately share this response. |
| 404 | `not_found` | Check the stored payment ID, merchant, and key environment; do not probe other merchants. |
| 409 | `idempotency_in_progress` | Retry the identical creation later with the same key. |
| 409 | `idempotency_key_reused` | Restore the original body/key pairing; do not retry altered input automatically. |
| 409 | `payment_in_progress` | An attempt is processing. Poll the same payment; do not submit a new charge. |
| 409 | `action_required` | Complete and confirm the outstanding customer action. |
| 409 | `confirmation_in_progress` | Confirmation is processing. Poll the same payment. |
| 409 | `confirmation_not_required` | No action is awaiting confirmation. Read current checkout/payment state. |
| 409 | `result_pending` | The previous result is being reconciled. Preserve the same payment and poll before another submission. |
| 410 | `checkout_expired` | Checkout is closed. Reconcile payment state before deciding whether a new checkout is appropriate. |
| 410 | `attempts_exhausted` | No attempts remain. Reconcile payment state before deciding whether a new checkout is appropriate. |
| 422 | `validation_failed` | Correct the identified field or enclosing object; an explicit environment must match the key. |
| 422 | `card_token_rejected` | Correct card capture/tokenization; read current checkout state before another permitted attempt. |
| 429 | `rate_limited` | Honor Retry-After and reduce concurrency. |
| 503 | `service_unavailable` | Retry within a bound and escalate persistent failure. Credential-store or provider availability failures are not proof that the key is invalid. |

For `POST /v1/payments`, well-formed JSON that violates the request schema
returns 422 `validation_failed`. For example, `amountCents: 100` identifies
`error.field: "amountCents"`. An unknown top-level property identifies `"body"`;
an unknown customer property identifies `"customer"`, without echoing untrusted
property names. Malformed JSON and invalid transport headers remain 400
`invalid_request`. Deployments predating this correction can return generic 400
for schema failures; merchants should handle both during rollout.

On 429 honor `Retry-After`; on 503 also honor that header when present, otherwise use bounded exponential backoff with jitter. For transport failures keep the original key/body and use the same bounded retry policy. Stop and investigate persistent failures. Never log authorization headers, checkout tokens, signing secrets, or payment payloads.

## Webhook defaults and API key overrides

Set the default receiver in **Dashboard → Settings → Payment webhooks**, separately
for Sandbox and Live. In **Dashboard → API Keys**, open a key's webhook settings
to choose:

- **Inherit:** use the merchant default for the key's environment. This is the
  default for existing and newly issued keys.
- **Custom:** deliver this key's payments to a separate receiver with its own
  one-time signing secret. Save or rotate the custom receiver through the key's
  settings; manage its secret independently of the API credential.
- **Disabled:** suppress new callback enqueueing for this key's payments.

Disabling the merchant default stops inherited delivery; custom key receivers remain
active until separately disabled.

The key's stored environment determines the webhook environment; a sandbox key
cannot select a live receiver. Custom configuration is a unit of URL and signing
secret; it does not inherit the default's secret. A failed custom delivery never
falls back to the merchant receiver. Changes use an optimistic version, so refresh
and retry after a conflict instead of overwriting a concurrent update.

Routing uses the key record that originally created the payment. Revoking that
key prevents new API authentication but retains routing for outstanding payments.
Revoked keys remain visible in merchant and admin key lists, with their revocation
time, so their webhook settings remain manageable. Their API credentials cannot
be revealed or copied.
Dashboard-created and older payments without a creating key use merchant defaults.
Changes affect events enqueued afterward, including later events on an existing
payment. Events already queued retain their destination and encrypted secret
snapshot through retry and replay. Keep old receivers and draining secrets until
those deliveries finish. Switching a key back to Inherit uses the current merchant
default for future events and does not reroute older queued events.

## Receive signed webhooks

Delivery is at least once and may arrive out of order. Headers are `Wegopay-Signature: t=<unix-seconds>,v1=<lowercase-hex>` and `Wegopay-Event-Id`. Calculate HMAC-SHA256 using the full issued secret string as UTF-8 over the exact bytes `<timestamp>.` followed by the raw HTTP request body. Do not decode the secret or parse/re-encode the body before verification. Compare digests in constant time. A five-minute timestamp tolerance is recommended receiver policy; keep your clock synchronized. Retries receive fresh signatures. Check that the event ID header matches the signed body's `eventId`.

The version-1 envelope contains `version`, `eventId`, `type`, `createdAt`, `environment`, and `data`. Historical legacy events may omit environment; route them only under the explicitly agreed legacy policy. `data` is a payment snapshot with all of these fields: `id`, `reference`,
`status`, `method`, `amountCents`, `refundedCents`, `netCents`, `currency`,
`cardBrand`, `cardLast4`, `cardBin`, `cardIssuer`, `failureCode`, `failureMessage`, `createdAt`, `paidAt`,
and `refundedAt`. Newly enqueued events also include `failureIsFinal`, `refunds`,
`dispute`, `verifiedAt`, `statusVersion`, and `observationVersion`.
Method, card details, failure details, paid time, and refund
time are nullable and remain present as null when unavailable. `netCents` is
amount less refunds, not settlement after fees. The envelope carries environment.
The signed `data.id` and `data.reference` can identify your order without a
lookup, but the event is a historical snapshot. Event types are `payment.paid`, `payment.failed`, `payment.refunded`, `payment.partially_refunded`, `payment.disputed`, `payment.dispute_won`, and `payment.dispute_lost`. Treat the event as a trigger to retrieve current state before making a business decision; do not let an older event overwrite newer state.

The dependency-free [Node.js verifier and HTTP handler](webhook.mjs) implements raw-byte verification, bounded bodies, timestamp checks, and constant-time comparison. Mount it before a JSON body parser. Set `expectedEnvironment` explicitly to `sandbox` or `live`; the handler rejects missing or mismatching signed event environments before storage. Process historical environment-less events only through a separately agreed legacy handler. Supply `acceptEvent` that inserts the signed event ID under a durable unique constraint and queues processing in the **same database transaction**. A duplicate already accepted event succeeds; a storage failure returns 503. The handler deliberately requires this database callback and does not substitute an in-memory set. Limit incoming request timeouts at your HTTPS server/proxy. Pass the handler an array of issued `secrets` (current and any draining old secret), the explicit `expectedEnvironment`, and your asynchronous `acceptEvent` function. Mount it on the exact endpoint registered with Wegopay; normal application routes should use their own handlers. Use separate handlers/secrets and processing queues for sandbox and live; a sandbox event must never fulfill a live order.

Return any 2xx promptly only after durable acceptance; worker timeout defaults to 10 seconds. There are six delivery attempts: immediate, then delays of 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours after the prior failure. Non-2xx responses, redirects, and transport failures retry; webhook `Retry-After` is ignored. Exhausted events require operator investigation/replay. Coordinate signing-secret rotation: queued events retain the old secret snapshot, so keep old secrets available until Wegopay confirms those deliveries/replays have drained. Disabling a configuration does not cancel already queued events.

Download the [companion verification tests](webhook.test.mjs) beside `webhook.mjs`. The examples use Node.js 22.18 or later and require no npm dependencies. From the directory containing both downloaded files, run:

```sh
node --test webhook.test.mjs
```

### Card details in payment webhooks

Signed payment webhooks already include these fields under `data`; the same
fields are available from `GET /v1/payments/{id}`:

| Field | Meaning | Example |
| --- | --- | --- |
| `cardBrand` | Card brand reported by the verified provider status. | `"visa"` |
| `cardLast4` | Last four card digits as a four-character string, preserving leading zeros. | `"0042"` |

Both keys remain present as `null` when card details are unavailable. Do not
assume every paid payment has card details, including wallet payments. These
values come from authenticated provider status, not from browser input or an
unverified provider callback.

BIN/IIN (the first six or eight digits identifying the issuer) is different from
the last four digits. See [Visa's BIN glossary](https://developer.visa.com/pages/glossary).
Signed webhooks include nullable `data.cardBin` and `data.cardIssuer` when
PCI Vault capture metadata is configured and bound to the exact payment attempt.
`cardBin` contains six or eight prefix digits; `cardIssuer` contains nullable
`bank`, `countryCode`, `countryName`, `type`, `level`, `category`, and `regulated`.
These fields come from a server-authenticated PCI Vault callback, separately from
verified brand/last four. Missing metadata stays `null`; older events
may omit the fields. A later verified event can enrich metadata with a higher
`observationVersion`. Original signed events never change. Payment reads retain
`cardBrand`, `cardLast4`, `cardBin`, and `cardIssuer`. Merchant and admin transaction lists/details expose the same captured issuer projection; transaction detail views display its available fields. Full card numbers, expiry dates, and CVV/CVC are not included.

### Additional verified details

| Field under `data` | Meaning |
| --- | --- |
| `failureIsFinal` | The reported finality flag, or `null` when unavailable. It does not replace `status` or guarantee settlement or immunity from later refunds/disputes. |
| `refunds` | Individual `{ "amountCents": 1000 }` entries when Wegopay reports a complete list matching `refundedCents`. `null` means unavailable; `[]` means a reported empty list. |
| `dispute` | Card-network `status`, sanitized `reason`, `amountCents`, `currency`, and nullable `createdAt`; `null` when unavailable. Its network stage is separate from the normalized payment status. Unknown stages/reasons become `other`. |
| `verifiedAt` | UTC time Wegopay authenticated and verified this observation, distinct from payment and event timestamps. |
| `statusVersion` | Per-payment counter that increases when normalized status changes. |
| `observationVersion` | Per-payment counter that increases for each applied verified observation, including public detail changes. Use it to recognize older snapshots; gaps are normal. |

These are additive version-1 fields and can be absent from historical queued
events, which keep their original bytes. Receivers must tolerate additional
fields after verifying the raw-body signature. A changed dispute stage can
produce another `payment.disputed` event with a higher `observationVersion` even
while the payment's normalized `status` and `statusVersion` remain unchanged.
Unchanged verification does not emit another event. Missing optional details
on a same-state refresh preserve known values; a changed cumulative refund total
invalidates an older refund list unless the provider supplies a new complete list.

Only reviewed fields cross this boundary. Processor names and identifiers,
processing costs, platform balance/FX information, affiliate commissions,
credentials, action secrets, raw failure text, and raw provider payloads remain
excluded. The [payment OpenAPI contract](payments.openapi.yaml)
defines `PaymentWebhookEvent`, `PaymentWebhookData`, `PaymentWebhookRefund`, and
`PaymentWebhookDispute` for typed receivers. These additional fields are webhook
fields; payment GET continues to expose its existing payment schema.

## Device-free integration simulations

Configure your sandbox webhook, then open **Dashboard → Test checkout → Device-free
sandbox scenarios**. Choose successful, declined, required-action, or expired;
choose card or wallet. Complete a simulated required action as paid or declined,
and refresh its webhook delivery status. No wallet device, card entry, or provider
connection is needed for these integration scenarios.

Server integrations use a sandbox key with `POST /v1/sandbox/simulations`,
`GET /v1/sandbox/simulations/{id}`, and
`POST /v1/sandbox/simulations/{id}/complete`. Create and complete require an
`Idempotency-Key`; persist it and retry with the identical body after uncertainty.
A create body is
`{"scenario":"requiresAction","method":"wallet","amountCents":500,"currency":"USD","reference":"SIM-order-1"}`;
complete accepts `{"outcome":"paid"}` or `{"outcome":"declined"}`.
Dashboard JWT operations use `/merchant/sandbox/simulations` and are restricted
to the signed-in merchant. Live API keys cannot access this capability.

Simulations are separate resources with `simulation: true` and `environment:
"sandbox"`. Their signed callbacks use `sandbox.simulation.paid`,
`sandbox.simulation.failed`, `sandbox.simulation.requiresAction`, or
`sandbox.simulation.expired`. They never update actual payment transactions or
financial totals. Use the simulation GET endpoint to inspect them; payment GET
is for actual payments. The Node webhook handler never passes these events to
`acceptEvent`. Supply a separate `acceptSimulation` callback on the sandbox
handler to durably accept test events; without it they are acknowledged and
discarded. Never fulfill orders from a simulated event.

See the [simulation API contract](sandbox-simulations.openapi.yaml)
and [simulation guide](sandbox-simulations.md) for isolation, expiry, retries,
and the boundary between platform simulations and real-provider acceptance.

## Sandbox limitations and scenario fixtures

The successful card fixture above and one required-action browser scenario have
recorded provider sandbox evidence. Additional decline, failed/canceled challenge
and frictionless fixtures must be obtained and approved for the actual
provider sandbox account. Internal emulator scenarios are application tests and
are not provider-approved merchant fixtures. To exercise checkout expiry, create
a sandbox checkout and leave it unused until its returned `expiresAt`; that tests
session expiry, not a provider decline or a final payment status.

There is no merchant API to force a transaction's status. Do not treat internal
test controls as available on the deployed sandbox. A device/browser that reports
Apple Pay or Google Pay unavailable cannot establish wallet end-to-end success.
A provider-approved device-free wallet scenario or provider-side test-state change
has not been established; request provider confirmation before promising one.
Sandbox does not establish production approval rates, issuer/risk decisions,
real-device wallet compatibility, production 3DS behavior, settlement, or live
account readiness. Verify those separately for the intended production setup.

## Sandbox acceptance and live handoff

Record payment and event IDs, deployment revision, timestamps, and observed results without secrets or raw payment data. Use the cards above to test both successful payment without 3DS and payment requiring a browser challenge. Request additional approved scenario fixtures during onboarding; do not invent test cards.

| Scenario | Required evidence |
| --- | --- |
| Successful hosted checkout including required provider action | Verified paid detail, signed event durably accepted, one fulfillment |
| Decline, abandonment, expiry | No premature fulfillment; a pending order is reconciled |
| Same create request retried / concurrent retry | One payment ID; conflicts handled without another charge |
| Conflicting environment and other-environment reads | Request rejected; sandbox key cannot read live payment |
| Tampered or stale webhook / wrong environment secret | Receiver rejects; no business side effects |
| Duplicate/out-of-order delivery | Durable deduplication and current-state reconciliation; one fulfillment |
| Receiver returns 500 then recovers | Delivery retries and durable acceptance is observed |
| Provider timeout or missing callback | Order remains pending until authenticated reconciliation resolves it |
| Secret rotation and key revocation | Draining webhook accepted; revoked API key rejected |

Wegopay and the merchant should sign off this matrix against the deployed sandbox before exchanging a live key. Confirm live webhook routing, worker operation, provider verification, monitoring, escalation ownership, and all launch approvals separately. A passing deterministic local test does not replace a real deployed provider run.

Canonical API reference: [payment OpenAPI](payments.openapi.yaml). It includes merchant API-key operations and separate dashboard/checkout operations with different authentication; a standard hosted-checkout merchant integration needs only `/v1/payments`, `/v1/payments/{id}`, and its own webhook receiver.

The interactive `/docs/swagger` page uses the frontend's configured `NEXT_PUBLIC_API_BASE_URL` as its server. Choose **Authorize** with a sandbox merchant API key, then **Try it out** and **Execute** on a merchant endpoint. Execution sends actual API requests; the key selects sandbox or live mode. Authorization is not persisted across reloads. Complete the returned checkout and retrieve the payment to verify the full flow; creating a pending payment alone does not prove successful payment processing.

## Checkout defaults and API key overrides

Wegopay administrators manage defaults under **Admin → Merchants → Checkout**,
and overrides under **Admin → Merchants → API keys → Checkout settings**.
Merchant accounts cannot read or modify these settings through the dashboard API.
Ask Wegopay to configure the required methods and websites. Each override field can independently inherit:

- **Payment methods:** cards, hosted wallet, both, or none. API key overrides can
  differ from merchant defaults but cannot enable a method disabled by Wegopay
  administrators for the merchant.
- **Embedding origins:** exact HTTPS origins with no fixed count limit, without paths, credentials,
  ports, wildcards, or IP addresses. Empty blocks embedding. Add only trusted sites.
  The server request-body size limit still applies (1 MiB by default).

For large allowlists, pass `embeddingOrigin` in `POST /v1/payments`, for example
`"embeddingOrigin": "https://shop.example.com"`. Wegopay checks the exact website
against the API key's effective allowlist before creating the payment. The
checkout can then be embedded only on that website. The field is included in
idempotency matching; use a new idempotency key when changing it. No wildcard or
allow-any-site option is provided.

Existing integrations may omit `embeddingOrigin`: lists of up to 20 websites
retain their existing embedding behavior. With a larger list, omission creates a
hosted-only checkout, so update the integration before expanding an existing
list beyond 20. Removing a website from the effective allowlist blocks subsequent
checkout page loads on that website, including previously created checkouts.
The browser receives at most one selected website (or the bounded legacy list),
never the full large allowlist. API-key overrides and merchant defaults retain
their existing inheritance behavior.


The API Keys panel shows the saved effective methods and websites and identifies
inherited fields. Clearing an override restores inheritance; explicitly saving an
empty list disables that capability. Updates use the displayed merchant version
and reject conflicting edits. Keys remain issued/revoked through Wegopay.

Admin settings endpoints are `GET`/`PUT /admin/merchants/{id}/checkout-settings`
and `GET`/`PUT /admin/merchants/{id}/api-keys/{apiKeyId}/checkout-settings`.
They require an active administrator dashboard JWT. PUT accepts `expectedVersion`,
`paymentTypes` (`card`/`wallet`), and `embeddingOrigins`. API key fields may be
`null` to inherit; merchant default fields must be arrays. Every key lookup is
scoped to the selected merchant. Settings share sandbox/live merchant
defaults; individual keys retain their existing immutable payment environment.

For the two-flow integration, configure a **card key** with only `card` and the
approved embedding origins, and a **wallet key** with only `wallet`. Create the
payment server-to-server using the corresponding key; the returned `checkoutUrl`
is linked to that key. Card-only checkout shows the card form and no wallet
option. Wallet-only checkout shows only the hosted wallet option. The current
provider combines Apple Pay and Google Pay; this setting does not guarantee
Apple Pay-only controls on the provider's page.

New payment attempts use the current effective methods, enforced on the server.
Existing attempts can finish after settings change. Existing links read current
origin settings when opened; already-rendered frames are not remotely closed.
Older and dashboard-created links use merchant defaults. Idempotency remains
merchant/environment scoped: replaying an existing creation key returns the
original payment and its original checkout configuration source, so use distinct
idempotency keys for distinct orders/flows.

## Embedded card checkout

Use the card key's returned URL as the iframe source:

```html
<iframe
  src="CHECKOUT_URL_RETURNED_BY_WEGOPAY"
  title="Secure Wegopay checkout"
  style="width:100%;height:850px;border:0"
  allow="payment"
  referrerpolicy="no-referrer"
></iframe>
```

Hosted and embedded checkout links accept added query parameters, including
tracking tags from sharing services. Wegopay uses only the checkout token in the
`/c/{token}` path and discards all query parameters when redirecting to `/checkout`.
Parameters cannot select a different payment, change its amount or return URLs,
or prove payment success. Share the original returned link; the clean `/checkout`
address depends on the current browser session and is not a shareable payment link.

This is embedded hosted checkout, not a direct card S2S API. Merchants cannot read
card fields or checkout sessions. Keep API keys server-side and fulfill only after
verified payment status or signed webhooks. The embedded receipt stays inside the
frame without an automatic success redirect; there is no parent-page payment
message API. The merchant can update its page after its backend verifies payment.
Wallet-only links must open as normal top-level navigation. If a checkout offering
both methods is embedded, choosing wallet opens the provider through an explicit
link in a new tab.

Embedding uses Secure, HttpOnly, SameSite=None, Partitioned checkout cookies.
Checkout API mutations still require Wegopay's exact Origin. Keep a normal checkout
link outside the iframe as a fallback for browser storage restrictions. Do not use
a credentialless iframe; additional sandbox restrictions can break hosted fields,
wallet navigation, and bank authentication.

PCI Vault must permit all ancestors, including the merchant origin, in its hosted
form policy. Before using a merchant's embedded flow, verify nested card capture,
3DS, reload/resume, and paid verification with approved sandbox fixtures in the
target browsers. Repository tests do not establish provider permissions or
real-device authentication. No merchant origin is automatically enabled.

## Self-service API keys

In **Dashboard → API Keys**, enter an integration name and choose sandbox or live,
then select **Create API key**. Use **Copy** to retrieve the full key through the
audited, uncached disclosure endpoint. Store it only on your server. To replace a
key, create and install a new one before revoking the old key. Revocation is
permanent and blocks new API requests; existing payments and their webhook routing
remain intact.

Use **Configure webhook defaults** to open the environment-specific receiver
settings, or **Key settings** on a key to override the default. Configuring a
receiver generates a separate signing secret, displayed once. Copy it to your
receiver's secret manager; rotate it if lost. An API key is not a webhook signing
secret.

Open **Key settings** to change an existing key's integration name in a modal.
Names can contain 1–100 characters after trimming; active and revoked keys can be
renamed. Renaming preserves the key's credentials, environment and configuration.

Dashboard sessions can call `POST /merchant/api-keys`,
`PATCH /merchant/api-keys/{id}` to rename with `label`, and
`POST /merchant/api-keys/{id}/revoke`. All use the merchant profile's current
`version` as `expectedVersion`; creation also requires an `Idempotency-Key` and
accepts `label` and `environment`. Merchant identity comes from the session.
Reload after a version conflict. If a creation response is lost, inspect the key
list before retrying, because issuance responses cannot be replayed.

## Refunds

Eligible paid USD card/wallet payments can be refunded fully or partially to the
original payment method. Refunds cannot be undone. A payment's `remainingCents`
is the verified refundable balance, not a guarantee that every request will succeed.

### Dashboard

Open a paid transaction and select **Refund** beside its amount. The refund panel
opens below the summary. Enter an amount (the default is the entire remaining
balance), then confirm. Administrators have the same control in the main
transaction modal and merchant workspace. The panel shows **Partially
refunded** or **Refunded**, the verified refunded total, and remaining refundable
balance. A further refund becomes available only after the previous request succeeds
and the balance reduction is verified.

### Server-side refund API

Use your merchant API key only on your server. Live keys can access live payments;
sandbox keys can access sandbox payments. Unknown, foreign-merchant and
wrong-environment payments return `404`. Dashboard session tokens cannot call
these routes. Keep the API key out of your browser, logs and customer responses.

1. `GET /v1/payments/{id}/refund` retrieves eligibility, verified totals and the
   latest refund request. The `request` field is `null` before the first refund.
2. `POST /v1/payments/{id}/refund` submits a refund. Supply `expectedAmountCents`
   equal to the `remainingCents` you just reviewed. Supply `amountCents` for a
   partial refund, or omit it to refund the whole remaining amount.
3. Poll the GET route to check the latest request. Retrieve `GET /v1/payments/{id}`
   or process verified payment webhooks for the final financial outcome.

```sh
curl "$WEGOPAY_API_BASE_URL/v1/payments/$PAYMENT_ID/refund" \
  -H "Authorization: Bearer $WEGOPAY_API_KEY"

# Refund $2.00 from a verified remaining balance of $5.00.
curl -X POST "$WEGOPAY_API_BASE_URL/v1/payments/$PAYMENT_ID/refund" \
  -H "Authorization: Bearer $WEGOPAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"expectedAmountCents":500,"amountCents":200}'
```

Example after successful verification:

```json
{"data":{"eligible":true,"remainingCents":300,"refundedCents":200,"request":{"id":"40000000-0000-4000-8000-000000000001","amountCents":200,"status":"succeeded","createdAt":"2026-10-05T12:00:00Z"}}}
```

Amounts are positive integer cents. The refund amount must not exceed the expected
remaining balance. The server records the requesting API key or dashboard user
before submitting a refund. Concurrent submissions share the same balance guard
across the API and dashboard. Repeating the latest request with the same balance
and amount returns that request; changing its amount or using a stale balance
returns `409`. Older requests may return `409` after a subsequent refund.

After a timeout or lost response, first GET the refund status. If retransmission
is needed, preserve the exact original body: **never refresh the balance and
automatically submit a new refund**. A reduced balance is a new refund opportunity,
not a retry identifier. Do not retry requests marked `unknown`, `failed`, `canceled`
or `requires_action`; contact Wegopay support. `submitting` and `pending` block
further refunds. HTTP `200` acknowledges the request; it does not by itself prove
that the funds were returned. Even `succeeded` may precede verification of payment
totals. `payment.partially_refunded` and `payment.refunded` webhooks reflect verified
payment changes.

Invalid amounts return `400`, invalid keys return `401`, stale/ineligible requests
return `409`, rate limits return `429`, and unavailable dependencies return `503`.
All responses are non-cacheable. Keep your own order-to-refund records using the
returned request ID. The GET route returns the latest request, not a full history.

The dashboard retains its session-authenticated `GET` and
`POST /merchant/transactions/{id}/refund`; merchant API keys cannot call those
routes. `X-Merchant-ID` is restricted to administrators on dashboard routes.
