Skip to content

Webhooks

Webhooks. Signed events for payments and refunds.

ZopPay sends an HTTPS POST to your endpoint when a payment is paid or fails, and when a refund succeeds or fails. Each delivery is signed with your endpoint’s secret and tried up to six times.

Set up

One page in the dashboard. No code to register an endpoint.

01

Add an endpoint

In the dashboard, open Webhooks, enter your address and pick the events it should get. The address must be https:// on a public host; private and internal addresses are refused.

02

Keep the signing secret

Each endpoint has its own secret, starting whsec_. It is shown when you save the endpoint. Later, Reveal shows it again, and that is logged.

03

Pause or delete

Pause an endpoint to stop new events for it; deliveries already waiting go out when you resume it. You can have up to 10 endpoints, paused ones included.

04

Test or live

Each endpoint has a mode, like an API key: a test endpoint gets only test payments’ events and a live one only live. Add one of each to get both, or switch an endpoint’s mode in the dashboard (its secret stays the same). Every payment and refund event carries data.mode.

A delivery

Headers and body.

Every event is a POST with a JSON body. The body is the same envelope for every event: id, type, created_at, organisation_id and data.

What is in data depends on what noticed the change: the checkout, the gateway’s own notice or ZopPay’s status check. Payment events always have payment_id and refund events always have refund_id. Most also carry the amount, currency and gateway ids; failures add failure_code and failure_message.

So use the event as a signal and the id to fetch the current state with GET /v1/payments/{id} or GET /v1/refunds/{id}.

X-PP-Delivery-ID stays the same across retries of one delivery. The event id is the same for every endpoint that gets the event.

Headers
POST /webhooks/zoppay HTTP/1.1
Content-Type: application/json
User-Agent: payment-platform-webhooks/1.0
X-PP-Event: payment.captured
X-PP-Delivery-ID: whd_01JD3VC5B2D4F6H8K1M3P5R7T9
X-PP-Timestamp: 2026-10-19T10:21:04Z
X-PP-Signature-V2: sha256=<64 hex characters>
X-PP-Signature: sha256=<64 hex characters>
Body of a test payment.captured
{
  "id": "whe_01JD3VC4R7T9W2Y5A8C1E3G6J9",
  "type": "payment.captured",
  "created_at": "2026-10-19T10:21:03.512Z",
  "organisation_id": "org_01JD2Q0X8M4N6P1R3S5T7V9W2Y",
  "data": {
    "amount": 49900,
    "currency": "INR",
    "gateway_transaction_id": "test_txn_pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA_1760869263512345678",
    "mode": "test",
    "order_id": "ord_01JD3V8Q2K5N7Q9S1V3W5Y7A0C",
    "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA",
    "reference": "order_1042"
  }
}
data of a payment.failed after the link expired
"data": {
  "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA",
  "order_id": "ord_01JD3V8Q2K5N7Q9S1V3W5Y7A0C",
  "amount": 49900,
  "currency": "INR",
  "mode": "test",
  "failure_code": "session_expired",
  "failure_message": "Checkout session expired before payment",
  "source": "status-poll"
}

Events

Four events. Choose them per endpoint.

Webhook events
EventWhen it is sentAlways in data
payment.capturedThe payment was paid. Also sent if a payment first marked failed turns out to be paid.payment_id
payment.failedThe payment ended unpaid: the gateway reported a failure or cancellation, or the checkout link expired.payment_id
refund.succeededThe gateway confirmed the refund.refund_id
refund.failedThe gateway refused the refund, or reported it failed.refund_id

Verify

Check the signature first.

  1. 01Read the raw body as bytes. Don’t parse the JSON and write it out again before checking.
  2. 02Work out HMAC-SHA256 of the X-PP-Timestamp value, a full stop and the raw body, keyed with the whole signing secret (whsec_ included). Write it as lowercase hex after sha256=.
  3. 03Compare it with X-PP-Signature-V2 using a constant-time comparison.
  4. 04Reject the request if the timestamp is more than 5 minutes from your clock. Each attempt is signed with a new timestamp, so retries pass.

