Payment lifecycle

Moving money on revenexx — create, confirm, capture, cancel and refund, the order-scoped capture a warehouse calls, the dunning scan, and where provider callbacks land.

A payment is one ledger row with one amount and one status. That single fact explains most of the API: there is no partial capture, no partial refund and no per-line payment, because there is nothing to express them on.

Base path: https://api.revenexx.com/v1/payments. GET /v1/payments also filters on organization_id (uuid, exact match): the buyer's organization, copied from the order when the payment is created, so one customer's payments show across every order.

The status lattice

text
                        ┌──────────────┐
   created ──────────►  │requires_action│ ──confirm──┐
      │                 └──────────────┘             │
      │                                              ▼
      ├──────────────────────────────────►  authorized ──capture──► captured ──refund──► refunded
      │                                              │
      └──► failed                                    └──cancel──► cancelled
                                                          ▲
                        created / requires_action / authorized
TransitionStarts fromNote
confirmrequires_action onlyA payment that already came back authorized needs no confirm; the lattice answers 400.
captureauthorized only
cancelcreated, requires_action or authorizedThe only transition with three starting states — everything short of captured can still be released.
refundcaptured onlyMoney that was only authorized is cancelled, not refunded.

A captured payment is not cancellable at all, and a refunded one cannot be refunded again — the lattice answers 400 rather than guessing what was meant.

Create

Request
curl -X POST https://api.revenexx.com/v1/payments \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{
        "method_code": "invoice",
        "amount": 249.90,
        "currency": "EUR",
        "order_ref": "ORD-000123",
        "contact_id": "…",
        "idempotency_key": "checkout-9f3a…"
      }'

The checkout's write: it opens the ledger row and takes it as far as the named method allows, in one call.

method_code and amount are required. Nothing else about the money is yours to choose — kind, provider and fee_amount are read off the method that method_code names, so a caller can neither pick an acquirer nor discount its own fee. amount: 0 is legal (free orders); negative is 400.

Eligibility is enforced here and not only in the checkout UI: the same country and order-value rules answer 422 if the method does not apply to this buyer.

What comes back depends on the method:

MethodResult
self_managed (invoice, prepayment)authorized at once, with the dunning clock already started.
pspcaptured, or authorized, or requires_action with next_action — the instruction the storefront must carry out, typically a redirect. next_action is set at that status and at no other.

Hosted checkout. A psp payment with no card token, at a provider that takes the card on its own page (Mollie today), answers requires_action: next_action is a redirect to the provider's checkout page, and psp_payment_id is the provider's payment id. Send a return_url with it, because the provider sends the buyer back there. Without one the create is refused with 422 return_url_required, before anything is written.

Send an idempotency_key. A repeat of the same call answers 200 with the payment that key already named, unchanged and not re-authorized.

What is never stored: the instrument, token or card you send is handed to the driver in-process. No token and no card number is written to the row.

Confirm — the other half of a redirect

Request
curl -X POST "https://api.revenexx.com/v1/payments/{id}/confirm" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

The create answered requires_action, the storefront carried out the next_action — a 3-D Secure step, a wallet approval, a bank login — and this asks the PSP how it went and writes the answer to the ledger. next_action is cleared whatever the outcome, except when the provider still reports the payment open: then it stays requires_action with its redirect. Otherwise the provider's answer decides: paid becomes captured, failed or expired becomes failed with its classified error_code (usually provider_declined; provider_error when the provider's wording is not a decline), cancelled becomes cancelled. A Mollie callback only names the payment, so Payments reads its status back from Mollie before it moves the ledger. Payments does not yet hand Mollie a callback address, so until it does, the confirm on the buyer's return is what settles a hosted payment; one the buyer abandons stays requires_action.

Where the tenant's auto_capture_policy is immediate, the money is taken straight after authorization in the same request, so a successful confirm can come back captured rather than authorized. A failed auto-capture does not fail the confirm — a good authorization is worth more than a tidy status.

Capture

Request
curl -X POST "https://api.revenexx.com/v1/payments/{id}/capture" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

Collects money that is currently only reserved. There is no request body, and an amount other than the payment's own is refused with 400 (partial_capture_not_supported) — a capture is the whole authorization or nothing.

Which route you use depends on auto_capture_policy:

PolicyCapture happens
immediateIn the same request that created the payment. This route is never reached.
manualHere, when an operator says so.
on_shipThrough the order-scoped route below.

On a self_managed payment it takes no PSP anywhere near it: it records that an invoice or prepayment was paid, and stops the dunning clock.

Refused with 422 once the authorization is older than the tenant's capture_expiry_days — the message carries both numbers. An expired authorization is declined by the provider anyway, and a 422 here is the cheap version of finding out later.

Capture by order reference

Request
curl -X POST "https://api.revenexx.com/v1/payments/orders/ORD-000123/capture" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

The hook auto_capture_policy: "on_ship" was written for: fulfilment knows the order it shipped, not the payment ids behind it. The shipment calls this with the reference it already holds and the money for that order is collected in one request.

It captures every authorized payment on that reference and reports the rest instead of failing — an order whose payment was already captured is a successful no-op, and a provider that refuses one payment lands in skipped.

payments.order_ref is nullable and has no foreign key. This route is exactly as good as the reference the checkout wrote onto the payment.

Cancel

Request
curl -X POST "https://api.revenexx.com/v1/payments/{id}/cancel" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"reason":"Buyer abandoned the checkout"}'

Drops the claim before any money has been taken — the abandoned basket, the buyer who never came back from the redirect, the invoice an operator writes off.

Unlike capture and refund it has no time window. capture_expiry_days and refund_window_days do not apply, so a stale authorization can always be released even once it is too late to collect.

