Confirm invoice payments from your ERP

Tell the platform an invoice or prepayment was paid, from the ERP or accounting system that sees the money arrive — a narrow API key, the confirmation call, the answers it gives, and marking the order paid.

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:

Environment
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:

Confirmation body
{
  "id": "BANK-2026-10-0412",
  "request": {
    "body": { "event": "captured", "order_ref": "A-10482" }
  }
}
  • {provider} is a name for your system, such as erp. It is not stored and does not have to match anything. Any name works except stripe, which is read as a Stripe event.
  • event is 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_ref finds the payment. If you also send psp_payment_id, that is tried first.
  • id is your own reference for the confirmation. It is echoed back as delivery_id, so you can match an answer to the bank line that caused it.
eventSend it whenMoves a payment from
capturedThe invoice is paid in fullcreated, requires_action, authorized
cancelledThe claim is written off and nobody will payanything except captured, refunded, failed
refundedYou paid a settled invoice back, for example on a credit notecaptured only
failedThe payment will not happen, for example a returned direct debitanything except captured, refunded
authorizedRarely: an invoice payment is authorized from the moment it is createdcreated, 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):

ScopeFor
payments.webhooks.ingestSending the confirmation
orders.listFinding the order by its number
orders.payment-status.updateMarking 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.

The confirmation route trusts the key. A key with 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:

Create 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:

Send the confirmation
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:

Applied
{ "payment_id": "7a1b9c3d-…", "status": "captured", "applied": "captured", "delivery_id": "BANK-2026-10-0412" }
Ignored
{ "payment_id": "7a1b9c3d-…", "status": "captured", "ignored": true, "reason": "'captured' does not advance status 'captured'" }
AnswerMeansWhat the job does
200 with appliedThe ledger movedMark the bank line done
200 with ignored and a payment_idThe payment was already there, or the event does not apply to its statusMark it done, and log reason
200 with ignored and no payment_idevent was missing or not one of the fiveFix the job. Nothing was looked up
404No payment has this order_ref or psp_payment_idCheck the reference. Retrying will not help
403The key lacks payments.webhooks.ingestFix the key's scopes
429 or 5xxRate-limited or temporarily unavailableRetry 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:

Mark the order paid
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

Read the payment
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

  • 404 for a payment you can see in Cockpit. The payment has no order_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 failed payment counts as a match. Send psp_payment_id once it is stored, or keep one payment per order that the ERP can confirm.
  • 200, but the payment did not move. Read reason. unknown event 'paid' or missing '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's psp_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. Leave verified out; 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 as authorized.
  • 400 from the order call. The body key is status, not payment_status, and it must be one of open, pending, authorized, paid, partially_paid, refunded, failed.

Where to go next

Was this page helpful?