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.

New here? The fastest way to grok the integration is to mint a real sandbox checkout and follow the redirect end to end. About 10 seconds, no credentials needed.

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 buildYou receive (during onboarding)
One outbound HTTPS POST per checkoutappkey + appsecret pair (one per environment), delivered via the credential handoff portal
One inbound HTTPS callback endpoint per checkoutSandbox alias for end-to-end testing before go-live
Constant-time MD5 verification on inbound notificationsTest 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:

request headersrequired
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.

Why MD5 and not HMAC-SHA256? The contract is symmetric (we sign outbound the same way) and inherited from the partner spec. The 32-byte appsecret entropy is wide enough that MD5 collision-resistance isn't in the threat model — verifiers know the secret, so the well-known MD5 collision attacks don't apply. We 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

request bodyapplication/x-www-form-urlencoded
payment_sn=P-2026-0042-271699
&order_sn=ORD-2026-0042
&device_type=printer
&trade_amount=49.99
&body=Phantom%203%20Custom%20Print
&notify_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
FieldTypeRequiredNotes
payment_snstringRequiredYour 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_snstringRequiredDisplay order number, can equal payment_sn if you don't maintain a separate one.
device_typeenumRequired"printer" or "laminator".
trade_amountdecimal stringRequired$0.50 – $10,000.00, two decimals max. USD only at v1.
bodystringRequiredLine item description shown to the customer on the hosted checkout. URL-encode special characters per form-urlencoded rules.
notify_urlhttps URLRequiredWhere we POST the success notification. Per-checkout — change it per request if your routing differs.
redirect_urlhttps URLRequiredWhere to send the customer after payment terminates. Typically your order page.
deviceJSON stringRequired{ "id": "<device_auth_code>" } — identifies the physical kiosk + maps it to a merchant config we hold. JSON-string-embedded inside the form-urlencoded payload.
skuJSON stringOptional{ "name": "iPhone 15 Pro Max" } — phone-model display info.
phoneJSON stringOptional{ "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).

200 OK · successcode: 1
{
  "code": 1,
  "msg":  "success",
  "data": {
    "transaction_id": "txn_PCAa9dQ7fMNxfu1aPxHd",
    "cashier_url":    "https://pay.balajibrands.com/c/hck_yx7uDuTlgZnCMkZSjF4V"
  }
}
FieldNotes
code1 = success. Anything else is an error (see §09).
msgHuman-readable message. "success" on the happy path; specific reason text on errors.
data.transaction_idStable Balaji-side identifier — use it on subsequent /payment/query + /payment/refund calls. Echoed back on the callback.
data.cashier_urlThe 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.

Success-only callback. The OpenPay spec only fires on the happy path. For FAILED / CANCELLED outcomes, poll /payment/query when the customer returns to your redirect_url.
POST your-pos.example/balaji-pay/callbackapplication/x-www-form-urlencoded
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.

verification · nodejavascript
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")
  );
}
Stuck on signature mismatches? The signature verifier on the sandbox page lets you paste appkey + random + signature + appsecret and shows you the exact hex digest we'd expect. Fastest way to catch the usual culprits (whitespace in the appsecret, wrong concatenation order, mixed-case hex, accidental hash-of-the-hash).

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.

200 OKcode: 1
{
  "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.

request bodyapplication/x-www-form-urlencoded
transaction_id=txn_PCAa9dQ7fMNxfu1aPxHd
&reason=Customer%20requested%20cancellation  // optional
200 OKcode: 1
{
  "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:

statusMeaningSuggested POS action
0 — unpaidPending, 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 — paidCaptured + settled. The success callback has fired (or will fire imminently).Mark order paid; trigger fulfillment.
2 — refundedA 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).

codeNameWhen
1001SIGNATURE_INVALIDMissing or wrong appkey / random / signature header.
1002VALIDATION_ERRORBody fails schema (missing required field, bad format).
1003DUPLICATE_PAYMENT_DIFFERENT_AMOUNTSame payment_sn previously billed at a different trade_amount.
1004PAYMENT_NOT_FOUNDtransaction_id not found on /query or /refund.
1005DEVICE_NOT_FOUNDdevice.id doesn't map to a configured kiosk.
1006WRONG_STATERefund attempted on an unpaid or already-refunded transaction.
1007REFUND_NOT_SUPPORTEDUnderlying processor doesn't support /refund (Phase 3 scope; today only Square refunds).
HTTP 503PROCESSOR_UNAVAILABLEUnderlying 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.

DirectionURL shapeUse when
Outbound — Balaji → youpay.balajibrands.com/handoff/hf_…Receiving fresh appkey / appsecret during onboarding or after a rotation.
Inbound — you → Balajipay.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.

Sealed with what? scrypt (N=32768, r=8, p=1) for the KDF + AES-256-GCM for the envelope. Wrong passphrase = unintelligible bytes, returned as 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.

ConcernPhase-1 (legacy)OpenPay (canonical)
EndpointPOST /api/checkoutsPOST /payment/create
Inbound authAuthorization: Bearer <api_key>appkey + random + signature (MD5)
Body formatJSONform-urlencoded (with JSON-string-embedded sub-fields)
Callback URLFixed per environment, configured during onboardingPer-checkout via the notify_url field
Callback fires onCOMPLETED + FAILED + CANCELLED (terminal-only)Success only — poll /payment/query for FAILED / CANCELLED
Callback signatureHMAC-SHA256(secret, "{ts}.{rawBody}")MD5(appkey + random + appsecret)
Callback ackHTTP 2xxHTTP 200 + body literal SUCCESS
Status enumUPPERCASE 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 →