Skip to content
Gaurav TiwariDocs

Payment Gateways: Stripe, PayPal, Razorpay and Polar

Version 0.2.0 Updated November 15, 2026

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 workerd with 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) or live. Every credential has a test and a live copy, and the mode picks which one is used.
  • Credential keys follow <gateway>_<key>_<mode>, for example stripe_secret_key_test and stripe_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:
    ProviderSession expiryClosing an open session
    Stripeexpires_at on 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).
    Razorpayexpire_by on the payment link or the subscription (at least 16 minutes ahead).Cancels the payment link or the pending subscription.
    PolarNone can be set, but each checkout carries its own expires_at, which Vendwick stores.No API.
    PayPalNone. 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, when paymentExpiresAt has passed, or when the provider closed the session (or says it already expired). Otherwise it waits until paymentExpiresAt. 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 flagged overLimit, the audit log gets a coupon.over_limit event on the order, and the admin order view shows the coupon as over its limit. The coupon’s uses can then be higher than maxUses.

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 an Idempotency-Key.
  • Signature: the Stripe-Signature header, HMAC-SHA256 over <t>.<body> with your whsec_ secret, and a 300-second window on t.
  • Events for one-time payments:
    • checkout.session.completed
    • checkout.session.async_payment_succeeded
    • checkout.session.async_payment_failed
    • checkout.session.expired
    • charge.refunded
    • charge.refund.updated
    • refund.updated
    • refund.failed
    • charge.dispute.created
    • charge.dispute.closed
  • Add these for automatic renewals (Stripe Billing):
    • invoice.paid
    • invoice.payment_succeeded
    • invoice.payment_failed
    • customer.subscription.updated
    • customer.subscription.deleted
  • Refunds: charge.refunded counts as a full refund only when the whole charge was refunded. refund.failed, refund.updated and charge.refund.updated report 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.created opens, charge.dispute.closed with won (or warning_closed) wins and with lost loses. funds_withdrawn and funds_reinstated are 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-time amount_off coupon. Plan trial days become a trial that ends at the license’s first expiry.

PayPal

  • Settings: paypal_client_id_<mode> and paypal_client_secret_<mode> (required), paypal_webhook_id_<mode>.
  • API host: api-m.sandbox.paypal.com in test mode, api-m.paypal.com in 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-url and paypal-auth-algo headers to PayPal’s verify-webhook-signature API with your webhook id. Without paypal_webhook_id_<mode>, every webhook is refused.
  • Events for one-time payments:
    • CHECKOUT.ORDER.APPROVED
    • CHECKOUT.ORDER.VOIDED
    • PAYMENT.CAPTURE.COMPLETED
    • PAYMENT.CAPTURE.DENIED
    • PAYMENT.CAPTURE.REFUNDED
    • PAYMENT.CAPTURE.REVERSED
    • CUSTOMER.DISPUTE.CREATED
    • CUSTOMER.DISPUTE.RESOLVED
  • Add these for automatic renewals:
    • BILLING.SUBSCRIPTION.ACTIVATED
    • BILLING.SUBSCRIPTION.SUSPENDED
    • BILLING.SUBSCRIPTION.CANCELLED
    • BILLING.SUBSCRIPTION.EXPIRED
    • BILLING.SUBSCRIPTION.PAYMENT.FAILED
    • PAYMENT.SALE.COMPLETED
    • PAYMENT.SALE.REFUNDED
    • PAYMENT.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.REFUNDED is 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.CREATED opens; CUSTOMER.DISPUTE.RESOLVED closes by dispute_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.REVERSED is 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> and razorpay_key_secret_<mode> (required), razorpay_webhook_secret_<mode>.
  • Checkout: a Payment Link with a reference_id that is stable per checkout, so a retry reuses the existing link.
  • Signature: the X-Razorpay-Signature header, 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.paid
    • payment_link.cancelled
    • payment_link.expired
    • payment.captured
    • payment.failed
    • refund.processed
    • refund.failed
    • payment.dispute.created
    • payment.dispute.won
    • payment.dispute.lost
    • payment.dispute.closed
  • Add these for automatic renewals:
    • subscription.activated
    • subscription.charged
    • subscription.pending
    • subscription.halted
    • subscription.cancelled
    • subscription.completed
  • The smallest charge is 100 minor units (one rupee).
  • Refunds are full only for refund_status: full or a refunded amount that covers the payment. From the admin, Vendwick refunds the Payment Link’s captured payment (POST /v1/payments/{id}/refund with notes.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.failed takes back a refund that failed later.
  • Disputes: payment.dispute.created opens, payment.dispute.won and payment.dispute.lost close, payment.dispute.closed closes 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 unsuffixed polar_product_id).
  • API host: sandbox-api.polar.sh in test mode, api.polar.sh in 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-timestamp and webhook-signature (v1,<base64>) over <id>.<timestamp>.<body>, with a 300-second window. A whsec_ secret is base64-decoded first; the raw secret string is also tried.
  • Events for one-time payments:
    • order.created
    • order.paid
    • order.refunded
    • checkout.updated
    • refund.created
    • refund.updated
  • Add these for automatic renewals:
    • subscription.active
    • subscription.canceled
    • subscription.uncanceled
    • subscription.revoked
    • subscription.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 paid order.* 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 from refund.* events (their own amount) and from a full order.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, paypal and 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 reason mock:fail is refused and mock:error acts as a provider that did not answer.
  • Webhooks are signed with payments_mock_webhook_secret: x-mock-timestamp plus x-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 (optional refund_id, refund_amount, local_refund_id), dispute.created, dispute.won, dispute.lost (dispute_id, payment_id, amount, reason), and for subscriptions subscription.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, Polar
  • EUR: Stripe, PayPal
  • INR: 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.

Share

Was this helpful?

Still stuck? Open a support ticket