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.
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>
{ "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": { "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.
| Event | When it is sent | Always in data |
|---|---|---|
| payment.captured | The payment was paid. Also sent if a payment first marked failed turns out to be paid. | payment_id |
| payment.failed | The payment ended unpaid: the gateway reported a failure or cancellation, or the checkout link expired. | payment_id |
| refund.succeeded | The gateway confirmed the refund. | refund_id |
| refund.failed | The gateway refused the refund, or reported it failed. | refund_id |
Verify
Check the signature first.
- 01Read the raw body as bytes. Don’t parse the JSON and write it out again before checking.
- 02Work out HMAC-SHA256 of the
X-PP-Timestampvalue, a full stop and the raw body, keyed with the whole signing secret (whsec_included). Write it as lowercase hex aftersha256=. - 03Compare it with
X-PP-Signature-V2using a constant-time comparison. - 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.# 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.
"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.
| Attempt | Waits after the last one | From the first |
|---|---|---|
| 1 | Straight away | 0 |
| 2 | 1 minute | 1 min |
| 3 | 5 minutes | 6 min |
| 4 | 30 minutes | 36 min |
| 5 | 2 hours | 2 h 36 min |
| 6 | 12 hours | 14 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.
| Event | Status | Attempts | Response | Last attempt |
|---|---|---|---|---|
| payment.captured | succeeded | 1 | 200 | 2 min ago |
| payment.failed | pending | 2 | 503 | 4 min ago |
| refund.succeeded | succeeded | 1 | 200 | 18 min ago |
| payment.captured | succeeded | 3 | 204 | 1 h ago |
| payment.captured | dead | 6 | 500 | 3 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.