Connect a payment provider and take a test payment

See which providers and methods a tenant has, ask what a buyer may pay with, connect a provider in test mode, take a payment against an order reference, capture and refund it, record it on the order, and know what a callback from the provider changes.

A checkout needs to know what a buyer may pay with, and the first time through you want to prove the money path without moving any. At the end of this guide you know which payment methods your tenant offers for a country and an amount, you have taken a test payment tied to an order reference, captured it where that applies, refunded it and recorded the result on the order. The built-in mock provider covers the whole path without an account at a payment provider. It is the only end-to-end path today: Stripe and Novalnet can be configured, but a real-provider payment cannot be completed from this guide yet.

This is for developers wiring a checkout or an ERP to Payments, the commerce app that holds payment methods, providers and the payment ledger. You need:

  • A tenant with the Payments app installed. A tenant is your workspace on the platform (Glossary); its slug is shown in Cockpit, the admin UI at app.revenexx.com, in the tenant switcher at the top left. Payments is a public app, installed from the App Marketplace in Cockpit. New to the platform? Prerequisites covers Cockpit and getting an account, and a test tenant is the safe place to try this.
  • An API key. For this walkthrough, a development key with the * scope covers every call, including the settings read in step 5, whose scope the API reference does not name. For a narrower key, payments.* covers the Payments calls, and step 7 adds orders.place and orders.payment-status.update (which scope covers which app lists payments.* and orders.*; Scopes explains naming single operations).
  • An order, only if you want step 7. Step 7 records the payment on an order. Placing the order before step 4 is the convenient order, because you can then use its number as the payment's order_ref; placing it later works too, because step 7 needs only the payment's id. Without an order, use the made-up reference TEST-0001 and skip step 7.
Statements below about behaviour that is unconfirmed, undocumented or unsupported can change. Re-check them before you build on one (as of 2026-10).

Every call goes through the API gateway. The commands use curl and jq and assume one terminal session from start to finish. curl -s prints the body only; where a step below depends on an HTTP status, add -i (status line and headers) or -w '\n%{http_code}\n' to see it:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

How the pieces fit

Two layers sit in front of a payment. A method is the line a checkout offers, such as invoice, card or PayPal. A provider is the account at a payment service provider (PSP) behind a method that moves real money. Invoice and prepayment are self-managed: no provider is involved, and the platform only records that they were paid. A payment is one ledger row with one amount and one status, so there is no partial capture and no partial refund (Payment lifecycle has the status diagram). This guide sends no card data to revenexx. If a card or token is ever handed to Payments, it goes to the provider integration and is not stored on the row.

1. See what the tenant already has

A fresh tenant has four methods and four provider rows, seeded when the Payments app was installed: the built-in mock provider (enabled), and Stripe, PayPal and Novalnet (disabled, in test mode, without credentials). A tenant whose install did not seed them has none, so check:

Methods and providers
curl -s https://api.revenexx.com/v1/payments/methods \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.items[] | [.id, .code, .kind, .provider, .enabled] | @tsv'

curl -s https://api.revenexx.com/v1/payments/providers \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.items[] | [.id, .provider, .enabled, .test_mode] | @tsv'
A fresh tenant, methods
<id>    invoice self_managed        true
<id>    prepayment  self_managed        true
<id>    card    psp mock    true
<id>    paypal  psp mock    true

If the lists are empty, seed them. The call creates the four methods and the four provider rows, and is safe to repeat: it never duplicates and never overwrites what an operator set.

Seed the defaults
curl -s -X POST https://api.revenexx.com/v1/payments/methods/defaults \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{}'

The provider catalog (GET /v1/payments/providers/catalog) is the closed set of provider codes a tenant can configure, the same for everyone. A provider with available: false can be stored but every payment through it fails with provider_unavailable, so a picker should grey it out (Payment methods). The card and paypal methods start out routed at the mock provider, which moves no money.

2. Ask what a buyer may pay with

The checkout asks the platform instead of guessing:

Eligible methods
curl -s -X POST https://api.revenexx.com/v1/payments/methods/eligible \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "country": "DE", "amount": 249.90, "currency": "EUR" }' \
  | jq '{methods: [.methods[] | {code, fee}], excluded}'

The answer comes from the method rules alone, which are country, amount and currency, and calls no provider. Eligible methods come back sorted, each with its fee already worked out for this amount. Everything else is in excluded with the reason in words; that is for diagnosing why a method is missing, not for showing a buyer. Because it reads no provider state, a method whose provider is switched off is still offered here and fails when the payment is created. The same rules answer 422 at creation, so this call spares the screen a refusal and is not the guard.

