For POS engineers + kiosk integrators
Balaji PayIntegration spec
Add Balaji Pay as a payment option on a kiosk POS in two endpoints — one outbound POST, one signed callback. We handle the underlying processor (card via hosted checkout, carrier billing, regional methods), so you only ever integrate against this contract.
00TL;DR
When a customer chooses Balaji Pay on your kiosk, your POS calls POST /payment/create with the order details (form-urlencoded) and three MD5-derived authentication headers. We return a customer-facing cashier_url; you redirect the customer to it. After the customer pays we POST a signed notification to a callback URL you provided per-checkout in the original request. You verify the MD5, reply SUCCESS, and you're done.
The integration is processor-agnostic. We route per-merchant to whichever processor the merchant has configured — card via hosted checkout today, carrier billing and regional methods soon — and you never touch any of that.
| You build | You receive (during onboarding) |
|---|---|
| One outbound HTTPS POST per checkout | appkey + appsecret pair (one per environment), delivered via the credential handoff portal |
| One inbound HTTPS callback endpoint per checkout | Sandbox alias for end-to-end testing before go-live |
| Constant-time MD5 verification on inbound notifications | Test kiosk binding (the seeded sandbox device for the playground) |
01Authentication
Every request to /payment/* carries three headers that prove you hold the matching appsecret without putting it on the wire:
appkey: <your_appkey> random: <fresh_nonce> // 32-char hex recommended signature: md5(appkey + random + appsecret) // lowercase hex Content-Type: application/x-www-form-urlencoded
The randomnonce should be fresh per request. We don't enforce a replay window server-side at v1, but a varying nonce means observed requests aren't trivially replayable, and it makes the signature non-deterministic across calls (handy for log forensics). Keys are issued one per environment (sandbox + production) and rotated on request — no integration changes when we rotate, you just receive a new value via the credential handoff portal.
timingSafeEqual the compare to defeat timing side-channels.02Request
Production: POST https://pay.balajibrands.com/payment/create
Sandbox: POST https://balaji-pay-sandbox.vercel.app/payment/create
payment_sn=P-2026-0042-271699 &order_sn=ORD-2026-0042 &device_type=printer &trade_amount=49.99 &body=Phantom%203%20Custom%20Print ¬ify_url=https%3A%2F%2Fyour-pos.example%2Fbalaji-pay%2Fcallback &redirect_url=https%3A%2F%2Fyour-pos.example%2Forder%2F271699 &device=%7B%22id%22%3A%22CCK020-SANDBOX%22%7D // JSON-string-embedded &sku=%7B%22name%22%3A%22iPhone%2015%20Pro%20Max%22%7D // optional &phone=%7B%22number%22%3A%22%2B12135551234%22%7D // optional
| Field | Type | Required | Notes |
|---|---|---|---|
payment_sn | string | Required | Your unique payment identifier. We treat it as the idempotency key — replay returns the original cashier_url; replay with a different trade_amount rejects with 1003 DUPLICATE_PAYMENT_DIFFERENT_AMOUNT. |
order_sn | string | Required | Display order number, can equal payment_sn if you don't maintain a separate one. |
device_type | enum | Required | "printer" or "laminator". |
trade_amount | decimal string | Required | $0.50 – $10,000.00, two decimals max. USD only at v1. |
body | string | Required | Line item description shown to the customer on the hosted checkout. URL-encode special characters per form-urlencoded rules. |
notify_url | https URL | Required | Where we POST the success notification. Per-checkout — change it per request if your routing differs. |
redirect_url | https URL | Required | Where to send the customer after payment terminates. Typically your order page. |
device | JSON string | Required | { "id": "<device_auth_code>" } — identifies the physical kiosk + maps it to a merchant config we hold. JSON-string-embedded inside the form-urlencoded payload. |
sku | JSON string | Optional | { "name": "iPhone 15 Pro Max" } — phone-model display info. |
phone | JSON string | Optional | { "number": "+12135551234" } — customer phone for SMS receipt (future). |
03Response
200 OK with a wire envelope that wraps the actual payload in data. Read code === 1 as success; anything else is an error (see §09).
{
"code": 1,
"msg": "success",
"data": {
"transaction_id": "txn_PCAa9dQ7fMNxfu1aPxHd",
"cashier_url": "https://pay.balajibrands.com/c/hck_yx7uDuTlgZnCMkZSjF4V"
}
}| Field | Notes |
|---|---|
code | 1 = success. Anything else is an error (see §09). |
msg | Human-readable message. "success" on the happy path; specific reason text on errors. |
data.transaction_id | Stable Balaji-side identifier — use it on subsequent /payment/query + /payment/refund calls. Echoed back on the callback. |
data.cashier_url | The hosted-checkout URL. Redirect the customer here. Single-use, ~15 min TTL before customer interaction; extends to 30 min on first interaction. |
04Callback
When a payment succeeds we POST a signed notification to the notify_url you provided on the original /payment/create.
FAILED / CANCELLED outcomes, poll /payment/query when the customer returns to your redirect_url.Headers: appkey: <our_appkey> random: <fresh_nonce> signature: md5(appkey + random + appsecret) Content-Type: application/x-www-form-urlencoded Body: payment_sn=P-2026-0042-271699 &transaction_id=txn_PCAa9dQ7fMNxfu1aPxHd &trade_amount=49.99 &paytime=1746058214
Reply with HTTP 200 + the literal body SUCCESS to acknowledge. Anything else triggers our retry sweep — exponential backoff capped at 30 minutes, max 10 attempts, ~1h50m total before the notification is flagged for manual reconciliation in the admin ops queue.
Notifications are at-least-once. Use payment_sn as your idempotency key — duplicate deliveries for the same payment must be no-ops in your handler.
05Signature
The same md5(appkey + random + appsecret) scheme runs in both directions. On the inbound side you sign and we verify; on the callback side we sign and you verify. The appsecret never travels — only the resulting hex digest does.
import { createHash, timingSafeEqual } from "crypto";
function verify(headers, appsecret) {
const appkey = headers["appkey"];
const random = headers["random"];
const presented = (headers["signature"] || "").toLowerCase();
if (!appkey || !random || !presented) return false;
const expected = createHash("md5")
.update(appkey + random + appsecret)
.digest("hex");
if (presented.length !== expected.length) return false;
return timingSafeEqual(
Buffer.from(presented, "hex"),
Buffer.from(expected, "hex")
);
}06Status query
When the customer returns to your redirect_urlafter payment terminates, query the canonical status before you mark the order paid. The success callback is reliable but async — query first if you need an immediate decision in the customer's flow.
GET /payment/query?transaction_id=<txn_*>
Same appkey / random / signature headers as /payment/create.
{
"code": 1,
"msg": "success",
"data": {
"transaction_id": "txn_PCAa9dQ7fMNxfu1aPxHd",
"payment_sn": "P-2026-0042-271699",
"status": 1, // 0 = unpaid, 1 = paid, 2 = refunded
"paytime": 1746058214,
"refund_amount": "0.00",
"refund_time": 0
}
}07Refund
Issue a full refund against a paid transaction. Partial refunds are not supported at v1.
POST /payment/refund, form-urlencoded body, same appkey / random / signature headers.
transaction_id=txn_PCAa9dQ7fMNxfu1aPxHd &reason=Customer%20requested%20cancellation // optional
{
"code": 1,
"msg": "success",
"data": {
"refund_id": "rfnd_a3f9c08b1234"
}
}A subsequent /payment/query on the same transaction_id will surface status: 2 + populated refund_amount and refund_time.
08Status taxonomy
The status field on /payment/query is a three-state enum:
| status | Meaning | Suggested POS action |
|---|---|---|
| 0 — unpaid | Pending, failed, or cancelled. The OpenPay spec collapses non-terminal and terminal-not-paid into one bucket. | Hold the order; surface the cashier_url again or treat as cancelled per your POS policy. |
| 1 — paid | Captured + settled. The success callback has fired (or will fire imminently). | Mark order paid; trigger fulfillment. |
| 2 — refunded | A refund has been issued against this transaction. refund_amount and refund_time are populated. | Reverse fulfillment + reconcile to your books. |
09Errors
OpenPay errors return HTTP 200 with code in the 1001–1500 range. The HTTP status reflects whether we failed (5xx) versus whether your request failed (200 + non-1 code).
| code | Name | When |
|---|---|---|
1001 | SIGNATURE_INVALID | Missing or wrong appkey / random / signature header. |
1002 | VALIDATION_ERROR | Body fails schema (missing required field, bad format). |
1003 | DUPLICATE_PAYMENT_DIFFERENT_AMOUNT | Same payment_sn previously billed at a different trade_amount. |
1004 | PAYMENT_NOT_FOUND | transaction_id not found on /query or /refund. |
1005 | DEVICE_NOT_FOUND | device.id doesn't map to a configured kiosk. |
1006 | WRONG_STATE | Refund attempted on an unpaid or already-refunded transaction. |
1007 | REFUND_NOT_SUPPORTED | Underlying processor doesn't support /refund (Phase 3 scope; today only Square refunds). |
| HTTP 503 | PROCESSOR_UNAVAILABLE | Underlying processor failed. Retry in ~15 seconds. |
10Credential handoff
During onboarding (and on rotations) Balaji ships your appkey + appsecret via a passphrase-gated handoff portal — never raw email, never Slack DM, never voice.
How it works. You receive two pieces of information over different channels — typically the URL via email and the passphrase via voice or SMS. Either one alone is insufficient to reveal the credentials; you need both.
| Direction | URL shape | Use when |
|---|---|---|
| Outbound — Balaji → you | pay.balajibrands.com/handoff/hf_… | Receiving fresh appkey / appsecret during onboarding or after a rotation. |
| Inbound — you → Balaji | pay.balajibrands.com/intake/hi_… | Sending us partner-side credentials Balaji needs (e.g. fulfillment-partner API keys). Browser-side form, sealed with your passphrase. |
What to do when you receive an outbound URL: visit the link, type the passphrase you received separately, copy the revealed values into your secret store (Vercel env vars, AWS Secrets Manager, etc.). The reveal page is single-use by default — once you copy the values, the record is destroyed and the URL returns 404.
What to do when you receive an inbound URL: visit the link, type the passphrase, fill in the per-partner form, submit. Your values are sealed with the passphrase before persistence; only the Balaji admin who minted the URL can open the result.
null; rate-limited at 5 attempts per 15 minutes per token.11Phase-1 (legacy)
The original Balaji Pay contract used Bearer auth on POST /api/checkouts with a JSON body, plus an HMAC-SHA256 callback to a fixed URL per environment. It's still alive — partners integrated before 2026-04-30 don't need to migrate immediately. New integrations should use the OpenPay spec above.
| Concern | Phase-1 (legacy) | OpenPay (canonical) |
|---|---|---|
| Endpoint | POST /api/checkouts | POST /payment/create |
| Inbound auth | Authorization: Bearer <api_key> | appkey + random + signature (MD5) |
| Body format | JSON | form-urlencoded (with JSON-string-embedded sub-fields) |
| Callback URL | Fixed per environment, configured during onboarding | Per-checkout via the notify_url field |
| Callback fires on | COMPLETED + FAILED + CANCELLED (terminal-only) | Success only — poll /payment/query for FAILED / CANCELLED |
| Callback signature | HMAC-SHA256(secret, "{ts}.{rawBody}") | MD5(appkey + random + appsecret) |
| Callback ack | HTTP 2xx | HTTP 200 + body literal SUCCESS |
| Status enum | UPPERCASE strings (COMPLETED / FAILED / CANCELLED) | Numeric (0 unpaid / 1 paid / 2 refunded) |
The legacy Phase-1 sandbox tools still mint and verify against the original contract — useful if you're debugging an existing integration before migrating.
Ready to wire it up? Try the sandbox →