The older header

X-PP-Signature signs the raw body only, without the timestamp. It is still sent so older receivers keep working. Use X-PP-Signature-V2.
Receive and verify a webhook
# Send a correctly signed test event to your own receiver, the way ZopPay signs it.
SECRET='whsec_…'   # your endpoint's signing secret: the whole value, whsec_ included
URL='http://localhost:3000/webhooks/zoppay'
BODY='{"id":"whe_01JD3VC4R7T9W2Y5A8C1E3G6J9","type":"payment.captured","created_at":"2026-10-19T10:21:03.512Z","organisation_id":"org_01JD2Q0X8M4N6P1R3S5T7V9W2Y","data":{"amount":49900,"currency":"INR","gateway_transaction_id":"test_txn_pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA_1760869263512345678","mode":"test","order_id":"ord_01JD3V8Q2K5N7Q9S1V3W5Y7A0C","payment_id":"pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA","reference":"order_1042"}}'
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)

# X-PP-Signature-V2 = "sha256=" + hex(HMAC-SHA256(secret, timestamp + "." + raw body))
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"

curl -i "$URL" \
  -H "Content-Type: application/json" \
  -H "X-PP-Event: payment.captured" \
  -H "X-PP-Timestamp: $TS" \
  -H "X-PP-Signature-V2: $SIG" \
  -d "$BODY"

Each tab is a complete receiver on port 3000. The cURL tab sends a correctly signed test event to one of them.

Signature tester

Check a signature. Without writing code.

Paste a secret, a timestamp and a raw body, and compare the result with the header you received.

The whole value, whsec_ included. Use a test endpoint’s secret, not a live one.

Byte for byte, as received. JSON that has been parsed and written out again won’t match.

Expected signature
Working it out…

"sha256=" + hex(HMAC-SHA256(secret, timestamp + "." + body))

Signature
Paste one to compare
Timestamp
Not readable

A match means your server should accept the delivery. The 5-minute window is a check you add on your side, so an old request can’t be replayed.

Nothing leaves your browser. The signature is worked out on this page with the Web Crypto API.

Retries

Six attempts. Over about 15 hours.

An attempt counts as delivered when your endpoint answers any 2xx within 10 seconds. Any other status, a timeout or a redirect counts as failed. Redirects are not followed.

After a failed attempt the next one waits as shown. Retries are picked up by a job that runs every minute, so they can be up to a minute later. After the sixth failure the delivery is marked dead and isn’t sent again.

There is no resend button and no replay endpoint. To catch up after an outage, fetch the payments and refunds you care about.

Webhook retry schedule
AttemptWaits after the last oneFrom the first
1Straight away0
21 minute1 min
35 minutes6 min
430 minutes36 min
52 hours2 h 36 min
612 hours14 h 36 min

In the dashboard

Recent deliveries.

The Webhooks page lists your 50 most recent deliveries with the event, the status, the number of attempts, the last response code and when the last attempt was made.

After 30 days a finished delivery keeps only its ids, amount, currency and status; the rest of its body is removed.

Webhooks / Recent deliveries
Sample data
Example of the recent deliveries list
EventStatusAttemptsResponseLast attempt
payment.capturedsucceeded12002 min ago
payment.failedpending25034 min ago
refund.succeededsucceeded120018 min ago
payment.capturedsucceeded32041 h ago
payment.captureddead65003 h ago

Good habits

What a solid receiver does.

01

Answer fast, work later

Check the signature, answer 2xx, then queue the work. ZopPay waits 10 seconds; a slow handler times out and the event is sent again.

02

Expect repeats

Delivery is at least once. Keep the event id, or the payment or refund id with the event type, and skip what you have already handled.

03

Check every request

Reject anything without a valid X-PP-Signature-V2 and anything older than 5 minutes.

04

Fetch when in doubt

Events can arrive late or out of order. GET the payment or refund for its current status.

Ready to receive events?

Add an endpoint under Webhooks in the dashboard and send a test payment to see the first delivery.