Payment Gateway API
Overview
Two payment modules expose REST endpoints for the parts that talk to their gateway: creating the payment during checkout, and receiving the gateway's webhook. These are gateway plumbing, not a public payments API.
Capturing and refunding no longer have gateway-specific routes. Since 2.3, one pair of admin endpoints serves every payment method, including Cash On Delivery: POST /api/orders/:id/capture and POST /api/orders/:id/refunds.
Four gateway endpoints exist:
| Endpoint | Access | Called by |
|---|---|---|
POST /api/stripe/paymentIntents | public | Storefront checkout |
POST /api/stripe/webhook | public | Stripe |
POST /api/paypal/orders | public | Storefront checkout |
POST /api/paypal/webhook | public | PayPal |
These routes no longer exist:
| Removed route | Use instead |
|---|---|
POST /api/stripe/paymentIntents/capture, POST /api/paypal/authorizations/capture, POST /api/cod/captures | POST /api/orders/:id/capture |
POST /api/stripe/paymentIntents/refund | POST /api/orders/:id/refunds |
POST /api/paypal/authorizedTransactions, POST /api/paypal/captureTransactions | Nothing. The PayPal return page (/paypal/processing/:order_id) finalizes the payment in-process. |
The old routes took an order_id in the request body (the Stripe refund route wanted the numeric id). The new routes take the order uuid in the URL path. The PayPal return page was also renamed from /paypal/proccessing to /paypal/processing.
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 behavior | — | stripePaymentMode (capture or authorizeOnly) |
| PayPal client id | system.paypal.clientId | paypalClientId |
| PayPal client secret | system.paypal.clientSecret | paypalClientSecret |
| PayPal environment (API base URL) | system.paypal.environment | paypalEnvironment (default https://api-m.sandbox.paypal.com) |
| PayPal webhook id | system.paypal.webhookId | paypalWebhookId |
| PayPal intent | — | paypalPaymentIntent (CAPTURE or AUTHORIZE, default CAPTURE) |
| PayPal abandoned-order TTL in hours | system.paypal.abandonedOrderTtlHours (default 6) | — |
| PayPal reconciliation schedule | system.paypal.reconcileSchedule (default */30 * * * *) | — |
| PayPal reconciliation switch | system.paypal.reconcileEnabled (false disables the job) | — |
Stripe Endpoints
Create A Payment Intent
Creates a Stripe PaymentIntent for an order 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, and it is called after the order has been placed.
The amount and currency come from the order, not from the request: order.grand_total converted to the currency's smallest unit, in order.currency. The order is resolved by uuid and must have payment_method = 'stripe' and payment_status = 'pending'; anything else answers 400 with Invalid order. An order that is already paid, already failed, or placed with another method therefore cannot get a new PaymentIntent.
cart_id is still required by the payload schema, but the server ignores it. The PaymentIntent's metadata carries only order_id (the order uuid), which the webhook and the return page read back to find the order.
capture_method follows the stripePaymentMode setting: capture produces automatic_async, anything else produces manual (authorize now, capture later with POST /api/orders/:id/capture).
| 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: order_id is order.uuid and cart_id is cart.uuid (ignored).
| Status | Body |
|---|---|
200 | { "data": { "clientSecret": "..." } } |
400 | { "error": { "status": 400, "message": "Invalid order" } }, or the schema message when a required field is missing |
500 | { "error": { "status": 500, "message": "Can not create the payment intent" } } for any other failure, such as a Stripe API error |
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. An exception, 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.
Five event types are handled. For payment_intent.* events the order is located from paymentIntent.metadata.order_id, which was written when the intent was created. For charge.refunded the charge carries no such metadata, so the order is found through the saved payment_transaction whose transaction_id equals the charge's payment_intent.
| 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.payment_failed | If the order is still pending, sets payment status stripe_failed and logs Stripe's error message. A late failure never overrides a captured or authorized order. |
payment_intent.canceled | Sets payment status canceled, unless the order is already canceled. |
charge.refunded | Records the refund through core's refund recorder, keyed on the Stripe refund id: a refund transaction, payment status stripe_refunded or stripe_partial_refunded, and the order_refunded event. A refund issued from EverShop and its webhook echo end as one transaction. |
Enable all five events on the webhook endpoint in the Stripe dashboard (or choose "All events").
Every other event type, an event whose order cannot be found, and an event for an order that is not a Stripe order is logged and acknowledged with 200 {"received": true}, so Stripe does not retry something the store will never act on.
Before payment_intent.succeeded or payment_intent.amount_capturable_updated can mark an order paid, the handler checks that the PaymentIntent's amount and currency equal the order's grand total and currency. On a mismatch nothing is written, an error is logged, the order stays pending for manual review, and the response is still 200 because retrying cannot fix it.
The handler runs in one database transaction and locks the order row (SELECT ... FOR UPDATE). "First transaction" is decided under that lock, so a redelivered or overlapping event cannot fire order_placed twice.
PayPal Endpoints
The buyer-facing capture/authorize step has no REST endpoint: the storefront's PayPal return page (/paypal/processing/:order_id) finalizes the payment in-process through the finalizePaypalOrder service. What remains on the REST surface is order creation (storefront) and the webhook. Admin capture and refund use the unified order endpoints. order_id below is the order uuid.
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 payload uses the current Orders v2 shape: buyer experience settings (return/cancel URLs, PAY_NOW, brand name) ride in payment_source.paypal.experience_context, the EverShop order number is sent as purchase_units[0].invoice_id (so captures show up reconciled in the PayPal dashboard and duplicate captures are rejected by PayPal itself), and the order uuid is sent as the PayPal-Request-Id idempotency key — a retried create returns the same PayPal order instead of minting a new one.
The intent comes from the paypalPaymentIntent setting (CAPTURE by default). Line item prices switch between tax-inclusive and tax-exclusive columns according to the store's catalog price setting, and the amount breakdown is computed in integer minor units and verified to sum exactly to the grand total — when per-unit rounding makes that impossible, the itemized breakdown is dropped and a bare amount is sent (PayPal accepts it; a mismatched breakdown would be a 422). Zero-decimal currencies (JPY, HUF, TWD) are sent as integers.
If PayPal returns no order id or no approval link, the handler re-activates the cart so the customer is not stranded, and answers 500 with PayPal's message, or PayPal did not return an approval link for this order.
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).
PayPal Webhook
The endpoint PayPal calls. Create a webhook in the PayPal developer dashboard pointing at https://<your domain>/api/paypal/webhook, subscribe it to the five events below, and paste the webhook's ID (not a secret — PayPal webhooks are verified by id) into Settings → Payment → Paypal → Webhook ID (or system.paypal.webhookId in config.json).
- cURL
- JavaScript
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/webhook
fetch('https://<your domain>/api/paypal/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
});
{
"data": {}
}
Every delivery is verified through PayPal's verify-webhook-signature API using the configured webhook id; a failed verification answers 400. While no webhook id is configured the endpoint answers 503 and logs a warning — the webhook feature is inert until you wire it. Transient processing failures answer 500, which makes PayPal retry the delivery.
The local order is resolved from the PayPal order id (integration_order_id), then the invoice_id (the order number), then the capture id saved in payment_transaction; events for unknown orders are acknowledged and ignored.
Events can arrive out of order, so each one only acts from a state where it makes sense.
| Event | Effect |
|---|---|
CHECKOUT.ORDER.APPROVED | If the order is still pending (the buyer approved at PayPal but never completed the return redirect), captures or authorizes it server-side — the same finalization the return page runs. A payment PayPal declined sets paypal_failed. |
PAYMENT.CAPTURE.COMPLETED | Upserts the payment_transaction row and sets payment status paypal_captured. Ignored for refunded, partially refunded and canceled orders. Emits order_placed only if this is the first record of the capture. |
PAYMENT.CAPTURE.PENDING | Records the capture and sets payment status paypal_pending (eCheck, manual review — money in flight, not yet settled). Never downgrades a settled or canceled order. |
PAYMENT.CAPTURE.DENIED | Only when the order is pending or paypal_pending: sets payment status paypal_failed and logs the denial for admin review. Deliberately does not auto-cancel the order. |
PAYMENT.CAPTURE.REFUNDED | Records the refund through core's refund recorder, keyed on the PayPal refund id, and sets paypal_refunded / paypal_partial_refunded from the cumulative refunded total. Emits order_refunded. A replay of a refund already recorded through POST /api/orders/:id/refunds is a no-op. |
All handlers share the same idempotency guard as the storefront return page (the payment_transaction row keyed on transaction id), so duplicate deliveries and out-of-order deliveries cannot double-record a payment. The order_placed guard is per PayPal transaction id.
Cash On Delivery
Cash On Delivery has no REST endpoints of its own. Since 2.3 it follows the same capture and refund contract as the other methods, with no money moving through a gateway:
- A COD order is created with payment status
cod_pending, and an offlineauthorizetransaction (cod-authorize-<uuid>) is recorded for it. - When the cash is collected, the admin calls
POST /api/orders/:id/capture. It records an offlinecapturetransaction for the full grand total, setscod_captured(labeled "Paid") and adds the activityCaptured <amount> <currency>. Transaction ID: cod-capture-<uuid>-<timestamp>. - Refunds use
POST /api/orders/:id/refundsand are recorded offline, ending incod_refundedorcod_partial_refunded.
Existing COD orders that were awaiting payment are moved from pending to cod_pending by a migration when you upgrade.
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 for the payment-creation and webhook routes, in the same shape as the modules above. It does not ship capture or refund routes: it declares optional capture, void and refund handlers, and core's order endpoints call them. See the payment method development guide and the Payment Method API for how a method becomes selectable on a cart.