3. Connect a real provider in test mode (optional, configuration only)

Configuration only: a real-provider payment can't be completed from this guide yet. Skip this step to stay on the mock provider, which covers every later step. The gaps:

  • Only Stripe, PayPal and Novalnet can transact, and PayPal test payments cannot reach PayPal's sandbox, so Stripe or Novalnet is the one to configure.
  • There is no documented way to send a test card. The public create request has no field for a card or a payment token.
  • The callback address is not documented. Where a provider posts its callbacks, and how you register that address in the provider's dashboard, is not covered here, so a real provider's callbacks cannot be set up from this page.

To configure a real one anyway, write its credentials onto the provider row the tenant already has, and keep the row in test mode. Take the row's id from the providers list in step 1:

Connect a provider
curl -s -X PUT "https://api.revenexx.com/v1/payments/providers/<PROVIDER_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Stripe (test)", "test_mode": true, "enabled": true, "credentials": { "api_key": "sk_test_…" } }'

There is no connection check. No route tests credentials, and the PUT answers 200 with the saved row (id, provider, name, enabled, test_mode, options and timestamps, never the credentials) whether or not they are valid. The first sign of a wrong key is the 502 a payment gets when it is sent to the provider.

Payments may call out to the API hosts of Stripe, PayPal and Novalnet and no others; PayPal's sandbox is not among them. Any other catalog code can still be stored with POST /v1/payments/providers (the same body plus "provider": "<code>"), but a payment through it cannot reach its provider. There is one row per provider per tenant, so a second create for the same provider answers 409. The same screen is in Cockpit under Payments → Providers.

What to know before you write credentials:

  • provider must be a code from the catalog, and the catalog's credential_fields list the key names that go inside credentials. The values come from the provider's own dashboard.
  • Credentials are write-only. No route returns them, so rotating one means writing a new value. The provider row also accepts a webhook_secret, stored the same way, but the Payments app's own callback handling does not read it: callbacks are verified before they reach Payments.
  • A provider is not enabled unless you say so, and the seeded Stripe, PayPal and Novalnet rows are disabled too (the mock row is enabled).
  • No contract check exists before live mode. Switch test_mode off deliberately, and only when you mean to charge real cards.

Then point a method at the provider, using the method id from step 1:

Route a method at the provider
curl -s -X PUT "https://api.revenexx.com/v1/payments/methods/<CARD_METHOD_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "provider": "stripe", "provider_method": "card" }'

This moves card off the mock. A card payment in step 4 would now go to that provider, and with no way to send it a card (above) it cannot complete. To carry on with the rest of the guide, point card back at the mock, which is how the seeded method is routed:

Route the method back at the mock
curl -s -X PUT "https://api.revenexx.com/v1/payments/methods/<CARD_METHOD_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "provider": "mock", "provider_method": "card" }'

provider_method is the provider's own name for the method; the seeded methods use card and paypal. A method whose provider is disabled fails at creation with 422 and code: provider_not_configured, and the payment is stored as failed. The exception is a tenant whose operator set the fallback_provider setting (empty by default; an operator sets it in Cockpit's settings form for Payments, see Settings): then the payment goes through that provider instead, possibly the mock, without an error, and the payment's provider names the fallback while metadata.provider_fallback_from names the one you meant. A provider that is enabled but has missing or invalid credentials errors when the payment is sent to it instead: the payment is stored as failed and the call answers 502.

4. Take a test payment

Create the payment with an order_ref and an idempotency_key. The reference ties the payment to the order, so a later lookup, capture or refund can find it, and the key makes a repeat of the same call harmless. This block keeps the payment's id for the steps that follow:

Create a payment
# Each keeps a value you already exported (step 7, or a rerun) and defaults to the one after :-
export ORDER_REF="${ORDER_REF:-TEST-0001}"
export METHOD_CODE="${METHOD_CODE:-card}"
export ATTEMPT="${ATTEMPT:-1}"

