Supercommerce API Docs
Webhooks

Webhook — PhonePe (Payments)

Public endpoint PhonePe calls to deliver payment and refund lifecycle events. Verified with SHA256(username:password), deduplicated on a composed event id, and never trusted for money.

Public endpoint PhonePe's servers call to deliver payment and refund lifecycle events. Authenticated with Authorization: SHA256(username:password), deduplicated through the shared webhook inbox, and — critically — never trusted for money: every settlement re-reads the Order Status API before touching order state.

Source: api-modules/payment-phonepe/src/controllers/phonepe-webhook.controller.ts


Authentication

PropertyValue
SchemeSHA256(username:password), hex
HeaderAuthorization
Secret sourceAdmin settings — payment.phonepe.webhook_username / webhook_password. Not environment variables
ComparisonConstant-time, after trimming and lowercasing

The credentials must match what you enter on the PhonePe dashboard, or every delivery is rejected.

This header is a static credential, not a per-request MAC over the body — unlike Razorpay's HMAC. It is therefore never logged and never persisted, including in the delivery archive, which records only signature_present: true. Treat it like a password.

The endpoint is public — no session, no API key. There is no IP allowlist.


Endpoint

POST /webhooks/payments/phonepe

Headers

NameRequiredNotes
AuthorizationyesSHA256(username:password) hex

Body — PhonePe's envelope is { event, payload }:

{
  "event": "checkout.order.completed",
  "payload": {
    "orderId": "OMO123456789",
    "merchantId": "MERCHANTUAT",
    "merchantOrderId": "ORD-2026-00000123",
    "state": "COMPLETED",
    "amount": 125000,
    "expireAt": 1703756259307,
    "metaInfo": { "udf1": "01J9…" },
    "paymentDetails": [
      {
        "transactionId": "TXN123",
        "paymentMode": "UPI_INTENT",
        "timestamp": 1703756259307,
        "amount": 125000,
        "state": "COMPLETED"
      }
    ]
  }
}

PhonePe's documentation is explicit that only the root-level payload.state is authoritative; per-attempt states inside paymentDetails are not. The internal order id is read from payload.metaInfo.udf1, stamped at place-time. When it is missing the event is acknowledged but not acted on.

Money fields are integer paise — the platform's own convention, so no scaling.

Response 200

{
  "data": { "accepted": true, "event": "checkout.order.completed", "handled": true },
  "message": "Success",
  "statusCode": 200
}
FieldMeaning
acceptedAlways true once authenticated — the ack PhonePe needs to stop retrying
eventEcho of event, or "unknown" when the body failed the schema check
handledtrue when the event drove a state change; false when deliberately ignored

PhonePe expects a 2xx within a few seconds, so the handler acks as soon as reconciliation completes and never blocks on archiving.

Event handling

EventAction
checkout.order.completedRe-reads Order Status, requires root state = COMPLETED and an exact amount match, then marks the order confirmed/paid, commits the reservation and emits order.paid
checkout.order.failedCancels a pending_payment order, recording errorCode / detailedErrorCode
pg.refund.completedResolves the payment_refund row by merchantRefundId and applies the refund, replaying the initiating admin as the actor. Ignored when allow_refunds is off
pg.refund.failedMarks the refund row failed and raises an ops alert. Order state is untouched — the customer has not been refunded
anything elseLogged and acked with handled: false

Idempotency

PhonePe ships no event id, so one is composed from the entity and its terminal state:

  • order events → `${payload.orderId}-${payload.state}` (e.g. OMO123-COMPLETED)
  • refund events → `${payload.refundId}-${payload.state}`

Retries of the same delivery collide on that key; a genuine later FAILED for the same entity does not. The key is claimed in the shared inbox (processed_webhook_event) before dispatch, and the outcome recorded after:

  • Replay → returns 200 with handled: false, no side effects.
  • Crash mid-dispatch → the row stays claimed, so PhonePe's own retry re-enters it rather than being skipped forever, and a stuck-row query surfaces it to ops.
  • Two simultaneous deliveries → the unique index makes one of them the replay.

Refunds have a second guard: the payment_refund transition is UPDATE … WHERE state = 'PENDING', so a replayed completion cannot refund twice even if dedup were bypassed.


Failure modes

StatusCodeWhen
400BAD_REQUESTreq.rawBody missing (Fastify content-type parser not registered)
401UNAUTHORIZEDAuthorization missing or not matching the configured credentials
500webhook_username / webhook_password not configured

A malformed or schema-rejected body does not raise: it returns 200 with event: "unknown", handled: false, and logs the reason — PhonePe would otherwise retry forever on a shape we cannot action.

Defence-in-depth rejections during settlement (order-id mismatch, amount mismatch) raise 400. PhonePe will retry those, but the mismatch will not resolve itself — persistent retries here mean a misconfigured merchant account or a cross-account delivery.


Archiving

Every delivery to this endpoint is archived for 90 days, including ones rejected for bad credentials — a forgery attempt is exactly what an operator needs to see. This is automatic via the shared /webhooks/* interceptor, with no per-provider work. See webhooks-inbox.md.


  • payment-phonepe.md — the full provider doc: configuration, place-order integration, verify, refunds.
  • order.mdpendingClientAction, and the order lifecycle the webhook drives.

On this page