Supercommerce API Docs
Full Module Docs

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 via PhonePePaymentModule.forRoot() in apps/api/src/app.module.ts).

Registers a PaymentProvider with id phonepe into the platform PaymentRegistry. The order module knows nothing about PhonePe specifically — it dispatches through the registry.

This is v2 / Standard Checkout, not the legacy v1 X-VERIFY SHA256-plus-salt scheme that most third-party write-ups still describe. The two are not interchangeable.


Conventions

Authentication

EndpointAuth
POST /store/orders/:id/phonepe/verifyrequired (customer session)
POST /webhooks/payments/phonepenoneAuthorization: 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.

KeyRequiredPurpose
client_idyesOAuth client id. Empty disables the provider
client_secretyesOAuth client secret. Secret
client_versionyes (default 1)Version credential issued with the client id
merchant_idyesMerchant 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
environmentyes (default sandbox)sandbox or production. Selects both hosts and the SDK's PhonePeEnvironment
webhook_usernamefor webhooksHalf of the webhook credential
webhook_passwordfor webhooksSecret
redirect_urlweb onlyWhere PhonePe returns the browser. Unused by the mobile SDK, so app-only installs may leave it empty
payment_expiry_secondsdefault 1200expireAfter; PhonePe accepts 300–3600
allow_refundsdefault falseGates gateway refunds and whether pg.refund.* webhooks are acted on

Hosts

OAuthPayment APIs
Sandboxapi-preprod.phonepe.com/apis/pg-sandbox/v1/oauth/tokenapi-preprod.phonepe.com/apis/pg-sandbox
Productionapi.phonepe.com/apis/identity-manager/v1/oauth/tokenapi.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 fieldValue
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's orderId (OMO…), the pin the verify and webhook paths check.

Web flow

  1. Storefront calls POST /store/checkout/place-order with paymentProvider: "phonepe", paymentMethod: "phonepe".

  2. OrderService creates the order (pending_payment, inventory reserved).

  3. The provider calls POST {pg}/checkout/v2/pay.

  4. The place-order response carries:

    {
      "pendingClientAction": {
        "provider": "phonepe",
        "payload": {
          "provider": "phonepe",
          "phonepeOrderId": "OMO123456789",
          "redirectUrl": "https://mercury-uat.phonepe.com/transact/…",
          "expireAt": 1703756259307,
          "amount": 125000
        }
      }
    }
  5. The client sends the customer to redirectUrl (or passes it as tokenUrl to PhonePe's checkout script).

  6. PhonePe returns the customer to redirect_url. That callback carries no payment status — only CONCLUDED or USER_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.

  1. Loads the order; 404 if it isn't the caller's.
  2. 400 if paymentProvider !== "phonepe".
  3. Returns the current view if already paid (the webhook won the race).
  4. Otherwise reads GET {pg}/checkout/v2/order/{merchantOrderId}/status and reconciles.

Response 200OrderResponse after reconciliation.

StatusCodeWhen
400BAD_REQUESTNot a PhonePe order, or the gateway order id doesn't match the place-time row
400BAD_REQUESTAmount mismatch between the gateway and the order total
404NOT_FOUNDOrder doesn't exist or belongs to another customer
409PAYMENT_NOT_YET_CAPTUREDPhonePe 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 reconcilePending before 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.


  • payment — the neutral PaymentProvider port and PaymentRegistry.
  • order — reconciliation, the neutral refund surface, and the pendingClientAction envelope.
  • webhooks — the shared idempotency inbox and delivery archive. See webhooks-inbox.md.
  • payment-razorpay — the other gateway, for comparison.

On this page