On a PSP payment the provider is called and reason is passed to it, so it reaches the PSP's own cancellation-reason field as well as being stored under metadata.cancel_reason. Cancelling stops the dunning clock: the stage goes back to none and the due date is cleared.

Refund

Request
curl -X POST "https://api.revenexx.com/v1/payments/{id}/refund" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"reason":"Return completed, goods received"}'
All or nothing. There is no partial refund and no second one to express — a refunded payment is refunded in full, an amount other than the payment's own is refused with 400 (partial_refund_not_supported), and a repeat is a 400 because refunded is not a status a refund starts from.

reason is handed to the driver in the same call, so it reaches the PSP's own refund-reason field rather than being a note only this database sees, and is stored under metadata.refund_reason.

On a self_managed payment nothing is sent anywhere: it records that the merchant paid the buyer back by their own means.

Refused with 422 once the capture is older than the tenant's refund_window_days — past that the provider stops accepting a refund against the transaction and it has to be made by bank transfer.

A refund the provider refuses answers 200 with the payment's status set to failed. A failed payment cannot be refunded through the API, so the money already captured is paid back outside Payments, for example by bank transfer. A provider that errors outright answers 502 and the payment stays captured, so a retry asks the provider again.

Errors are classified

A failed payment carries an error_code from a closed set, so a caller can branch:

CodeMeans
provider_unavailableNo driver for this provider in this deployment. See Payment methods.
provider_unreachableThe PSP could not be reached.
provider_not_configuredNo credentials, the provider is disabled, or the provider answers that the API key is invalid, unauthorised or failed to authenticate.
provider_declinedThe PSP said no.
payment_method_not_supportedThe provider refuses the payment method it was sent, as configured. Nothing was charged. The create usually answers 422, not 502; a refusal the provider reports in its answer comes back as 201 with a failed payment.
provider_errorAnything else the PSP reported.

Clean up error text stored before these codes existed

Payments written before these error codes existed still keep the provider's or runtime's raw text in the stored error_message. API responses never repeat that text, but the stored value is still raw and the payments screens in Cockpit show it as stored, so it needs rewriting once per tenant.

Request
curl -X POST https://api.revenexx.com/v1/payments/errors/redact \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

The route is a dry run by default: it reports what it would touch and changes nothing.

Request
curl -X POST https://api.revenexx.com/v1/payments/errors/redact \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"apply": true}'

The answer says what happened: applied (false on a dry run, where the numbers are what an apply would do), scanned (payments read, oldest first), redacted (payments rewritten, or that would be) and payments, each with its id and the error_code it was given. Applying writes that code and the fixed sentence that belongs to it in place of the stored text.

It is idempotent: rows whose stored message is already one of the fixed sentences, or empty, are skipped, so a second run reports 0. limit (default 500, maximum 5000) counts every payment, failed or not, oldest first. Each call reads the same oldest payments again rather than paging on, so a failed payment that is not among the oldest 5000 is not reached. A 0 therefore proves the clean-up complete only when scanned is lower than limit.

The dunning scan

Request
curl -X POST https://api.revenexx.com/v1/payments/dunning/scan \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

Classifies every unpaid self-managed payment as on time, reminder due or overdue, from payment_reminder_after_days and overdue_after_days. It writes dunning_stage (none, reminder, overdue) and dunning_due_at, and reports PSP payments still waiting on a callback longer than webhook_stale_after_minutes.

A pure function of each payment's age, so it is idempotent. It also runs daily on a schedule.

It classifies and does not send. A stage change emits payment.updated; what a reminder looks like is your workflow.

POST /v1/payments/orders/relink scans payments that have no organization_id yet. For each, it resolves the order_ref to the order it names and writes order_id and that order's organization_id. A payment that already has an order_id but no organization gets the organization filled in. It is the same resolution a new payment gets, applied to payments written before that existed.

Request
curl -X POST https://api.revenexx.com/v1/payments/orders/relink \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"apply":true,"limit":100}'
  • Dry run by default. Without apply: true the call reports what it would link and changes nothing.
  • Oldest first. limit sets how many payments a run scans (default 100, at most 500). To continue, send the next value of the previous run as after.
  • Only a plausible match is linked. The order must have the same currency, an amount within its total, and be no younger than the payment. A number that a later order reuses is reported, not linked.
  • Safe to repeat. A payment that now has an organization is not scanned again.
  • Unresolved rows are reported. The answer lists payments (what was linked, or would be) and unresolved, each with an id, its order_ref and a reason. next is the value to send as after, or null on the last page.
  • If the orders app cannot be asked, the call answers 502 orders_unavailable and stops where it was.

Provider callbacks

text
POST /v1/payments/webhooks/{provider}

A PSP never posts here directly. It posts to the platform's webhook ingress, which verifies and captures the delivery and dispatches its envelope to this route.

Everything a callback does to the ledger happens here and nowhere else, which makes this the one override point for PSP callback handling: a deployment that needs a provider's callbacks normalised differently replaces this operation instead of touching the lifecycle routes.

A callback reports a fact rather than asking for a transition, so it follows its own, looser lattice: a payment can go from created straight to captured, and failed or cancelled can follow only an open payment (created, requires_action or authorized). Facts only move forward — an event that does not advance the payment answers 200 with ignored: true and a reason, which makes provider retries and redeliveries no-ops. An envelope marked verified: false is refused with 422. The payment is resolved by psp_payment_id or, failing that, by order_ref.

Every applied callback publishes the lifecycle event it stands for — payment.authorized, payment.captured, payment.failed, payment.cancelled or payment.refunded — and anything but authorized stops the dunning clock.

The same route takes a plain {event, order_ref, psp_payment_id?} shape from systems the platform has no driver for, which is how an ERP records that an invoice was paid. See Confirm invoice payments from your ERP.

Where to go next

Was this page helpful?