Recipes
Bank, 1Link and JazzCash payment sync
Turn confirmed bank, 1Link bill-payment, JazzCash and Easypaisa payments into School360 receipts automatically, safely retried and reconciled every day.
Goal
When the bank or wallet confirms a payment (a 1Link bill-payment callback, a JazzCash IPN, a line in the day's statement), your connector posts it to POST /v1/payments. School360 makes a normal receipt from the school's own series, pays the family's vouchers (late fines first, then the oldest) and sends the fee.payment_received webhook.
Matching the payment to a family
Tell School360 who paid, in one of three ways:
voucherNumber: the voucher's number (e.g. FV-2026-10-00123). Best for 1Link and bank bill payment, where the parent enters the number printed on the voucher as the consumer or bill reference.familyCode: the family's code (e.g. FAM-00233), printed on every voucher. Pays the family's open vouchers, oldest first: good for a fixed reference per family on JazzCash or Easypaisa.familyId: the family's id from the API, when your system already keeps it.
method is one of the school's payment methods (Finance › Fields & Options › Payment methods: ibft, bank-deposit, jazzcash, easypaisa…) and reference is the bank's or wallet's transaction id.
Recording the payment
Send the bank's transaction id as reference and build the Idempotency-Key from it. A timed-out request can then be sent again as many times as needed, and the payment is still recorded once.
curl -X POST https://api.school360.cloud/v1/payments \
-H "Authorization: Bearer $SCHOOL360_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hbl-FT26100812345" \
-d '{ "voucherNumber": "FV-2026-10-00123", "amount": 8500, "method": "ibft",
"reference": "FT26100812345", "paidAt": "2026-10-08T10:15:00+05:00" }'
# → 201 { "id": 981, "number": "RCT-2026-00981", "amount": 8500, "lines": [...] }
# The same request again → the same 201 answer, with Idempotent-Replayed: true// txn: a confirmed payment from the bank or wallet
async function recordPayment(txn) {
const res = await fetch("https://api.school360.cloud/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHOOL360_KEY}`,
"Content-Type": "application/json",
// Derived from the bank's transaction id: a retry can never make a second receipt
"Idempotency-Key": `hbl-${txn.id}`,
},
body: JSON.stringify({ voucherNumber: txn.billRef, amount: txn.amount, method: "ibft", reference: txn.id, paidAt: txn.paidAt }),
})
const body = await res.json()
if (res.status === 201) return body // the receipt
if (res.status === 409 && body.error.code === "duplicate_reference") return null // recorded before
throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`)
}<?php
// $txn: a confirmed payment from the bank or wallet
function record_payment(array $txn): ?array
{
$ch = curl_init('https://api.school360.cloud/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SCHOOL360_KEY'),
'Content-Type: application/json',
'Idempotency-Key: hbl-' . $txn['id'], // a retry can never make a second receipt
],
CURLOPT_POSTFIELDS => json_encode([
'voucherNumber' => $txn['billRef'],
'amount' => $txn['amount'],
'method' => 'ibft',
'reference' => $txn['id'],
'paidAt' => $txn['paidAt'],
]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status === 201) return $body; // the receipt
if ($status === 409 && $body['error']['code'] === 'duplicate_reference') return null; // recorded before
throw new RuntimeException("$status {$body['error']['code']}: {$body['error']['message']}");
}import os
import requests
def record_payment(txn):
"""txn: a confirmed payment from the bank or wallet → the receipt (None if recorded before)."""
res = requests.post(
"https://api.school360.cloud/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['SCHOOL360_KEY']}",
# Derived from the bank's transaction id: a retry can never make a second receipt
"Idempotency-Key": f"hbl-{txn['id']}",
},
json={"voucherNumber": txn["bill_ref"], "amount": txn["amount"], "method": "ibft",
"reference": txn["id"], "paidAt": txn["paid_at"]},
timeout=30,
)
body = res.json()
if res.status_code == 201:
return body
if res.status_code == 409 and body["error"]["code"] == "duplicate_reference":
return None
raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")Daily reconciliation
Once a day, compare the bank's statement with GET /v1/receipts for that day and method: anything on the statement without a receipt is sent again (the same Idempotency-Key makes this safe), and anything School360 has that the bank doesn't is checked by the accounts office.
// reconcile.mjs: does every bank credit of the day have a School360 receipt? Run once a day.
// bankTxns: the day's statement (from the bank's portal or API), [{ id, amount, billRef }]
const headers = { Authorization: `Bearer ${process.env.SCHOOL360_KEY}` }
async function receiptsOn(day, method) {
const out = []
let cursor = null
do {
const q = new URLSearchParams({ from: day, to: day, method, limit: "200", ...(cursor ? { cursor } : {}) })
const res = await fetch(`https://api.school360.cloud/v1/receipts?${q}`, { headers })
const page = await res.json()
if (!res.ok) throw new Error(`${res.status} ${page.error.code}`)
out.push(...page.data)
cursor = page.hasMore ? page.nextCursor : null
} while (cursor)
return out
}
export async function reconcile(day, bankTxns) {
const receipts = (await receiptsOn(day, "ibft")).filter((r) => !r.cancelled)
const byRef = new Map(receipts.map((r) => [r.reference, r]))
const missing = bankTxns.filter((t) => !byRef.has(t.id)) // send these again (same Idempotency-Key)
const wrong = bankTxns.filter((t) => byRef.has(t.id) && byRef.get(t.id).amount !== t.amount)
const unknown = receipts.filter((r) => !bankTxns.some((t) => t.id === r.reference)) // not on the statement
return { missing, wrong, unknown }
}# reconcile.py: does every bank credit of the day have a School360 receipt? Run once a day.
import os
import requests
api = requests.Session()
api.headers["Authorization"] = f"Bearer {os.environ['SCHOOL360_KEY']}"
def receipts_on(day, method):
out, cursor = [], None
while True:
params = {"from": day, "to": day, "method": method, "limit": 200, **({"cursor": cursor} if cursor else {})}
res = api.get("https://api.school360.cloud/v1/receipts", params=params, timeout=30)
res.raise_for_status()
page = res.json()
out += page["data"]
if not page["hasMore"]:
return out
cursor = page["nextCursor"]
def reconcile(day, bank_txns):
"""bank_txns: the day's statement, [{"id", "amount", "bill_ref"}]."""
receipts = {r["reference"]: r for r in receipts_on(day, "ibft") if not r["cancelled"]}
missing = [t for t in bank_txns if t["id"] not in receipts] # send again (same Idempotency-Key)
wrong = [t for t in bank_txns if t["id"] in receipts and receipts[t["id"]]["amount"] != t["amount"]]
ids = {t["id"] for t in bank_txns}
unknown = [r for ref, r in receipts.items() if ref not in ids] # not on the statement
return missing, wrong, unknownPitfalls
- Amounts are numbers in PKR (
8500, not"8,500"). 409 duplicate_referencemeans this method and reference were already recorded: treat it as done, not as a failure.422means the amount is more than the family owes, or the voucher is already paid or cancelled: park the payment for a person to look at; don't retry it.- The receipt is dated the day it's recorded;
paidAt(when the parent paid) is kept on the receipt's note. Reconcile byreference, not by date. - Give the connector's key fees: write and families: read only, limited to the connector's IP address.