The API is still being designed. Names and fields below show its intended shape, and we confirm the exact surface with you during onboarding. Questions at any step: support@zonipay.com.
01Get your keys
Your dashboard has two sets of keys. Build everything with the test key first; it runs against the sandbox and moves no money.
- zp_test_...
- Sandbox. Test cards only. Events go to your test webhook endpoint.
- zp_live_...
- Production. Available once your account has passed review.
- Signing secret
- One per webhook endpoint, shown when you add the endpoint. Used in step 5.
Keep secret keys on your server, in environment variables, out of source control and never in browser code. If a key leaks, roll it from the dashboard; the old one stops working.
ZONIPAY_SECRET_KEY=zp_test_...
ZONIPAY_WEBHOOK_SECRET=...
02Create a checkout session
When the buyer clicks checkout on your store, your server creates a session with the cart. Amounts are integers in the smallest unit of your account currency, so 4800 is 48.00.
import Zonipay from "@zonipay/node";
const zonipay = new Zonipay(process.env.ZONIPAY_SECRET_KEY);
const session = await zonipay.checkoutSessions.create({
line_items: cart.items.map((i) => ({
name: i.title, // "Reference peptide, 5 mg vial"
unit_amount: i.priceCents, // 4800
quantity: i.qty,
})),
customer_email: cart.email,
success_url: "https://yourstore.com/thanks?order=1042",
cancel_url: "https://yourstore.com/cart",
metadata: { order_id: "1042" },
});
// session = { id: "cs_test_8Hq2...", url: "https://checkout.yourstore.com/c/cs_test_8Hq2..." }
- line_items
- Name, unit amount and quantity for each product. Use the product names shown on your storefront.
- customer_email
- Prefills the checkout. The receipt from Zonipay goes to this address.
- success_url
- Where the buyer returns after paying.
- cancel_url
- Where the buyer returns if they leave the checkout.
- metadata
- Your own references. Returned on every related event.
Save the session id against your pending order. You will match it when the webhook arrives.
03Redirect the buyer
Send the buyer to session.url. The hosted checkout runs on your checkout subdomain with your branding, collects the card or wallet payment, runs 3-D Secure when the issuer asks for it, and requires the research-use attestation before the pay button works.
app.post("/checkout", async (req, res) => {
const session = await createSessionFor(req.cart); // step 2
res.redirect(303, session.url);
});
Do not mark the order paid when the buyer reaches success_url. A buyer can close the tab before returning, and anyone can type the URL. The webhook in step 4 is the record.
04Handle webhooks
Add an HTTPS endpoint in your dashboard and choose the events it receives. Each event is a JSON body with an id, a type and a data object.
{
"id": "evt_3kP9...",
"type": "order.paid",
"created": 1767225600,
"data": {
"checkout_session": "cs_test_8Hq2...",
"amount_total": 9600,
"metadata": { "order_id": "1042" }
}
}
import express from "express";
import { verifySignature } from "./verify.js"; // step 5
// Raw body: the signature covers the exact bytes we sent
app.post("/webhooks/zonipay", express.raw({ type: "application/json" }), async (req, res) => {
const ok = verifySignature(
req.body.toString("utf8"),
req.get("Zonipay-Signature") || "",
process.env.ZONIPAY_WEBHOOK_SECRET
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (await alreadyHandled(event.id)) return res.sendStatus(200);
switch (event.type) {
case "order.paid": await markPaid(event.data.metadata.order_id); break;
case "order.refunded": await markRefunded(event.data); break;
case "dispute.opened": await flagForEvidence(event.data); break;
case "compliance.finding": await notifyStoreEditor(event.data); break;
}
res.sendStatus(200);
});
Reply with a 2xx as soon as the event is stored, and do slow work afterwards. If your endpoint fails or times out, the event is sent again, so the same event can arrive more than once: record each id you have handled and skip repeats. The six event types are listed on the developers page.
05Verify the signature
Every request carries a header in this form:
Zonipay-Signature: t=1767225600,v1=5f2b9c...
v1 is the hex HMAC-SHA256, keyed with your endpoint's signing secret, of the string made of t, a full stop, and the raw body. Recompute it, compare in constant time, and reject timestamps outside a few minutes to stop replays.
import crypto from "node:crypto";
export function verifySignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("="))
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
06Fulfil and add tracking
Ship only after order.paid. Then add the carrier and tracking number to the order in your dashboard; on WooCommerce, the plugin can pass them on when you mark the order shipped.
Tracking matters beyond the buyer's convenience. If a buyer disputes a charge, we answer the chargeback as merchant of record and ask you for tracking and proof of delivery, to be sent within 5 business days of our request. The research-use attestation record is attached by us. Orders with tracking already on file are the easiest to defend.
Refunds you issue from the dashboard trigger order.refunded, so your system stays in step. Our buyer refund standard is in the refund policy.
07Go-live checklist
Work through this list before switching to your live key. The boxes are for your own tracking and are not saved.
Then open the store to your customers. Storefront scans continue every 8 hours; see compliance for what they check. If anything in this guide does not match what you see, write to support@zonipay.com.