Confirm invoice payments from your ERP
On invoice and prepayment, the money arrives in the merchant's bank, and the platform only knows about it once something tells it. Until then the payment stays authorized, and the daily dunning scan, which tracks unpaid invoices, keeps moving a buyer who has already paid towards reminder and overdue. This guide connects the system that reconciles the bank, usually the ERP, so that a settled invoice is recorded as captured within one call, the dunning clock stops and the order shows paid.
This is for integrators building the ERP side of a B2B shop. You need a tenant with the Payments and Orders core apps, a self-managed payment method such as invoice or prepayment (the Payments app seeds both on install, see Payment methods), and access to Cockpit, the tenant's admin console, to create an API key. You also need the API key your checkout already uses, for two calls that belong to the checkout rather than to the ERP. Every call goes through the API gateway:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
export CHECKOUT_API_KEY="rvxk_..."
REVENEXX_API_KEY is the ERP job's key, created in step 1. CHECKOUT_API_KEY is your checkout's existing key. It is the only second key in this guide, used in steps 2 and 6.
How a confirmation reaches the ledger
The Payments app takes outside payment facts on one route: POST /v1/payments/webhooks/{provider}. Callbacks from card and wallet payment service providers (PSPs) arrive there too, but those are delivered by the platform itself (see Card and PSP payments). An ERP uses the same route in its plain shape:
{
"id": "BANK-2026-10-0412",
"request": {
"body": { "event": "captured", "order_ref": "A-10482" }
}
}
{provider}is a name for your system, such aserp. It is not stored and does not have to match anything. Any name works exceptstripe, which is read as a Stripe event.eventis what happened to the money. Five values are understood, and anything else is acknowledged and ignored. (This is the field you send. The events the platform publishes afterwards are covered in step 6.)order_reffinds the payment. If you also sendpsp_payment_id, that is tried first.idis your own reference for the confirmation. It is echoed back asdelivery_id, so you can match an answer to the bank line that caused it.
event | Send it when | Moves a payment from |
|---|---|---|
captured | The invoice is paid in full | created, requires_action, authorized |
cancelled | The claim is written off and nobody will pay | anything except captured, refunded, failed |
refunded | You paid a settled invoice back, for example on a credit note | captured only |
failed | The payment will not happen, for example a returned direct debit | anything except captured, refunded |
authorized | Rarely: an invoice payment is authorized from the moment it is created | created, requires_action |
refunded here records that you paid a settled invoice back outside the platform, for example on a credit note. To have the platform record a refund as part of a return, use the refund call in Ship, cancel and return an order instead: it takes the money back through POST /v1/payments/{id}/refund.
Facts only move forward. An event that does not advance the payment changes nothing and says so, which is what makes a retry safe: send the same captured twice and the second one answers ignored. A repeated cancelled or failed is applied again rather than ignored, but lands on the same status and publishes no second lifecycle event.
1. Create a key for the ERP job
In Cockpit, create an API key for this job alone (see API keys and create an API key), and add three custom scopes instead of picking a preset (which scope covers which app):
| Scope | For |
|---|---|
payments.webhooks.ingest | Sending the confirmation |
orders.list | Finding the order by its number |
orders.payment-status.update | Marking the order paid |
Keep it to exactly these. A scope names the operations a key may call, and * or payments.* would also let this job create, capture and refund payments.
payments.webhooks.ingest can report any payment on the tenant as settled, including a card payment the PSP never collected. Keep this key on the ERP side, never in a storefront or a browser, and only send events for invoice and prepayment payments.2. Write the order reference onto the payment
The confirmation finds the payment by order_ref, so the reference has to be on the payment from the start, and it has to be one your ERP also holds. The order number is the natural choice, which means placing the order first and creating the payment after it, with the order's number as order_ref. The checkout writes it when it creates the payment:
curl -s -X POST https://api.revenexx.com/v1/payments \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $CHECKOUT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"method_code": "invoice",
"amount": 185.16,
"currency": "EUR",
"country": "DE",
"order_ref": "A-10482",
"idempotency_key": "checkout-A-10482"
}'
This is the checkout's key, not the ERP's. country is the buyer's country, and it matters: the seeded invoice method is limited to DE, and a method with a country list answers 422 to a request that names no country or another one. An invoice payment comes back authorized at once, with the dunning clock already running. If your storefront already creates payments, check only that it sets order_ref: order_ref is free text with no link to the order, so a payment created without it cannot be confirmed by order number later.
3. Send the confirmation
When the bank line for A-10482 is matched in full, the ERP job sends:
ANSWER=$(curl -s -X POST https://api.revenexx.com/v1/payments/webhooks/erp \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "BANK-2026-10-0412",
"request": { "body": { "event": "captured", "order_ref": "A-10482" } }
}')
echo "$ANSWER"
Send captured only for full payment. A payment carries one amount and one status, so there is nothing to record a part-payment on. Leave the payment authorized until the balance is settled, and let the dunning scan keep counting.
If the ERP has its own id for the payment, such as a bank transaction id, add it as psp_payment_id. The first confirmation that is applied stores it on the payment, if the payment has none yet, and every later event can then match on it instead of on the order number, which matters as soon as one order has more than one payment.
4. Read the answer
Most answers are 200, including the ones where nothing happened, so read the body, not just the status:
{ "payment_id": "7a1b9c3d-…", "status": "captured", "applied": "captured", "delivery_id": "BANK-2026-10-0412" }
{ "payment_id": "7a1b9c3d-…", "status": "captured", "ignored": true, "reason": "'captured' does not advance status 'captured'" }
| Answer | Means | What the job does |
|---|---|---|
200 with applied | The ledger moved | Mark the bank line done |
200 with ignored and a payment_id | The payment was already there, or the event does not apply to its status | Mark it done, and log reason |
200 with ignored and no payment_id | event was missing or not one of the five | Fix the job. Nothing was looked up |
404 | No payment has this order_ref or psp_payment_id | Check the reference. Retrying will not help |
403 | The key lacks payments.webhooks.ingest | Fix the key's scopes |
429 or 5xx | Rate-limited or temporarily unavailable | Retry with backoff. A repeat is safe |
The 200 ignored without a payment_id is the one to watch for: a misspelt event such as "paid" is acknowledged rather than refused, so a job that checks only the status code believes it succeeded.
A captured, cancelled, failed or refunded event also stops the dunning clock: dunning_stage goes back to none and the due date is cleared.
5. Mark the order paid
The payment and the order are separate records, and confirming one does not update the other. Look the order up by its number, then set its payment status:
ORDER_ID=$(curl -s "https://api.revenexx.com/v1/orders?number=A-10482" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq -r '.items[0].id')
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\": \"$(echo "$ANSWER" | jq -r .payment_id)\"}"
payment_id comes from the confirmation's answer in step 3. The order writes an order.payment_status.changed entry to its trail and publishes order.payment_status_changed for subscribers, and where the tenant's auto_complete_on is payment, an order that has already shipped in full is completed by this call. See Orders.
6. Check the result
curl -s "https://api.revenexx.com/v1/payments?order_ref=A-10482" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $CHECKOUT_API_KEY" | jq '.items[] | {status, captured_at, dunning_stage}'
This read needs a key with the payments.list scope. The ERP job's key does not have it, so this uses the checkout's key, provided that key holds it. The payment is captured with a captured_at, and dunning_stage is none.
Systems that react to settled payments don't need to poll for this. The confirmation publishes payment.captured (or payment.cancelled, payment.failed, payment.refunded) with payment_id, order_ref, provider, method, amount and currency, plus captured_amount and captured_at on a capture. A repeat that changed nothing publishes nothing. Subscribe from an App or a webhook endpoint, and read the exact payload in the event catalog.
Card and PSP payments
They are not the ERP job's business. When a buyer pays by card or wallet, the provider's callbacks reach this same route through the platform's own inbound delivery, which checks the provider's signature before it passes them on, and they move the payment without your code involved. What you configure is the provider account itself (see Payment methods).
So the ERP job should confirm only self_managed payments. The PSP already reported a card payment, and a second fact from the ERP can at best be ignored.
payments.webhooks.ingest is also listed as a designated override point, for a deployment that wants provider callbacks handled differently. Don't build on that today (as of 2026-09). An App that implements the capability receives the callbacks in place of the Payments app, but it has no way to write the payment ledger, so callbacks would stop moving payments for that tenant.
Troubleshooting
404for a payment you can see in Cockpit. The payment has noorder_ref, or a different one.GET /v1/payments?order_ref=…is an exact match, so compare the stored value character for character.- The wrong payment was confirmed. Matching by order number picks the newest payment on the order that is not captured, refunded or cancelled, and a
failedpayment counts as a match. Sendpsp_payment_idonce it is stored, or keep one payment per order that the ERP can confirm. 200, but the payment did not move. Readreason.unknown event 'paid'ormissing 'event'means the body is wrong.'captured' does not advance status 'failed'usually means a newer failed payment on the same order was matched instead of the invoice payment. Send the invoice payment'spsp_payment_id.'captured' does not advance status 'cancelled'means the only payment left was cancelled, which cannot be captured: create a new payment for the money that arrived.422 unverified webhook refused. The body carries"verified": false. Leaveverifiedout; it belongs to deliveries from the platform's own ingress.- The order is not
paid. Step 5 was skipped or failed. The payment and the order are updated by two calls, so the order keeps the payment status it was placed with, such asauthorized. 400from the order call. The body key isstatus, notpayment_status, and it must be one ofopen,pending,authorized,paid,partially_paid,refunded,failed.
Where to go next
- Payment lifecycle — the statuses, the transitions and the dunning scan.
- Markets and pricing settings —
payment_reminder_after_daysandoverdue_after_days, the two numbers the dunning clock counts against. - Orders — the order's own payment status, and how an order completes.
- Integration Studio — running the ERP job as a scheduled workflow.