Skip to main content

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:

EndpointAccessCalled by
POST /api/stripe/paymentIntentspublicStorefront checkout
POST /api/stripe/webhookpublicStripe
POST /api/paypal/orderspublicStorefront checkout
POST /api/paypal/webhookpublicPayPal
Removed in 2.3.0

These routes no longer exist:

Removed routeUse instead
POST /api/stripe/paymentIntents/capture, POST /api/paypal/authorizations/capture, POST /api/cod/capturesPOST /api/orders/:id/capture
POST /api/stripe/paymentIntents/refundPOST /api/orders/:id/refunds
POST /api/paypal/authorizedTransactions, POST /api/paypal/captureTransactionsNothing. 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.

PurposeConfig key (wins)Setting key (fallback)
Stripe secret keysystem.stripe.secretKeystripeSecretKey
Stripe webhook signing secretsystem.stripe.endpointSecretstripeEndpointSecret
Stripe capture behavior—stripePaymentMode (capture or authorizeOnly)
PayPal client idsystem.paypal.clientIdpaypalClientId
PayPal client secretsystem.paypal.clientSecretpaypalClientSecret
PayPal environment (API base URL)system.paypal.environmentpaypalEnvironment (default https://api-m.sandbox.paypal.com)
PayPal webhook idsystem.paypal.webhookIdpaypalWebhookId
PayPal intent—paypalPaymentIntent (CAPTURE or AUTHORIZE, default CAPTURE)
PayPal abandoned-order TTL in hourssystem.paypal.abandonedOrderTtlHours (default 6)—
PayPal reconciliation schedulesystem.paypal.reconcileSchedule (default */30 * * * *)—
PayPal reconciliation switchsystem.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).

Request Schema (application/json)
Field NameField TypeRequired
cart_idstringYes
order_idstringYes
POST/api/stripe/paymentIntents
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/stripe/paymentIntents
Sample Of Response
{
"data": {
"clientSecret": "pi_3QhK8xJv2Lm4Np0R1sT5uVwX_secret_YzA9bCdEfGhIjKlMnOpQrStU"
}
}

Both ids are uuids: order_id is order.uuid and cart_id is cart.uuid (ignored).

StatusBody
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).

Request Schema (application/json)
No request body required
POST/api/stripe/webhook
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/stripe/webhook
Sample Of Response
{
"received": true
}
Signature verification is mandatory and there is no way to disable it

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.

EventEffect
payment_intent.succeededUpserts 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_updatedUpserts 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_failedIf 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.canceledSets payment status canceled, unless the order is already canceled.
charge.refundedRecords 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.

Request Schema (application/json)
Field NameField TypeRequired
order_idstringYes
POST/api/paypal/orders
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/orders
Sample Of Response
{
"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).

Request Schema (application/json)
No request body required
POST/api/paypal/webhook
curl
-H "Accept: application/json"
--data-raw "<JSON DATA>"
https://<your domain>/api/paypal/webhook
Sample Of Response
{
"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.

EventEffect
CHECKOUT.ORDER.APPROVEDIf 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.COMPLETEDUpserts 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.PENDINGRecords 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.DENIEDOnly 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.REFUNDEDRecords 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 offline authorize transaction (cod-authorize-<uuid>) is recorded for it.
  • When the cash is collected, the admin calls POST /api/orders/:id/capture. It records an offline capture transaction for the full grand total, sets cod_captured (labeled "Paid") and adds the activity Captured <amount> <currency>. Transaction ID: cod-capture-<uuid>-<timestamp>.
  • Refunds use POST /api/orders/:id/refunds and are recorded offline, ending in cod_refunded or cod_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.