API reference
The /v1 API. Payments, refunds and saved cards.
JSON over HTTPS with a secret key. This page covers every endpoint, field, status code and error message the API has today.
Authentication
Send your secret key as a Bearer token on every request. Call the API from your server. It doesn’t allow calls from web pages (there is no CORS), and the key must never reach a browser.
- Create keys under API keys in the dashboard. You give each key a name and a mode. The full secret is shown once; ZopPay keeps only a hash of it and shows the last four characters. Revoked keys stop working straight away.
sk_test_…keys send every payment through the built-in test gateway. No real money moves.sk_live_…keys use the live gateway that is turned on for the payment’s currency.- Test and live are kept apart. A key only sees payments, refunds and cards of its own mode; asking for one of the other mode gets the same answer as asking for one that doesn’t exist.
A wrong key answers 401. A suspended account answers 403 on every call, with a body of a different shape: error is a string and code is organisation_suspended.
Authorization: Bearer sk_test_… # On every POST as well (without it the body is ignored): Content-Type: application/json
HTTP/1.1 403 Forbidden { "error": "this organisation is suspended; contact support", "code": "organisation_suspended" }
Requests and responses
- Send bodies as JSON with
Content-Type: application/json. A body sent with any other content type is ignored, so the request fails as if the fields were missing. - Every successful answer wraps its result in
data. Creating something answers201 Created, reading answers200 OKand deleting answers204 No Contentwith no body. - Amounts are whole numbers in the currency’s smallest unit: paise for INR, cents for USD, halalas for SAR.
49900in INR is ₹499.00. - Currencies are three-letter ISO 4217 codes. Any case is accepted; answers use capitals.
- Ids start with their type:
pay_,rfd_,card_. Times are RFC 3339 in UTC, such as2026-10-19T10:21:03Z. - The API has no list endpoints for payments or refunds, so there is no paging. Lists and CSV exports are in the dashboard.
HTTP/1.1 201 Created Content-Type: application/json { "data": { "id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "status": "pending", … } }
Errors
Errors use the HTTP status code and a body with one readable message. The messages below are the API’s own wording.
One exception to note: POST /v1/refunds for a payment it can’t find answers 400 with payment not found, not 404.
HTTP/1.1 400 Bad Request { "error": { "message": "return_url must use https" } }
| Status | When | Example messages |
|---|---|---|
| 400 | The request is invalid, or can’t be carried out as sent. | amount must be > 0 · return_url must use https · no active live gateway for INR; connect one in the dashboard first · another refund is already in-flight for this payment |
| 401 | The key is missing, not a Bearer token, or not a valid key. | missing auth header in key 'Authorization' · unknown API key format · invalid API key |
| 403 | A live payment before your account is live, or a suspended account. | live payments are unavailable until this organisation goes live: accept the terms, … |
| 404 | No such payment, refund or card for this key’s account and mode. | No entity found with payment: pay_… |
| 409 | A request with the same Idempotency-Key is still running. | a request with this Idempotency-Key is still being processed; retry shortly |
| 422 | The Idempotency-Key was used with a different body, or the gateway refused a refund. | the gateway rejected the refund: … |
| 429 | Too many requests. | rate limit exceeded (300/min) |
| 500 | Something failed on our side. The details stay in our logs. | internal error; please retry, and contact support if it persists |
| 503 | The account’s status couldn’t be checked. Retry after 5 seconds. | temporarily unavailable; please retry |
Idempotency
Creating a payment and creating a refund accept an optional Idempotency-Key header. If a request times out, send it again with the same key and the same body: you get the first answer back instead of a second payment or refund.
- A key is kept for 24 hours, separately for each endpoint and each mode.
- The key is tied to the exact request body. The same key with a different body answers
422; while the first request is still running, a repeat answers409. - If the first request ended in an error, nothing is kept and a retry runs again.
- Use an id your own system already has, such as the order id for a payment and your refund id for a refund.
Creating a payment also checks your reference; see below.
Idempotency-Key: order_1042
Rate limits
Each API key can make 300 requests a minute. Each IP address can make 1,200 requests a minute to /v1, counted before the key is checked. Over either limit the answer is 429 Too Many Requests with Retry-After: 60.
HTTP/1.1 429 Too Many Requests Retry-After: 60 {"error": {"message": "rate limit exceeded (300/min)"}}
Create a payment
POST/v1/payments
Creates an order and a payment, and returns a checkout_url. Send the shopper’s browser there. The link works for 30 minutes; use it exactly as returned.
amountintegerrequired- In the currency’s smallest unit. More than 0 and at most 100,000,000,000.
currencystringrequired- Three-letter ISO 4217 code. For a live key, a live gateway for this currency must be turned on in your dashboard.
referencestringrequired- Your order id, up to 255 characters. Kept separately for test and live.
return_urlstringrequired- Where the shopper comes back after the checkout. A full
https://address. customerobject- Optional
name,emailandphone, passed on to the gateway. metadataobject- Optional JSON object, stored with the order. It is not returned by the API or sent in webhooks, so match on reference and payment id.
- Same reference again. With the same amount and currency you get the existing payment back while it is pending or paid. If that payment failed, or its link expired, a new payment is made for the same order, with a new link if the old one had expired. A different amount or currency answers
400. - Live keys. The payment goes to the gateway that is turned on for its currency. Before your account is live it answers
403and lists what is missing. - Gateway trouble. If the gateway won’t start the payment, the answer is
500and the payment is marked failed, so a retry with the same reference makes a new one.
A full request body
{ "amount": 49900, "currency": "INR", "reference": "order_1042", "return_url": "https://shop.example.com/orders/1042/complete", "customer": { "name": "Asha Rao", "email": "asha@example.com", "phone": "+919876543210" }, "metadata": { "cart_id": "cart_88213" } }
# Create a payment. Needs: export ZOPPAY_SECRET_KEY=sk_test_… curl https://api.zoppay.in/v1/payments \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_1042" \ -d '{ "amount": 49900, "currency": "INR", "reference": "order_1042", "return_url": "https://shop.example.com/orders/1042/complete", "customer": { "name": "Asha Rao", "email": "asha@example.com", "phone": "+919876543210" } }' # The answer is 201 Created, with the payment under "data". # Send the shopper's browser to data.checkout_url.
HTTP/1.1 201 Created { "data": { "id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "status": "pending", "amount": 49900, "currency": "INR", "reference": "order_1042", "checkout_url": "https://checkout.zoppay.in/q3l7vz2kbx6ndw4rj5tmhs2pyc7ga4fe6uon3wia" } }
After the checkout
When the shopper finishes, the browser goes to your return_url with these added to the query string. Any query string you put in return_url is kept.
payment_idpay_…- The payment.
statuscaptured | failed | pending- What the checkout saw. pending means the gateway hasn’t confirmed yet.
pp_outcomepayment-success | payment-failure- For older mobile apps. Ignore it.
Treat these as a hint only. Confirm the payment from your server with GET /v1/payments/{id} or a webhook before you ship.
# After the checkout, the shopper's browser comes back to your return_url # with the payment id and status added to the query string: https://shop.example.com/orders/1042/complete?payment_id=pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA&pp_outcome=payment-success&status=captured # status is captured, failed or pending. Anyone can type this URL, # so confirm the payment from your server before you ship the order.
Get a payment
GET/v1/payments/{id}
Returns one payment of your key’s mode. Use it to confirm a payment and to catch up if you missed a webhook. A payment of the other mode answers 404.
idstring- The payment id.
statusstring- See Statuses.
capturedmeans paid. amountinteger- In the smallest unit.
amount_refundedinteger- The total of refunds that succeeded. Pending refunds are not counted.
currencystring- Three-letter code.
referencestring- Your order reference.
modestringtestorlive.gateway_transaction_idstring- The gateway’s own id. Left out until there is one.
created_at, updated_atstring- RFC 3339, UTC.
# Get a payment. Use the data.id that create returned. curl https://api.zoppay.in/v1/payments/pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY" # The answer is 200 OK, with the payment under "data". # Fulfil the order when data.status is "captured".
HTTP/1.1 200 OK { "data": { "id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "status": "captured", "amount": 49900, "amount_refunded": 10000, "currency": "INR", "reference": "order_1042", "mode": "live", "gateway_transaction_id": "pay_PJ8sYb2Lq0M3xT", "created_at": "2026-10-19T10:20:11Z", "updated_at": "2026-10-19T10:21:03Z" } }
Create a refund
POST/v1/refunds
Refunds all or part of a paid payment. The refund goes back through the gateway account that took the payment, even if you have switched gateways since.
payment_idstringrequired- The payment to refund.
amountinteger- Optional. Leave it out (or send 0) to refund everything not yet refunded. Otherwise at most what is left.
reasonstring- Optional free text, kept with the refund.
- The payment must be paid (
captured). One refund at a time per payment: a second while one is pending answers400. succeededmeans the gateway confirmed it straight away.pendingmeans the result comes later, as arefund.succeededorrefund.failedwebhook.- If the gateway refuses, the answer is
422with the gateway’s reason, the refund is recorded as failed andrefund.failedis sent. Fix the cause and refund again. - TechProcess connections can’t refund through ZopPay; refund those from TechProcess’s own panel.
Request body
{ "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "amount": 10000, "reason": "Item returned" }
# Refund ₹100.00 of a payment. Leave out "amount" to refund everything that is left. curl https://api.zoppay.in/v1/refunds \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: refund_order_1042_1" \ -d '{ "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "amount": 10000, "reason": "Item returned" }' # The answer is 201 Created, with the refund under "data". # data.status is "succeeded", or "pending" until the gateway confirms.
HTTP/1.1 201 Created { "data": { "id": "rfd_01JD3VB2XH8N4P6Q9S3T5V7W1Y", "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "amount": 10000, "currency": "INR", "status": "pending", "reason": "Item returned", "created_at": "2026-10-19T11:02:45Z" } }
Get a refund
GET/v1/refunds/{id}
Returns one refund whose payment is of your key’s mode; otherwise 404. It has the same fields as the create answer, plus gateway_refund_id once the gateway has given one and completed_at once it has finished.
curl https://api.zoppay.in/v1/refunds/rfd_01JD3VB2XH8N4P6Q9S3T5V7W1Y \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY"
HTTP/1.1 200 OK { "data": { "id": "rfd_01JD3VB2XH8N4P6Q9S3T5V7W1Y", "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA", "amount": 10000, "currency": "INR", "status": "succeeded", "reason": "Item returned", "gateway_refund_id": "rfnd_PJ9Lw0cK2xQb7R", "created_at": "2026-10-19T11:02:45Z", "completed_at": "2026-10-19T11:04:10Z" } }
Saved cards
Stores a card that a shopper registered with a gateway that supports saved cards. Today that is HyperPay only. Your own app runs HyperPay’s card registration; you then send its id here and ZopPay keeps the gateway’s token with the card brand, the first digits (BIN), the last four digits and the expiry date.
POST
/v1/cardsBody:
customer_reference,gateway_slug(hyperpay),currencyandregistration_context, all required, plus an optionalis_debit. Your HyperPay connection must be the one turned on for that currency and mode.GET
/v1/cards?customer_reference=…Lists that customer’s cards in your key’s mode, under
data.DELETE
/v1/cards/{id}Removes the card, and asks the gateway to delete its token. Answers
204.
POST /v1/payments isn’t available. Talk to us before you build on saved cards.curl https://api.zoppay.in/v1/cards \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "customer_reference": "cust_42", "gateway_slug": "hyperpay", "currency": "SAR", "registration_context": "<the checkout id of a HyperPay card registration>" }'
curl "https://api.zoppay.in/v1/cards?customer_reference=cust_42" \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY"
{ "data": [ { "id": "card_01JD3VE6K8M2P4R6T8V2X4Z6B8", "customer_reference": "cust_42", "gateway_slug": "hyperpay", "card_brand": "VISA", "card_last4": "4242", "card_bin": "424242", "expiry_month": 12, "expiry_year": 2029, "is_debit": false, "status": "active", "created_at": "2026-10-19T10:21:03Z" } ] }
curl -X DELETE https://api.zoppay.in/v1/cards/card_01JD3VE6K8M2P4R6T8V2X4Z6B8 \ -H "Authorization: Bearer $ZOPPAY_SECRET_KEY" # 204 No Content, with no body
Statuses
Payment
- pending
- Created; the shopper hasn’t paid yet, or the gateway hasn’t confirmed.
- captured
- Paid. Safe to fulfil the order.
- failed
- Not paid: the gateway said it failed, or the checkout link expired. If the gateway later shows it was paid, it becomes captured and payment.captured is sent.
Refund
- pending
- Sent to the gateway; the result comes later.
- succeeded
- The money is on its way back to the shopper.
- failed
- The gateway refused it, or it couldn’t be sent.
Payments and refunds carried over from older ZopPay accounts can also show reconciled. Treat it like captured or succeeded. Events and signatures are in the webhooks guide.
Questions about a call?
Send us the request you made and the response you got, and we will help.