Payment Gateways: Stripe, PayPal, Razorpay and Polar
How to set up Stripe, PayPal, Razorpay and Polar: credentials, webhook URLs and the events to enable, test and live modes, renewals, currency routing and the mock gateway. Each provider uses its hosted page, so card details never touch your site; the mock gateway stands in for all four in test mode.
Not verified live. The gateway code is covered by unit tests with recorded request and response shapes. With the mock gateway, the full purchase flow runs end to end on Node, and refunds and a recurring renewal have also run on a local
workerdwith D1 and R2. None of the four providers has been run against a live sandbox account yet. Do a real test-mode purchase, renewal and refund with each provider you plan to use before you take money.
Where the settings live
All gateway settings are on the admin Payments page. Secret fields are encrypted by EmDash (they need EMDASH_ENCRYPTION_KEY) and are never sent back to the browser. An empty secret field on save keeps the stored value.
payment_mode:test(default) orlive. Every credential has a test and a live copy, and the mode picks which one is used.- Credential keys follow
<gateway>_<key>_<mode>, for examplestripe_secret_key_testandstripe_secret_key_live. - Stripe, Razorpay and Polar also accept an unsuffixed webhook secret (
stripe_webhook_secret,razorpay_webhook_secret,polar_webhook_secret) as a fallback for both modes. The per-mode key wins when both are set. checkout_expiry_hours(default 48): unpaid checkouts expire after this.
Session expiry and coupon holds
A checkout with a coupon holds one use of it from the moment it starts. If the buyer cancels or the checkout fails or expires, that use goes back, but only once the provider session can no longer be paid. Otherwise a buyer could cancel, let the use go to someone else, and then pay the old session.
- Every provider session is created with an expiry that follows the checkout’s own (
checkout_expiry_hours), for one-time and subscription checkouts, where the provider’s API has one:Provider Session expiry Closing an open session Stripe expires_aton the Checkout Session, clamped to Stripe’s 30 minutes to 24 hours. A 48-hour checkout gets a 24-hour session.Expires the session ( /checkout/sessions/<id>/expire).Razorpay expire_byon the payment link or the subscription (at least 16 minutes ahead).Cancels the payment link or the pending subscription. Polar None can be set, but each checkout carries its own expires_at, which Vendwick stores.No API. PayPal None. Orders v2 and Billing Subscriptions take no expiry. No API for a created order. - The provider’s expiry is stored on the checkout as
paymentExpiresAt. The orders tick releases a failed or expired checkout’s hold when there was no session, whenpaymentExpiresAthas passed, or when the provider closed the session (or says it already expired). Otherwise it waits untilpaymentExpiresAt. With no provider expiry (PayPal), it waits until the checkout’s own expiry. - A payment that still arrives after the hold went back is fulfilled, because the customer paid. When it takes the coupon past
maxUses, or past the buyer’s per-customer limit, the redemption is flaggedoverLimit, the audit log gets acoupon.over_limitevent on the order, and the admin order view shows the coupon as over its limit. The coupon’susescan then be higher thanmaxUses.
Webhook URLs
Paste this URL into each provider’s dashboard, with your site’s origin in front:
https://your-site.example/_vendwick/webhook/<provider>
<provider> is stripe, paypal, razorpay or polar. The companion forwards the raw body and the signature headers, unchanged, to the plugin route. That plugin route also works directly:
https://your-site.example/_emdash/api/plugins/vendwick/payments/webhook?provider=<provider>
The Payments page shows both URLs per gateway, whether a webhook secret is saved, and the last webhook received.
Answers follow the WordPress plugin’s status table: 401 for a bad signature, 503 when something failed and the provider should retry, 404 for an unknown provider, and 200 for everything else (including duplicates, unmatched payments and verified events Vendwick doesn’t use, which answer ignored). Event ids are stored, so a retried delivery is applied once.
Stripe
- Settings:
stripe_secret_key_<mode>(required),stripe_publishable_key_<mode>,stripe_webhook_secret_<mode>. - Checkout: a hosted Checkout Session (
mode=payment), created with anIdempotency-Key. - Signature: the
Stripe-Signatureheader, HMAC-SHA256 over<t>.<body>with yourwhsec_secret, and a 300-second window ont. - Events for one-time payments:
checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedcheckout.session.expiredcharge.refundedcharge.refund.updatedrefund.updatedrefund.failedcharge.dispute.createdcharge.dispute.closed
- Add these for automatic renewals (Stripe Billing):
invoice.paidinvoice.payment_succeededinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
- Refunds:
charge.refundedcounts as a full refund only when the whole charge was refunded.refund.failed,refund.updatedandcharge.refund.updatedreport a refund that failed after Stripe accepted it, which Vendwick then takes back. From the admin, Vendwick refunds the payment intent behind the Checkout Session (POST /v1/refunds,Idempotency-Key: vw-refund-<refundId>,metadata[vw_refund]); a renewal is refunded by its own invoice’s charge. - Disputes:
charge.dispute.createdopens,charge.dispute.closedwithwon(orwarning_closed) wins and withlostloses.funds_withdrawnandfunds_reinstatedare acknowledged without effect. - Recurring: a recurring plan becomes a Checkout Session in
mode=subscription(with any one-time cart lines on the first invoice) with a Price created once per plan, currency, interval and amount. A first payment lower than the renewal price gets a one-timeamount_offcoupon. Plan trial days become a trial that ends at the license’s first expiry.
PayPal
- Settings:
paypal_client_id_<mode>andpaypal_client_secret_<mode>(required),paypal_webhook_id_<mode>. - API host:
api-m.sandbox.paypal.comin test mode,api-m.paypal.comin live mode. - Checkout: Orders v2 with a server-side capture. The return page captures the order, so a one-time payment can settle without a webhook.
- Signature: PayPal has no shared secret. Vendwick sends the
paypal-transmission-*,paypal-cert-urlandpaypal-auth-algoheaders to PayPal’sverify-webhook-signatureAPI with your webhook id. Withoutpaypal_webhook_id_<mode>, every webhook is refused. - Events for one-time payments:
CHECKOUT.ORDER.APPROVEDCHECKOUT.ORDER.VOIDEDPAYMENT.CAPTURE.COMPLETEDPAYMENT.CAPTURE.DENIEDPAYMENT.CAPTURE.REFUNDEDPAYMENT.CAPTURE.REVERSEDCUSTOMER.DISPUTE.CREATEDCUSTOMER.DISPUTE.RESOLVED
- Add these for automatic renewals:
BILLING.SUBSCRIPTION.ACTIVATEDBILLING.SUBSCRIPTION.SUSPENDEDBILLING.SUBSCRIPTION.CANCELLEDBILLING.SUBSCRIPTION.EXPIREDBILLING.SUBSCRIPTION.PAYMENT.FAILEDPAYMENT.SALE.COMPLETEDPAYMENT.SALE.REFUNDEDPAYMENT.SALE.REVERSED
- Amounts use PayPal’s decimals: none for HUF, JPY and TWD.
- Refunds: from the admin, Vendwick refunds the order’s capture (
POST /v2/payments/captures/{id}/refund,PayPal-Request-Id: vw-refund-<refundId>,custom_id: vwr-<refundId>); a subscription payment is refunded by its sale.PAYMENT.CAPTURE.REFUNDEDis read as this refund’s own amount, plus the running total only when PayPal includes it, so two equal partial refunds add up. - Disputes:
CUSTOMER.DISPUTE.CREATEDopens;CUSTOMER.DISPUTE.RESOLVEDcloses bydispute_outcome.outcome_code(seller favor, canceled by the buyer, denied, or resolved with a PayPal payout are won; buyer favor, accepted and refunded are lost).PAYMENT.CAPTURE.REVERSEDis treated as a lost dispute of that capture. - Recurring: a PayPal product per store product and a billing plan per set of terms, both cached. A first payment lower than the renewal price is a one-cycle paid trial. Renewals only arrive by webhook, so recurring plans need the webhook id.
Razorpay
- Settings:
razorpay_key_id_<mode>andrazorpay_key_secret_<mode>(required),razorpay_webhook_secret_<mode>. - Checkout: a Payment Link with a
reference_idthat is stable per checkout, so a retry reuses the existing link. - Signature: the
X-Razorpay-Signatureheader, HMAC-SHA256 over the raw body. Razorpay signs no timestamp, so there’s no replay window; the stored event id (X-Razorpay-Event-Id) stops a replay from applying twice. - Events for one-time payments:
payment_link.paidpayment_link.cancelledpayment_link.expiredpayment.capturedpayment.failedrefund.processedrefund.failedpayment.dispute.createdpayment.dispute.wonpayment.dispute.lostpayment.dispute.closed
- Add these for automatic renewals:
subscription.activatedsubscription.chargedsubscription.pendingsubscription.haltedsubscription.cancelledsubscription.completed
- The smallest charge is 100 minor units (one rupee).
- Refunds are full only for
refund_status: fullor a refunded amount that covers the payment. From the admin, Vendwick refunds the Payment Link’s captured payment (POST /v1/payments/{id}/refundwithnotes.vw_refund); a subscription purchase is refunded by its first paid invoice’s payment. Razorpay has no documented idempotency key for refunds, so a retry first lists the payment’s refunds and reuses the one with this refund id.refund.failedtakes back a refund that failed later. - Disputes:
payment.dispute.createdopens,payment.dispute.wonandpayment.dispute.lostclose,payment.dispute.closedcloses by the dispute’s status. - Recurring limits: Razorpay can’t lower the first charge, run a trial, or restart a canceled subscription.
Polar
- Settings:
polar_access_token_<mode>(required),polar_webhook_secret_<mode>,polar_product_id_<mode>(or the unsuffixedpolar_product_id). - API host:
sandbox-api.polar.shin test mode,api.polar.shin live mode. - Checkout: with a product id set, the checkout sells that Polar product at its Polar price. Without one, Vendwick creates a private one-time product per amount, currency and label and pins its price.
- Signature: Standard Webhooks.
webhook-id,webhook-timestampandwebhook-signature(v1,<base64>) over<id>.<timestamp>.<body>, with a 300-second window. Awhsec_secret is base64-decoded first; the raw secret string is also tried. - Events for one-time payments:
order.createdorder.paidorder.refundedcheckout.updatedrefund.createdrefund.updated
- Add these for automatic renewals:
subscription.activesubscription.canceledsubscription.uncanceledsubscription.revokedsubscription.past_due
- Merchant of record: Polar adds its own sales tax. A checkout with tax or a coupon can’t use Polar, and neither can a cart with a physical item; the buyer is asked to pick another method.
- Recurring: the first payment must equal the renewal price, and trials are refused.
- Refunds: from the admin, Vendwick refunds the Polar order (
POST /v1/refunds/,revoke_benefits: false,metadata.vw_refund). The order id is saved when the paidorder.*event arrives, so a checkout paid before 0.2.0 has none and is refunded in the Polar dashboard. Polar’s refunded totals include the tax Polar adds, so Vendwick records Polar refunds fromrefund.*events (their own amount) and from a fullorder.refunded. - Disputes: Polar handles chargebacks as merchant of record and sends no dispute events Vendwick can act on. Not supported.
- The request shapes follow the WordPress plugin’s 5.0.2 Polar gateway and its tests. This is the least certain of the four.
The mock gateway
For demos and local testing only. Turn on payments_mock_gateways while payment_mode is test. Live mode never uses it.
- It isn’t a fifth provider. It runs under each real id, so orders still say
stripe,paypaland so on, and no request leaves the site. - A checkout goes straight to the success return URL, as if the buyer approved.
- A refund from the admin succeeds at once with refund id
mock_refund_<refundId>. The reasonmock:failis refused andmock:erroracts as a provider that did not answer. - Webhooks are signed with
payments_mock_webhook_secret:x-mock-timestampplusx-mock-signature= hex HMAC-SHA256 of<t>.<body>, 300-second window. Send them to the same/_vendwick/webhook/<provider>URL. - Event types:
payment.paid,payment.failed,payment.pending,payment.refunded,payment.partially_refunded(optionalrefund_id,refund_amount,local_refund_id),dispute.created,dispute.won,dispute.lost(dispute_id,payment_id,amount,reason), and for subscriptionssubscription.activated,subscription.payment,subscription.renewal_paid,subscription.payment_failed,subscription.cancelled,subscription.resumed,subscription.ended,subscription.renewal_refunded.
Test and live
Set up and test everything in test mode with the provider’s sandbox or test keys, then enter the live keys and switch payment_mode to live. Webhooks are configured separately in each provider’s test and live dashboards, so add the URL and events in both.
Automatic renewals
Recurring plans renew through the provider when the Automatic Renewals module is on. Each gateway has its own switch (subscriptions_stripe, subscriptions_paypal, subscriptions_razorpay, subscriptions_polar, all on by default). These apply to new purchases of a recurring plan (one subscription line per cart; see Cart and Checkout); existing licenses keep manual renewals.
subscriptions_grace_days (default 3) is how long Vendwick waits after a period ends without a renewal payment. Then the subscription is marked past due, and 30 days later it expires.
Currency routing
Each currency maps to an ordered list of gateways. The first is the default; the others are alternatives the native checkout can offer. The defaults:
USD: Stripe, PayPal, PolarEUR: Stripe, PayPalINR: Razorpay
Edit the routes on the Payments page. Reset brings the defaults back. Saving an empty map means no routes, and then any gateway is allowed for any currency.
Refunds and disputes
Refund an order from its admin page, by amount or by item, or in the provider’s dashboard. Both reach the same ledger: each refund is one refund row and one ledger entry, whichever of the admin’s answer and the provider’s webhook arrives first. A partial refund marks the order partially refunded, records per-item amounts, issues a credit note and a report entry, and disables licenses only for the items you chose. When refunds add up to the amount paid, the full reversal runs once: every license is disabled, the coupon use is given back and the provider subscription is ended.
Disputes suspend the order’s licenses while open, restore them when won, and record a refund when lost. The details, per provider, are in Refunds and disputes.