Payment Gateway API
Overview
Three payment modules ship in core, and each one exposes its own REST surface rather than sharing a generic payment endpoint. These are gateway plumbing, not a public payments API: the storefront calls some of them during checkout, the admin order screen calls the rest, and Stripe calls one of them from the outside.
Nine endpoints exist across the three modules:
| Endpoint | Access | Called by |
|---|---|---|
POST /api/stripe/paymentIntents | public | Storefront checkout |
POST /api/stripe/paymentIntents/capture | private | Admin order screen |
POST /api/stripe/paymentIntents/refund | private | Admin order screen |
POST /api/stripe/webhook | public | Stripe |
POST /api/paypal/orders | public | Storefront checkout |
POST /api/paypal/captureTransactions | public | Storefront PayPal return page |
POST /api/paypal/authorizedTransactions | public | Storefront PayPal return page |
POST /api/paypal/authorizations/capture | public | Admin order screen |
POST /api/cod/captures | private | Admin order screen |
POST /api/paypal/authorizations/capture is declared publicIt is the "Capture" button on the admin order screen, but its route.json says access: "public", so the admin auth middleware never runs on it. Anyone who knows an order uuid can trigger the capture of an authorized PayPal payment. Compare with the COD equivalent (/api/cod/captures), which is correctly private. Put a network-level rule in front of it if that matters to you.
Credential Resolution
Every gateway handler resolves its keys the same way: a config.json value wins, and the admin Settings → Payment value is the fallback. Nothing is read from environment variables directly.
| Purpose | Config key (wins) | Setting key (fallback) |
|---|---|---|
| Stripe secret key | system.stripe.secretKey | stripeSecretKey |
| Stripe webhook signing secret | system.stripe.endpointSecret | stripeEndpointSecret |
| Stripe capture behaviour | — | stripePaymentMode (capture or authorize) |
| PayPal client id | system.paypal.clientId | paypalClientId |
| PayPal client secret | system.paypal.clientSecret | paypalClientSecret |
| PayPal intent | — | paypalPaymentIntent (CAPTURE or AUTHORIZE, default CAPTURE) |
Stripe Endpoints
Create A Payment Intent
Creates a Stripe PaymentIntent for a cart and returns its client secret so the browser can mount Stripe Elements. This is the only gateway endpoint the storefront calls before the customer pays.
The amount comes from cart.grand_total, converted to the currency's smallest unit. The cart is resolved by uuid; a cart that does not exist answers 400 with Invalid cart. The order_id is not validated here — it is written into the PaymentIntent's metadata, and the webhook reads it back later to find the order.
capture_method follows the stripePaymentMode setting: capture produces automatic_async, anything else produces manual (authorize now, capture later through the endpoint below).
| Field Name | Field Type | Required |
|---|---|---|
| cart_id | string | Yes |
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/stripe/paymentIntents
fetch('https://<your domain>/api/stripe/paymentIntents', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"clientSecret": "pi_3QhK8xJv2Lm4Np0R1sT5uVwX_secret_YzA9bCdEfGhIjKlMnOpQrStU"
}
}
Both ids are uuids: cart_id is cart.uuid and order_id is order.uuid.
Capture A Payment Intent
Captures a PaymentIntent that was created in manual mode and is sitting in requires_capture. Used by the admin "Capture" button after an authorize-only checkout.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/stripe/paymentIntents/capture
fetch('https://<your domain>/api/stripe/paymentIntents/capture', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"amount": 12995
}
}
order_id is the order uuid. The returned amount is Stripe's amount, in the smallest currency unit — 12995 means 129.95 for a two-decimal currency.
The request is rejected with 400 when the order does not exist or its payment_method is not stripe (Invalid order), when no payment_transaction row exists for it (Can not find payment transaction), or when the PaymentIntent is not in requires_capture (Payment intent is not in the correct state (requires_capture)). On success the order's payment status moves to stripe_captured.
Refund A Payment Intent
Issues a full or partial refund against the order's PaymentIntent, then reconciles the order's payment status from the resulting Stripe charge: fully refunded becomes stripe_refunded, anything less becomes stripe_partial_refunded. An order activity log entry (Refunded <amount> <currency>) is written in the same transaction.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
| amount | string or number | No |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/stripe/paymentIntents/refund
fetch('https://<your domain>/api/stripe/paymentIntents/refund', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"amount": 5000
}
}
order_id here is the numeric id, not the uuidEvery other endpoint on this page resolves the order by uuid. This one queries WHERE order_id = :order_id — the integer primary key. Sending a uuid answers 400 with Invalid order. The admin refund form supplies the value from the GraphQL Order.orderId field, which is that integer.
amount is declared optional by the payload schema, but the handler passes it straight to the currency converter with no default, so a request without it will not produce a usable refund. Always send it, in major units (50 or 49.99), and read the returned amount back as smallest units.
Stripe Webhook
The endpoint Stripe calls. Set it to https://<your domain>/api/stripe/webhook in the Stripe dashboard, and paste the resulting signing secret into Settings → Payment → Stripe → Endpoint secret (or system.stripe.endpointSecret in config.json).
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/stripe/webhook
fetch('https://<your domain>/api/stripe/webhook', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"received": true
}
The route parses the request with bodyParser.raw({ type: '*/*' }) — the untouched bytes are required for stripe.webhooks.constructEvent(...), which verifies the stripe-signature header against the endpoint secret. If the secret is blank or wrong, every delivery fails. Do not put a JSON body parser in front of this route; a re-serialized body will never match the signature.
The success response is {"received": true} — a bare object, not the usual {"data": ...} envelope. Any failure, including a signature mismatch, answers 400 with a plain-text body of the form Webhook Error: <message> and rolls back the transaction, so Stripe retries.
The order is located from paymentIntent.metadata.order_id, which was written when the intent was created. Three event types are handled; everything else is logged at debug level and acknowledged.
| Event | Effect |
|---|---|
payment_intent.succeeded | Upserts the payment_transaction row. If this is the first transaction for the order, sets payment status stripe_captured, adds an activity log entry and emits order_placed. |
payment_intent.amount_capturable_updated | Upserts the payment_transaction row. If this is the first transaction for the order, sets payment status stripe_authorized, adds an activity log entry and emits order_placed. |
payment_intent.canceled | Sets payment status canceled. |
The order_placed emit is guarded by the absence of an existing payment_transaction row, so a redelivered event will not fire the event twice.
PayPal Endpoints
All four PayPal endpoints take a single order_id, which is the order uuid, and all four return an empty or near-empty envelope — the meaningful state change is on the order row, not in the response.
Create A PayPal Order
Builds the PayPal order payload from the EverShop order (line items, shipping, discount, tax breakdown, shipping address) and posts it to PayPal's /v2/checkout/orders. The returned PayPal order id is stored on order.integration_order_id, and the approval URL is handed back for the browser to redirect to.
The EverShop order must exist with payment_method = 'paypal' and payment_status = 'pending'; anything else answers 400 with Invalid order.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/orders
fetch('https://<your domain>/api/paypal/orders', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {
"paypalOrderId": "5O190127TN364715T",
"approveUrl": "https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T"
}
}
The intent sent to PayPal comes from the paypalPaymentIntent setting (CAPTURE by default). Line item prices and the amount breakdown switch between tax-inclusive and tax-exclusive columns according to the store's catalog price setting. If PayPal returns no order id, the handler re-activates the cart so the customer is not stranded, and answers 500 with PayPal's message.
Two registry keys let an extension rewrite the payload before it is sent — register a processor for either from bootstrap.ts: paypalFinalAmount (the amount breakdown) and finalPaypalOrderData (the whole request body).
Capture A PayPal Order
Called by the storefront's PayPal return page when the store's intent is CAPTURE. Posts to PayPal's /v2/checkout/orders/{integration_order_id}/capture, inserts the resulting payment_transaction row and moves the order's payment status to paypal_captured.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/captureTransactions
fetch('https://<your domain>/api/paypal/captureTransactions', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {}
}
This handler looks the order up by uuid alone — unlike the other three PayPal endpoints it does not additionally require payment_method = 'paypal' or a pending payment status.
Authorize A PayPal Order
Called by the storefront's PayPal return page when the store's intent is AUTHORIZE. Posts to PayPal's /v2/checkout/orders/{integration_order_id}/authorize, records the authorization as a payment_transaction with payment_action: "authorize", and moves the payment status to paypal_authorized.
The order must be payment_method = 'paypal' and payment_status = 'pending'.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/authorizedTransactions
fetch('https://<your domain>/api/paypal/authorizedTransactions', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {}
}
Capture An Authorized PayPal Payment
The second half of the authorize flow, driven from the admin order screen. It reads the stored authorization from PayPal first: if PayPal already reports it as CAPTURED, EverShop simply syncs its own status rather than double-capturing; otherwise it posts to /v2/payments/authorizations/{transaction_id}/capture. Either way the payment status ends at paypal_captured and an activity log entry is written.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/authorizations/capture
fetch('https://<your domain>/api/paypal/authorizations/capture', {
headers: {
'Accept': 'application/json',
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {}
}
Errors from PayPal are passed through with PayPal's own HTTP status and message rather than being flattened to 500. A missing payment_transaction row answers 400 with Can not find payment transaction.
Cash On Delivery
Capture A COD Payment
Marks a cash-on-delivery order as paid. This is the admin "Capture" button on the order screen and it is the one gateway endpoint on this page that is correctly gated as private.
The order must exist with payment_method = 'cod' and payment_status = 'pending'; otherwise 400 with Requested order does not exist or is not in pending payment status.
| Field Name | Field Type | Required |
|---|---|---|
| order_id | string | Yes |
- cURL
- JavaScript
curl
-H "Accept: application/json"
-H "Authorization: Bearer <admin JWT token>"
--data-raw '<JSON DATA>'
https://<your domain>/api/cod/captures
fetch('https://<your domain>/api/cod/captures', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer <admin JWT token>'
},
body: <JSON DATA>
})
.then(response => response.json())
.then(data => {
if(data.error) {
// Handle the error
} else {
// Handle the data
}
})
.catch(error => {
// Handle the error
});
{
"data": {}
}
On success it does three things: sets the order's payment status to paid, inserts a payment_transaction row with transaction_type: "offline" and payment_action: "capture" for the full grand_total, and appends the order activity Customer paid using cash.
Adding Your Own Gateway
None of these endpoints are extension points. A new gateway registers itself at bootstrap with registerPaymentMethod and ships its own api/ folder in the same shape as the modules above. See the payment method development guide and the Payment Method API for how a method becomes selectable on a cart.