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.
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 — Klaviyo (Marketing)
Public HTTP surface that Klaviyo's servers call to deliver webhook events. The handler verifies an HMAC signature over the raw body, dedups on Klaviyo's event id, and mirrors…
Razorpay Magic Checkout — Webhook & Callbacks
Server-side surface for Razorpay Magic Checkout: the completion webhook (prepaid payment.captured / COD payment.pending) plus the three modal callbacks (get/apply promotions, shipping serviceability), all HMAC-verified over the raw body.