Taking payments
Customers pay you inside Shainee with Pay with Shainee. You never handle wallet details, card numbers or passwords: your server asks for a payment, Shainee shows its own payment sheet, the customer pays on CityPay Checkout (CityPay's own payment page), and your server checks the result. Sellers are paid out through CityWallet. Both are licensed Nepali payment services.
Every payment is held in escrow. The customer's money goes to Shainee first and waits there, split by seller, until you tell us to release it to the sellers (for example when the order is delivered) or refund it to the customer. Customers trust a new shop more when they know nobody is paid until they get what they ordered.
- The customer checks out on your page. Your server saves the order.
- Your server creates a payment for it on Shainee, with the amount, the customer's
suband each seller's share. - Shainee answers with a payment id.
- Your server hands the id to your page.
- Your page asks Shainee to take the payment.
- Shainee shows its own payment sheet with the amount and sellers from step 2, never from your page. When the customer taps Pay, Shainee opens CityPay's payment page, where they log in to CityPay and pay. Afterwards Shainee asks CityPay itself whether the payment went through.
- Shainee tells your page the sheet closed, and (if you have a webhook) tells your server.
- Your page asks your server to check the order.
- Your server asks Shainee what really happened. Only this answer decides whether the order is paid.
- Later, the money is released to the sellers, by your server or by Shainee staff.
Before your first payment: register your sellers#
A payment is split between sellers (we call them vendors). Register each one once, with the CityWallet number their money is paid out to. Registering again with the same ref updates the vendor; it is safe to do on every start of your server.
POST /api/partner/vendors with your client credentials (HTTP Basic, the same as the verify call):
{"ref": "ilam-tea", "name": "Ilam Tea House", "wallet_mobile": "9800000101"}
ref is your own id for the seller. Shainee checks the wallet exists before saying yes:
{"ref": "ilam-tea", "name": "Ilam Tea House", "wallet_mobile": "980*****01", "wallet_name": "Wallet 0101"}
A shop that sells its own goods registers a single vendor: itself.
1. Create the payment (your server)#
POST /api/partner/payments with your client credentials and an Idempotency-Key header:
{
"sub": "yyYFeygPWSKpCJE7wJNKOP01Dto8F3V5Arwstmi-M20",
"amount": "930.00",
"description": "Himal Mart order HM-71FAEB1E (2 items)",
"order_ref": "HM-71FAEB1E",
"splits": [
{"vendor": "ilam-tea", "amount": "650.00"},
{"vendor": "himal-salt", "amount": "280.00"}
]
}
| Field | Rule |
|---|---|
sub |
The customer, as your verify call told you. Only this customer can pay it |
amount |
Rupees as a string with two decimals. More than zero, at most the per-payment limit |
description |
What the customer sees on the payment sheet and in their history |
order_ref |
Your order id, so you can match the payment later |
splits |
One entry per vendor; the amounts must add up to amount |
Idempotency-Key header |
8 to 64 letters, digits or . _ : -, unique per order. Use your order id |
Work out the amounts on your server from your own prices. Never take a price from the page.
The answer is 201 with the new payment, or 200 with the same payment if you send the same Idempotency-Key again, so a retry after a timeout never charges twice. Reusing a key for a different amount answers 409 idempotency_conflict.
An unpaid payment expires after 30 minutes, unless the customer is still on CityPay's page or CityPay is still confirming it: a payment never expires while it may still be paid. Create a new one (with a new key) if the customer comes back later.
2. Ask Shainee to take it (your page)#
Your page sends one message and waits for one answer, over the same channel as sign-in (see the Browser-side contract):
| Direction | Message |
|---|---|
| Page to Shainee | {"type": "pay", "payment_id": "pay_qmo8uHYBAMq-ShlCKzdALg"} |
| Shainee to page | {"type": "payment_result", "payment_id": "pay_qmo8uHYBAMq-ShlCKzdALg", "status": "paid"} |
status in the answer is paid, processing (CityPay is still confirming), cancelled (the customer closed the sheet) or failed. It only tells your page the sheet has closed. Never mark an order paid because of this message: anything running in a browser can be faked. Ask your server instead.
The customer can close the sheet and pay later. Until the payment expires, you can send the same pay message again, for example from a "Pay now" button on the order. If an attempt on CityPay's page fails, the customer can simply try again from the sheet.
3. Check the result (your server)#
GET /api/partner/payments/{payment_id} with your client credentials. Check that order_ref and sub are the ones you expect (and, on your production server, that livemode is true), then read status and escrow:
{
"id": "pay_qmo8uHYBAMq-ShlCKzdALg",
"status": "paid",
"escrow": "held",
"amount": "930.00",
"order_ref": "HM-71FAEB1E",
"livemode": true,
"splits": [
{"vendor": "ilam-tea", "vendor_name": "Ilam Tea House", "amount": "650.00", "status": "held"},
{"vendor": "himal-salt", "vendor_name": "Himalayan Salt Co.", "amount": "280.00", "status": "held"}
]
}
The full answer has a few more fields; they are listed in the API reference.
status |
Meaning | What you do |
|---|---|---|
created |
Waiting for the customer | Show "Pay now" |
awaiting_payment |
The customer is on CityPay's payment page | Wait |
processing |
CityPay is still confirming the payment | Wait. Shainee keeps asking CityPay, and a webhook tells you how it ends |
paid |
The money is with Shainee | Fulfil the order. Read escrow |
failed |
The last attempt at CityPay did not go through. No money moved | Show "Pay now" again; the customer may retry |
cancelled, expired |
No money moved | Let the customer try again with a new payment |
escrow |
Meaning |
|---|---|
none |
Not paid yet, so nothing is held |
held |
Every split is held |
partially_held |
Some splits are paid out or refunded, others are still held |
processing |
A payout is under way at CityWallet |
released |
Every split was paid to its vendor |
refunded |
Every split went back to the customer |
settled |
Every split was paid out or refunded, some each way |
4. Release the money (your server)#
When the customer has what they paid for, release the money:
POST /api/partner/payments/{payment_id}/release
The body is empty ({}) to release every held split, or {"vendor": "ilam-tea"} for one vendor, for example when only part of an order arrives. The answer is the payment with its new escrow value. A split can be paid out only once: a second call answers 409 nothing_held. Each vendor's share goes to their CityWallet.
If CityWallet does not accept a payout, the answer is an error and the money stays held, so you can try again. Shainee staff can also release a payment from their side, for example once delivery is confirmed or to settle a dispute.
Refunds are not available yet#
POST /api/partner/payments/{payment_id}/refund exists, but it answers 409 refund_unavailable for now and the money stays held: CityPay Checkout does not tell Shainee which wallet the customer paid from, so there is nowhere to send it back yet. If an order is cancelled after payment, do not release it, and contact the Shainee team, who will settle it with the customer.
What the customer sees#
- The payment sheet, with your app's name, the amount, each seller and their share, and a note that the money is held until delivery.
- Pay with CityPay opens CityPay's own payment page, where they log in to CityPay and pay. Nothing to link or set up in Shainee first.
- Back in Shainee: "Paid", "CityPay is still confirming" (with a "check again" button), or "didn't go through" with a way to try again.
- In the app's Activity tab, every payment with its status ("Held until delivery", "Paid to seller") and a timeline of each step: requested, sent to CityPay, paid, released to each seller.
Tell your customers on your own order pages too, in the same words: "Paid, held by Shainee until delivery".
Webhooks (optional)#
Instead of waiting for your page, your server can be told. Give the Shainee team an HTTPS address on your server; you get a webhook signing secret once, and from then on a POST for each of these events:
| Event | When |
|---|---|
payment.paid |
The money is held in escrow |
payment.processing |
CityPay is still confirming |
payment.failed |
An attempt at CityPay did not go through |
payment.cancelled, payment.expired |
The payment will not be paid |
payment.released |
A vendor's share was paid out |
payment.refunded |
A share went back to the customer |
The body is the same payment object as GET /api/partner/payments/{payment_id}:
{
"id": "evt_8f3k2m9q",
"type": "payment.paid",
"created_at": "2026-10-19T08:25:10+00:00",
"livemode": true,
"data": {"payment": {"id": "pay_qmo8uHYBAMq-ShlCKzdALg", "status": "paid", "escrow": "held", "order_ref": "HM-71FAEB1E"}}
}
(The real payment has every field.) Each request carries SuperPreneur-Event, SuperPreneur-Event-Id and a signature header:
SuperPreneur-Signature: t=<unix seconds>,v1=<hex>
To check it, compute HMAC-SHA256 with your webhook secret over the text <t>.<raw body> (the timestamp, a dot, and the body exactly as received) and compare it with v1 in constant time. Refuse requests whose t is more than 5 minutes old.
Answer any 2xx quickly. Anything else is retried after 1, 5, 30, 120, 360 and 1440 minutes, with the same event id, so ignore an id you have already handled. A webhook only says something changed: before you ship, confirm with GET /api/partner/payments/{payment_id} as in step 3.
Test mode and live mode#
The platform runs in one of two modes, and every payment records which one in livemode:
| Mode | livemode |
Money |
|---|---|---|
| Test | false |
Nothing real moves. CityPay and CityWallet run with test accounts |
| Live | true |
Real money through CityPay and CityWallet |
Payments and vendors belong to the mode they were made in:
- A payment can be paid or released only in its own mode. Releasing in the other mode answers
409 mode_mismatch, and the money stays where it is. - Vendors registered in test mode are not accepted for live payments (
unknown_vendor, "register these vendors again"). Register every vendor again after the platform goes live, so their real wallets are checked. Registering on every start of your server does this for you.
On your production server, never ship real goods for a payment with livemode: false. To show a "test mode" banner, your page can ask Shainee's GET /api/config (not your own route of the same name; no credentials needed):
{"mode": "test", "test_mode": true}
If payments are switched off on the server, the payment routes answer 502 provider_not_configured. Nothing changes; try again later.
Testing payments#
Payments are not in the hosted sandbox yet. Ask the Shainee team for an app on a server in test mode:
- No real money moves. The payment sheet says it is in test mode.
- Customers pay on CityPay's page with a CityPay test account; the Shainee team gives you one.
- Vendors must be CityWallet test accounts too, so register the test numbers the team gives you.
Test at least: a paid order, a cancelled sheet followed by "Pay now", a failed attempt followed by a successful one, a retry of the create call with the same Idempotency-Key, a release, a partial release, a second release of the same payment (refused), a refund (refused for now with refund_unavailable), and, if you use them, your webhook's signature check.
Rules#
- Create payments only on your server, with your client secret. The page never sees the secret.
- Prices and splits come from your database, never from the page.
- Only
GET /api/partner/payments/{id}decides whether an order is paid. - Use your order id as the
Idempotency-Key, so retries are safe. - Release only when the customer has what they paid for. If they will not get it, do not release; contact the Shainee team until refunds are available.