eBarimt 3.0
Mongolia's national electronic receipt system. POS API 3.0, Keycloak OAuth, JSON receipts. No Node SDK exists; build thin REST client. Outbox pattern with retry — replaces v1's fire-and-forget post-save hook.
What it is
Every sale to a Mongolian customer must push a receipt to eBarimt within a short window. Receipt issuance returns a lottery code + QR; QR printed on tenant receipt. Compliance is non-optional — accountants will check.
Reference bindings (no Node SDK)
| Lang | Repo | Use as |
|---|---|---|
| Java | hurelhuyag/ebarimt | Wire-format reference |
| Go | techpartners-asia/ebarimt-pos3-go | Endpoint + error code reference |
| Go (alt) | lambda-platform/ebarimt-rest-api | Auth flow reference |
Build TS client (~250 lines). Spec: developer.itc.gov.mn/docs/ebarimt-api/.
API shape (POS API 3.0)
Auth (Keycloak)
POST https://st.auth.itc.gov.mn/auth/realms/{Realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials
→ { access_token, expires_in, token_type: "Bearer" }
Cache token in Redis until expires_in - 30s. Prod realm differs from staging.
Push receipt
POST {merchantBase}/rest/receipt
Authorization: Bearer {token}
Content-Type: application/json
{
"totalAmount": 1500000, // major units (₮); confirm in production
"totalVat": 136363,
"totalCityTax": 0,
"branchNo": "001",
"districtCode": "34",
"merchantTin": "1234567",
"posNo": "1",
"customerTin": "9876543", // optional for B2C
"type": "B2B", // "B2C" or "B2B"
"receiptItems": [
{
"name": "Rent — 2026-06, Unit 101",
"barCode": "00000",
"measureUnit": "month",
"qty": 1,
"unitPrice": 1363637,
"totalAmount": 1500000,
"totalVat": 136363,
"totalCityTax": 0
}
]
}
→ { id, lottery, qrData, ... }
Common errors
| Code | Meaning | Treatment |
|---|---|---|
MERCHANT_TIN_NOT_FOUND | Bad config | Dead-letter, alert owner |
INVALID_VAT | VAT math doesn't sum | Dead-letter, fix invoice |
DUPLICATE_INVOICE_ID | Already pushed | Treat as success (idempotent) |
BRANCH_NOT_FOUND | Bad branchNo | Dead-letter, fix config |
| 5xx / network | Transient | Exp backoff, max 8 attempts |
Outbox flow
- Invoice paid (status →
paid) inside a transaction. - Same tx: insert
outboxrow{ eventType: "ebarimt.push", aggregateId: invoice.id, payload: { invoiceId } }. - Tx commits. No HTTP call from the request handler.
- Worker drains outbox (pg-boss or LISTEN/NOTIFY) → loads invoice → builds receipt payload → POSTs to eBarimt.
- 2xx: store
id,lottery,qrDataon invoice; mark outboxsent; queue PDF render. - 4xx non-retryable: mark
dead; alert owner via in-app notification. - 5xx / network: increment
attempts, schedule next attempt:1m, 5m, 30m, 2h, 12h, 24h; give up after 8.
Client sketch
// packages/integrations/src/ebarimt/client.ts
import { z } from "zod";
interface EbarimtConfig {
realmUrl: string;
realm: string;
clientId: string;
clientSecret: string;
merchantBase: string;
merchantTin: string;
redis: Redis;
}
export class EbarimtClient {
constructor(private cfg: EbarimtConfig) {}
private async token(): Promise<string> {
const cached = await this.cfg.redis.get(`ebarimt:token:${this.cfg.realm}`);
if (cached) return cached;
const form = new URLSearchParams({ grant_type: "client_credentials" });
const res = await fetch(`${this.cfg.realmUrl}/realms/${this.cfg.realm}/protocol/openid-connect/token`, {
method: "POST",
headers: {
Authorization: "Basic " + Buffer.from(`${this.cfg.clientId}:${this.cfg.clientSecret}`).toString("base64"),
"Content-Type": "application/x-www-form-urlencoded",
},
body: form,
});
if (!res.ok) throw new Error(`eBarimt auth failed: ${res.status}`);
const j = z.object({ access_token: z.string(), expires_in: z.number() }).parse(await res.json());
await this.cfg.redis.setex(`ebarimt:token:${this.cfg.realm}`, j.expires_in - 30, j.access_token);
return j.access_token;
}
async pushReceipt(receipt: ReceiptInput): Promise<ReceiptResult> {
const t = await this.token();
const res = await fetch(`${this.cfg.merchantBase}/rest/receipt`, {
method: "POST",
headers: { Authorization: `Bearer ${t}`, "Content-Type": "application/json" },
body: JSON.stringify(receipt),
});
const body = await res.json();
if (!res.ok) {
const err = z.object({ message: z.string().optional(), code: z.string().optional() }).safeParse(body);
throw new EbarimtError(err.data?.code ?? "UNKNOWN", err.data?.message ?? "eBarimt error", res.status);
}
return ReceiptResultSchema.parse(body);
}
async getBranchInfo() { /* GET /rest/branch */ }
async getTinInfo(tin: string) { /* GET /rest/tin/{tin} */ }
}
export class EbarimtError extends Error {
constructor(public code: string, msg: string, public status: number) {
super(msg);
}
isRetryable(): boolean {
if (this.code === "DUPLICATE_INVOICE_ID") return false; // treat as success
if (this.status >= 500) return true;
return false;
}
}
Worker handler
// apps/workers/src/ebarimt.ts
async function handleEbarimtPush(payload: { invoiceId: string }) {
const inv = await db.query.invoices.findFirst({
where: eq(invoices.id, payload.invoiceId),
with: { items: true, owner: true },
});
if (!inv) throw new Error("invoice not found");
if (inv.ebarimtReceiptId) return; // already pushed; idempotent
const receipt = buildReceipt(inv);
try {
const result = await ebarimt.pushReceipt(receipt);
await db.transaction(async (tx) => {
await tx.update(invoices).set({
ebarimtReceiptId: result.id,
ebarimtLottery: result.lottery,
ebarimtQrData: result.qrData,
}).where(eq(invoices.id, inv.id));
await tx.update(outbox).set({ status: "sent", sentAt: new Date() })
.where(and(eq(outbox.aggregateId, inv.id), eq(outbox.eventType, "ebarimt.push")));
});
} catch (err) {
if (err instanceof EbarimtError && err.code === "DUPLICATE_INVOICE_ID") {
// pretend success — already on eBarimt; we'll re-fetch via getById on next reconcile
await db.update(outbox).set({ status: "sent", sentAt: new Date(), lastError: "duplicate-treated-as-sent" })
.where(and(eq(outbox.aggregateId, inv.id), eq(outbox.eventType, "ebarimt.push")));
return;
}
throw err; // pg-boss/BullMQ retry with exp backoff
}
}
Build steps
- Client + types in
packages/integrations/src/ebarimt. - Schema additions on
invoicesforebarimt_*columns (already in billing). - Outbox event handler in
apps/workers/src/ebarimt.ts. - Config UI:
property_ownersstores eBarimt credentials per owner (encrypted). - Stage against eBarimt staging realm before flipping to prod.
- Daily reconciliation job — re-fetch eBarimt status for any invoice with
ebarimtReceiptId is nullandstatus = paid > 1h ago; surface in admin. - PDF template includes lottery + QR (uses
qrcodenpm package).
Open questions
- Per-owner or per-property eBarimt config? Most owners use one TIN; recommend per-owner with override per property.
- B2B vs B2C auto-detection: if invoice has
customerTinSnapshot, push as B2B. Manual override flag on invoice? - Lottery on tenant-facing PDFs: required for compliance — confirm with accounting.
- Refunds via eBarimt return API: scope for v2 launch or Phase 6+?