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
| Property | Value |
|---|---|
| Scheme | SHA256(username:password), hex |
| Header | Authorization |
| Secret source | Admin settings — payment.phonepe.webhook_username / webhook_password. Not environment variables |
| Comparison | Constant-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
| Name | Required | Notes |
|---|---|---|
Authorization | yes | SHA256(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.
The payment attempt is read off payload.merchantOrderId — the reference we sent, echoed back — so a late webhook for an earlier attempt still reconciles against that attempt rather than the order's current one. A reference with no -R<n> suffix reads as attempt 1.
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
}| Field | Meaning |
|---|---|
accepted | Always true once authenticated — the ack PhonePe needs to stop retrying |
event | Echo of event, or "unknown" when the body failed the schema check |
handled | true 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
| Event | Action |
|---|---|
checkout.order.completed | Re-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.failed | Cancels a pending_payment order, recording errorCode / detailedErrorCode |
pg.refund.completed | Resolves 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.failed | Marks the refund row failed and raises an ops alert. Order state is untouched — the customer has not been refunded |
| anything else | Logged 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
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | req.rawBody missing (Fastify content-type parser not registered) |
| 401 | UNAUTHORIZED | Authorization missing or not matching the configured credentials |
| 500 | — | webhook_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.
Related
- payment-phonepe.md — the full provider doc: configuration, place-order integration, verify, refunds.
- order.md —
pendingClientAction, and the order lifecycle the webhook drives.
Webhook — CCAvenue (Payments)
The three public CCAvenue ingest surfaces: the browser return and cancel handlers that 302 the shopper onward, and the server-to-server dynamic event notification. None is trusted for money.
Razorpay Magic Checkout — Webhook & Callbacks
Server-side surface for Razorpay Magic Checkout: the completion webhook (prepaid payment.captured / COD payment.pending, HMAC-verified over the raw body) plus the three unsigned, read-only modal callbacks (get/apply promotions, shipping serviceability).