Skip to content

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.

Base URL, for test and live keyshttps://api.zoppay.in

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.

Headers
Authorization: Bearer sk_test_…

# On every POST as well (without it the body is ignored):
Content-Type: application/json
A suspended account
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 answers 201 Created, reading answers 200 OK and deleting answers 204 No Content with no body.
  • Amounts are whole numbers in the currency’s smallest unit: paise for INR, cents for USD, halalas for SAR. 49900 in 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 as 2026-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.
Every success
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.

Every error
HTTP/1.1 400 Bad Request

{
  "error": {
    "message": "return_url must use https"
  }
}
Status codes and example messages
StatusWhenExample messages
400The 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
401The 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
403A live payment before your account is live, or a suspended account.live payments are unavailable until this organisation goes live: accept the terms, …
404No such payment, refund or card for this key’s account and mode.No entity found with payment: pay_…
409A request with the same Idempotency-Key is still running.a request with this Idempotency-Key is still being processed; retry shortly
422The Idempotency-Key was used with a different body, or the gateway refused a refund.the gateway rejected the refund: …
429Too many requests.rate limit exceeded (300/min)
500Something failed on our side. The details stay in our logs.internal error; please retry, and contact support if it persists
503The 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 answers 409.
  • 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.

Header
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.

Over the limit
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, email and phone, 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 403 and lists what is missing.
  • Gateway trouble. If the gateway won’t start the payment, the answer is 500 and the payment is marked failed, so a retry with the same reference makes a new one.
A full request body
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"
  }
}
POST /v1/payments
# 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.
Response
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.

The return URL
# 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. captured means 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.
modestring
test or live.
gateway_transaction_idstring
The gateway’s own id. Left out until there is one.
created_at, updated_atstring
RFC 3339, UTC.
GET /v1/payments/{id}
# 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".
Response
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 answers 400.
  • succeeded means the gateway confirmed it straight away. pending means the result comes later, as a refund.succeeded or refund.failed webhook.
  • If the gateway refuses, the answer is 422 with the gateway’s reason, the refund is recorded as failed and refund.failed is sent. Fix the cause and refund again.
  • TechProcess connections can’t refund through ZopPay; refund those from TechProcess’s own panel.
Request body
Request body
{
  "payment_id": "pay_01JD3V8Q4M6Y2C9T7K5W1R0BNA",
  "amount": 10000,
  "reason": "Item returned"
}
POST /v1/refunds
# 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.
Response
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
curl https://api.zoppay.in/v1/refunds/rfd_01JD3VB2XH8N4P6Q9S3T5V7W1Y \
  -H "Authorization: Bearer $ZOPPAY_SECRET_KEY"
Response
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/cards

    Body: customer_reference, gateway_slug (hyperpay), currency and registration_context, all required, plus an optional is_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.

Charging a saved card through POST /v1/payments isn’t available. Talk to us before you build on saved cards.
POST /v1/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>"
  }'
GET /v1/cards
curl "https://api.zoppay.in/v1/cards?customer_reference=cust_42" \
  -H "Authorization: Bearer $ZOPPAY_SECRET_KEY"
Response
{
  "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"
    }
  ]
}
DELETE /v1/cards/{id}
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.