PAYMENT=$(curl -s -X POST https://api.revenexx.com/v1/payments \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"method_code\": \"$METHOD_CODE\", \"amount\": 249.90, \"currency\": \"EUR\", \"order_ref\": \"$ORDER_REF\", \"idempotency_key\": \"$ORDER_REF-attempt-$ATTEMPT\" }")

echo "$PAYMENT" | jq '{id, status, error_code, next_action}'
export PAYMENT_ID=$(echo "$PAYMENT" | jq -r '.id // empty')
: "${PAYMENT_ID:?no payment id in the answer: read the error above}"

method_code and amount are required. Nothing else about the money is yours to choose: the provider and the fee come from the method. currency, country and return_url are optional: the tenant's default currency applies when you leave currency out, country only matters for a method restricted to countries, and return_url is where a provider that redirects sends the buyer back (below). order_ref is the reference you choose: a plain text tag, and nothing checks that an order with that reference exists. The Payments app keeps it for lookups: the order_ref filter on GET /v1/payments, the order-level capture route in step 5, matching a provider's or an ERP's callback to a payment when it names no provider payment id, and the order_ref carried in each payment.* event. Use a real order's number for a real order so those find it; TEST-0001 is fine for the mock path alone. A repeat with the same idempotency_key answers 200 with the payment that key already created, unchanged, whatever else the repeat's body says.

A create that is refused (422) or that a provider errors on (502) stores the payment as failed but answers with the error's code and message and no payment id, so the guard above stops the block. Find the failed row with GET /v1/payments?order_ref=$ORDER_REF&order=created_at.desc.

Read the status and branch on it:

statusMeansNext
capturedThe money was taken in the same requestSkip step 5; go to step 6
authorizedThe money is reservedCapture it (step 5)
requires_actionThe buyer must do something, and next_action says what, typically a redirectSend the buyer there, then confirm (below)
failedThe provider refused it or could not be usedRead error_code

With the default settings, a card payment on the mock provider answers captured, because the tenant's auto_capture_policy is immediate. Self-managed methods such as invoice and prepayment answer authorized and are never captured automatically. That is how you see a capture on the mock: run export METHOD_CODE=prepayment ATTEMPT=2 and then step 4's block again. The new attempt number matters, because the same idempotency_key would return the first payment, already captured, unchanged. (The seeded invoice method is limited to Germany, so it only works with "country": "DE" in the body.) Step 5 then captures it and step 6 refunds it, with no provider involved; a captured self-managed payment refunds like any other. A redirect or a decline comes only from a real provider in test mode. A provider that sends the buyer to its own page answers requires_action with a redirect in next_action, and return_url in the create body is where it sends the buyer back; without one the buyer is left at the provider.

For requires_action, the storefront carries out next_action. When the buyer is back at your return_url, call confirm once; it asks the provider how it went and writes the answer to the payment, so there is nothing to poll:

Confirm after the redirect
curl -s -X POST "https://api.revenexx.com/v1/payments/$PAYMENT_ID/confirm" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{}' \
  | jq '{id, status, error_code}'

Confirm applies to requires_action only and answers 400 from any other status, for example when a provider callback already moved the payment. With the default auto_capture_policy, a successful confirm can answer captured rather than authorized. A failed payment carries a classified error_code: provider_unavailable, provider_unreachable, provider_not_configured, provider_declined or provider_error. A provider that errors outright answers 502 with the payment stored as failed.

5. Capture, where it applies

Run the capture only if step 4's status is authorized. With the default auto_capture_policy of immediate, a card payment on the mock provider is already captured, and a capture then answers 400; go straight to step 6, or run export METHOD_CODE=prepayment ATTEMPT=2 and step 4 again to run the capture.

Capture collects money that is only reserved, and it takes no body and no amount. Whether you call it depends on the tenant's auto_capture_policy: immediate (the default) captures in the create call, manual waits for this call, and on_ship waits for the order-scoped capture, which no commerce app calls for you: the system that knows the goods shipped makes the call. The public API reads the policy and does not change it; an operator sets it in Cockpit's settings form for Payments (Settings explains the read route):

Settings and capture
curl -s https://api.revenexx.com/v1/settings/apps/payments \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '.settings.auto_capture_policy'

curl -s -X POST "https://api.revenexx.com/v1/payments/$PAYMENT_ID/capture" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{}'

Capturing a payment that is not authorized answers 400. A PSP payment authorized longer ago than the tenant's capture_expiry_days (7 by default) answers 422; self-managed payments never expire. To capture everything on an order at once, use POST /v1/payments/orders/<ORDER_REF>/capture. If you placed an order for step 7, record it as paid now, before the refund.

6. Refund it

If you placed an order for step 7, record it as paid first (step 7, sub-step 2); recording paid and then refunded back to back is fine. If you lost the payment's id, find it by order reference with GET /v1/payments?order_ref=$ORDER_REF&order=created_at.desc and take .items[0].id. One order can hold several payments, one per attempt, and the list has no promised order, so sort newest first and check that .items[0].status is the one you mean. Then refund:

Refund
curl -s -X POST "https://api.revenexx.com/v1/payments/$PAYMENT_ID/refund" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{ "reason": "Test refund" }' \
  | jq '{id, status}'

A refund is all or nothing and works only on a captured payment. An amount in the body is ignored, so there is no partial refund. A payment that was only authorized is released instead, with POST /v1/payments/$PAYMENT_ID/cancel. A repeat answers 400, and a capture older than the tenant's refund_window_days (180 by default) answers 422. Check that the answer says refunded before you treat the money as sent. If you placed an order for step 7, record it as refunded now.

7. Record the result on the order (optional, needs a real order)

Payments never updates the order, so you record the payment on it yourself. The call takes the order's id and the payment's id and never reads order_ref, so the order can be placed before step 4 or after step 6; nothing links the two but the reference you chose. status is one of open, pending, authorized, paid, partially_paid, refunded or failed, and anything else answers 400. In order:

  1. Place the order, once. A position needs a name or a sku; a position with no unit_price is worth 0, and nothing checks that the order's total matches the payment's amount (Placing an order has the full body). If you place it before step 4, run export ORDER_REF=$ORDER_NUMBER afterwards so step 4 uses its number.
    Place a test order
    ORDER=$(curl -s -X POST https://api.revenexx.com/v1/orders/place \
      -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "items": [ { "sku": "TEST-ITEM", "name": "Test position" } ] }')
    
    export ORDER_ID=$(echo "$ORDER" | jq -r '.id')
    export ORDER_NUMBER=$(echo "$ORDER" | jq -r '.number')
    
  2. Record paid, once the payment is captured (after step 4 or step 5) and before the refund. If you only place the order after the refund, record paid and then refunded straight away; the call accepts any status at any time:
    Record it as paid
    curl -s -X POST "https://api.revenexx.com/v1/orders/$ORDER_ID/payment-status" \
      -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{ \"status\": \"paid\", \"payment_id\": \"$PAYMENT_ID\" }"
    
  3. Record refunded after the refund in step 6, with the same call:
    Record it as refunded
    curl -s -X POST "https://api.revenexx.com/v1/orders/$ORDER_ID/payment-status" \
      -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{ \"status\": \"refunded\", \"payment_id\": \"$PAYMENT_ID\" }"
    

Ship, cancel and return an order walks the refund side with the checks that go around it.

What a callback from the provider changes

This is background, not a step: you do not call anything here. It applies to a real provider, not the mock. A callback changes the payment only. It never touches the order, whose payment_status you record yourself (step 7).

A provider does not post to the payments route directly. It posts to the platform, which verifies the delivery and passes it on to Payments. A callback reports a fact, so it only moves a payment forward:

The callback saysApplied from
authorizedcreated, requires_action
capturedcreated, requires_action, authorized
refundedcaptured
failedanything except captured or refunded
cancelledanything except captured, refunded or failed

A callback that does not advance the payment answers 200 with ignored: true, which makes the provider's retries harmless. A repeated failed or cancelled is the exception: it is written again onto the same status and publishes its event again (Payment lifecycle). A callback marked as not verified is refused with 422. Every applied callback publishes the matching event (payment.authorized, payment.captured, payment.failed, payment.cancelled or payment.refunded), so a consumer needs no polling. Read the exact topic strings from your tenant's event catalog (GET /v1/events/catalog; Consuming events covers the consumers).

Troubleshooting

SymptomCauseFix
The methods list is emptyThe install did not seed themCall methods/defaults (step 1)
A method is missing from the eligible answerA country or amount rule excludes it; the answer says whichRead excluded (step 2)
Create answers 422 with provider_not_configured, or 502The method's provider is disabled (422), or enabled with missing or invalid credentials (502)Enable it and write its credentials (step 3)
A payment through a disabled provider succeeded, or shows another providerThe tenant's fallback_provider is set, and the payment went through itRead metadata.provider_fallback_from; enable the provider you meant (step 3)
Payments through a provider other than Stripe, PayPal or Novalnet never reach it, nor do PayPal test paymentsPayments can call only those three providers' production hostsConfigure Stripe or Novalnet (step 3); no real payment completes from this guide yet
Create answers 422 for a method that was eligibleThe same rules run again at creationCheck country, amount and currency match step 2
Creating a provider answers 409The tenant already has a row for itUpdate the seeded row instead (step 3)
Every payment through a provider fails with provider_unavailableThe catalog marks it available: falseUse a provider the catalog marks available
Capture answers 400The payment is already captured (the default policy captures at creation)Read status first (step 4)
A second create returned the first paymentThe same idempotency_keyUse a new key per attempt
Refund answers 400The payment is not captured, or is already refundedCancel an authorized payment instead (step 6)

Where to go next

Run the path on the mock provider before a checkout depends on it. A real provider in test mode cannot be exercised end to end from this guide yet (step 3), so the mock proves the shape of your calls, not a provider's behaviour. Then read:

Was this page helpful?