Payment — PhonePe Provider
PhonePe Standard Checkout v2 provider covering web hosted checkout and the mobile SDK, the customer-side verify endpoint, the credential-verified webhook, and gateway-driven refunds.
PhonePe Standard Checkout v2 provider that plugs into the platform-neutral payment module. Covers web hosted checkout and the Android/iOS/Flutter/React-Native SDKs, the customer-side verify endpoint, a credential-verified webhook, and gateway-driven refunds through the neutral refund surface.
Source:
api-modules/payment-phonepe(registered viaPhonePePaymentModule.forRoot()inapps/api/src/app.module.ts).Registers a
PaymentProviderwith idphonepeinto the platformPaymentRegistry. The order module knows nothing about PhonePe specifically — it dispatches through the registry.This is v2 / Standard Checkout, not the legacy v1
X-VERIFYSHA256-plus-salt scheme that most third-party write-ups still describe. The two are not interchangeable.
Conventions
Authentication
| Endpoint | Auth |
|---|---|
POST /store/orders/:id/phonepe/verify | required (customer session) |
POST /webhooks/payments/phonepe | none — Authorization: SHA256(username:password) |
The verify endpoint also pins the request to the place-time order_payment row, so a valid gateway response for a different order cannot drive a state change on this one.
Currency
PhonePe uses integer paise, the same subunits the platform uses internally — no scaling between the order's grandTotal and PhonePe's amount. PhonePe rejects amounts below 100 paise.
Configuration
Settings live in the payment.phonepe group (read via SettingsService.getGroup("admin", …)), not environment variables, so they are per-deployment and rotatable from the admin UI without a restart.
| Key | Required | Purpose |
|---|---|---|
client_id | yes | OAuth client id. Empty disables the provider |
client_secret | yes | OAuth client secret. Secret |
client_version | yes (default 1) | Version credential issued with the client id |
merchant_id | yes | Merchant ID from onboarding. Distinct from client_id — the mobile SDK's init() needs it, and it is sent as X-MERCHANT-ID on status/refund calls |
environment | yes (default sandbox) | sandbox or production. Selects both hosts and the SDK's PhonePeEnvironment |
webhook_username | for webhooks | Half of the webhook credential |
webhook_password | for webhooks | Secret |
redirect_url | web only | Where PhonePe returns the browser. Unused by the mobile SDK, so app-only installs may leave it empty |
payment_expiry_seconds | default 1200 | expireAfter; PhonePe accepts 300–3600 |
allow_refunds | default false | Gates gateway refunds and whether pg.refund.* webhooks are acted on |
Hosts
| OAuth | Payment APIs | |
|---|---|---|
| Sandbox | api-preprod.phonepe.com/apis/pg-sandbox/v1/oauth/token | api-preprod.phonepe.com/apis/pg-sandbox |
| Production | api.phonepe.com/apis/identity-manager/v1/oauth/token | api.phonepe.com/apis/pg |
Note production OAuth sits on a different host from the payment APIs.
Tokens
Every payment call carries Authorization: O-Bearer <access_token>. Tokens are cached per (environment, client_id) and refreshed 5 minutes before expires_at, which PhonePe reports in epoch seconds. Concurrent callers share one in-flight request, so a burst of webhooks does not stampede the OAuth endpoint. A 401 from a payment API drops the cached token and retries once.
Place-order integration (no HTTP surface of its own)
PaymentProvider field | Value |
|---|---|
id | "phonepe" |
methods | ["phonepe"] — PhonePe's checkout renders UPI / card / netbanking internally |
supportedPlatforms | ["WEB", "APP"] |
Identity mapping
PhonePe keys everything off a merchant reference we choose, and offers no free-form notes object on the order — only metaInfo.udf1…udf5.
merchantOrderId= the human order number (ORD-2026-00000123), so the PhonePe dashboard is readable and because the status and refund APIs are keyed by it. Sanitized to[A-Za-z0-9_-]and capped at 63 characters, falling back to the internal id.metaInfo.udf1= the internal order UUID, which is how a webhook resolves back to our order without trusting the merchant reference.order_payment.externalReference= PhonePe'sorderId(OMO…), the pin the verify and webhook paths check.
Web flow
-
Storefront calls
POST /store/checkout/place-orderwithpaymentProvider: "phonepe",paymentMethod: "phonepe". -
OrderServicecreates the order (pending_payment, inventory reserved). -
The provider calls
POST {pg}/checkout/v2/pay. -
The place-order response carries:
{ "pendingClientAction": { "provider": "phonepe", "payload": { "provider": "phonepe", "phonepeOrderId": "OMO123456789", "redirectUrl": "https://mercury-uat.phonepe.com/transact/…", "expireAt": 1703756259307, "amount": 125000 } } } -
The client sends the customer to
redirectUrl(or passes it astokenUrlto PhonePe's checkout script). -
PhonePe returns the customer to
redirect_url. That callback carries no payment status — onlyCONCLUDEDorUSER_CANCEL. The client then calls the verify endpoint.
Mobile flow
POST {pg}/checkout/v2/sdk/order instead, and the payload carries everything the SDK's two calls need:
{
"provider": "phonepe",
"phonepeOrderId": "OMO123456789", // startCheckoutPage(orderId)
"token": "…", // startCheckoutPage(token)
"merchantId": "MERCHANTUAT", // init(merchantId)
"environment": "SANDBOX", // init(phonePeEnvironment)
"flowId": "ord2026000001234abcd", // init(flowId)
"expireAt": 1703756259307,
"amount": 125000
}merchantId, environment and flowId are server-supplied deliberately: an app that hardcodes them eventually ships a sandbox build against production. flowId is strictly alphanumeric (the SDK rejects anything else) and derived from the order id so app-side journeys and PhonePe's SDK logs correlate.
The SDK's activity result is exactly as untrustworthy as the web redirect — it reports that the flow concluded, not what happened — so mobile calls the same verify endpoint.
Storefront: verify
POST /store/orders/:id/phonepe/verify
Called after PhonePe returns the customer, from web and mobile. Takes no body: PhonePe's client-side callback has no signature or status to forward, so the server reads the Order Status API itself.
- Loads the order; 404 if it isn't the caller's.
- 400 if
paymentProvider !== "phonepe". - Returns the current view if already
paid(the webhook won the race). - Otherwise reads
GET {pg}/checkout/v2/order/{merchantOrderId}/statusand reconciles.
Response 200 — OrderResponse after reconciliation.
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Not a PhonePe order, or the gateway order id doesn't match the place-time row |
| 400 | BAD_REQUEST | Amount mismatch between the gateway and the order total |
| 404 | NOT_FOUND | Order doesn't exist or belongs to another customer |
| 409 | PAYMENT_NOT_YET_CAPTURED | PhonePe still reports PENDING — retry shortly |
Safe to retry: idempotent against the webhook.
Webhook
See the webhook page for the full surface. In summary: POST /webhooks/payments/phonepe, authenticated by Authorization: SHA256(username:password), handling checkout.order.completed, checkout.order.failed, pg.refund.completed and pg.refund.failed.
The webhook body is never trusted for money. Every settlement re-reads the Order Status API and requires the root state to be COMPLETED and the amount to match the order total before marking anything paid.
Refunds
PhonePe implements the payment port's optional refund and getRefundState, so it is driven entirely through the provider-neutral admin surface — POST /admin/orders/:id/refunds and friends. See admin/order.md.
Gateway refunds are asynchronous: POST {pg}/payments/v2/refund returns PENDING, and the order's payment status moves only when pg.refund.completed arrives or the sync endpoint polls it. allow_refunds must be on, or the provider refuses and refund webhooks are acknowledged without effect.
Missed-webhook recovery
PhonePe's redirect carries no status, so a lost webhook is more likely than with an SDK handshake. Three paths converge on the same reconciliation:
- the verify endpoint, called by the client after checkout;
- the webhook, whenever it arrives;
- the stale-pending sweep, which calls the provider's
reconcilePendingbefore cancelling an order — so a paid order is never auto-cancelled because a webhook went missing. A gateway outage defers the cancel rather than performing it.
Kill switch
Removing PhonePePaymentModule.forRoot() and PhonePePaymentProvider from PaymentModule.forRoot disables it completely: the verify and webhook routes leave the OpenAPI surface, phonepe drops out of GET /store/checkout/payment-providers, and no other provider is affected.
Related modules
payment— the neutralPaymentProviderport andPaymentRegistry.order— reconciliation, the neutral refund surface, and thependingClientActionenvelope.webhooks— the shared idempotency inbox and delivery archive. See webhooks-inbox.md.payment-razorpay— the other gateway, for comparison.
Order Module
HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).
Payment — Razorpay Provider
Razorpay payment provider that plugs into the platform-neutral payment module. Exposes the SDK-bootstrap flow at place-order (via the PaymentProvider port), the customer-side…