A single REST contract any School Management System maps onto. Integrate once, then provision and serve as many schools as you like. Base URL: https://pregota.com
Paste this with your sandbox key. It provisions a school, sends a pupil, approves the KES 1 prompt, pays a fee, and reads the balance back — the entire lifecycle, touching nothing real.
KEY=pgv_your_sandbox_key
BASE=https://pregota.com
# 1. A school (returns school_ref)
curl -s -X POST $BASE/api/sms/v1/schools -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"school_name":"Sunrise Primary","admin_name":"Head","payout_phone":"0712345678",
"fees":[{"term_label":"Term 1","amount":5000}],
"classes":[{"class_name":"Grade 1","teacher_name":"Mr A"}]}'
# 2. A pupil (safe to repeat — matched on admission_no)
curl -s -X POST $BASE/api/sms/v1/schools/sunrise-primary/roster \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: first-sync' \
-d '{"students":[{"admission_no":"SUN-001","name":"Amina","class":"Grade 1","parent_phone":"0798765432"}]}'
# 3. The head teacher approves the KES 1 prompt (sandbox stands in for the handset)
curl -s -X POST $BASE/api/sms/v1/schools/sunrise-primary/sandbox/approve -H "Authorization: Bearer $KEY"
# 4. A parent pays — your webhook fires
curl -s -X POST $BASE/api/sms/v1/schools/sunrise-primary/sandbox/payments \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"admission_no":"SUN-001","amount":2000}'
# 5. What does she still owe?
curl -s $BASE/api/sms/v1/schools/sunrise-primary/students/SUN-001 -H "Authorization: Bearer $KEY"
You were given two keys: a live one and a sandbox one. Use the sandbox key and everything below works identically — same endpoints, same webhooks — against a world that isn’t real. No real school, no real phone, no real money. We never call M-Pesa at all.
It also lets you do the two things you can never do for yourself in production, so you can build the whole flow end to end:
POST/api/sms/v1/schools/{school_ref}/sandbox/approve
Stands in for the head teacher approving the KES 1 prompt on their handset — the gate that decides whether a school can collect at all.
POST/api/sms/v1/schools/{school_ref}/sandbox/payments
{ "admission_no": "SUN-001", "amount": 2000, "method": "mpesa" }
Stands in for a parent paying. Your webhook fires, and the pupil’s balance moves — exactly as it will in production.
403 sandbox_only) on your live key. In production a parent pays on their own handset and the payout phone approves its own prompt — no key can do it for them, including yours.Send an Idempotency-Key header on any POST and the same key always gets the same answer back — the work happens once. So a request that times out is no longer a dilemma: just send it again.
Idempotency-Key: roster-sync-2026-07-11
A replayed response carries Idempotent-Replay: true. If the first request is still running you get 409 request_in_progress — wait and retry. Keys are scoped to your vendor, so they can never collide with anyone else's.
Every request carries your connection key as a bearer token. Get a key at /sms-integration.
Authorization: Bearer pgv_xxx_your_key
Accept: application/json
POST/api/sms/v1/schools
Creates the school and starts a KES 1 verification to its payout phone (the school taps to approve, once). Returns a school_ref you use in every later call.
{
"school_name": "Sunrise Primary",
"admin_name": "Head Teacher",
"payout_phone": "0712345678",
"external_ref": "your-tenant-id-001",
"fees": [{ "term_label": "Term 1", "amount": 5000 }],
"classes": [{ "class_name": "Grade 1", "teacher_name": "Mr A" }]
}
Response:
{ "school_ref": "sunrise-primary", "verified": false,
"prompt_sent": true,
"verify_url": "/api/sms/v1/schools/sunrise-primary/verification",
"resend_url": "/api/sms/v1/schools/sunrise-primary/verification/resend",
"note": "A KES 1 prompt has been sent to the payout phone..." }
Use a payout phone unique to each school (a reused number is rejected with 409 payout_phone_in_use).
Check prompt_sent. If it is false, M-Pesa refused to send the prompt (usually a wrong or unreachable number) and the school cannot collect any fees — fix the number and re-send below.
GET/api/sms/v1/schools/{school_ref}/verification
A school cannot collect a single shilling until its payout phone approves the KES 1 prompt on the handset. Poll this to see where it stands:
{ "verified": false, "prompt_sent": true, "payout_phone": "•••5678",
"hint": "The payout phone has not approved the KES 1 prompt..." }
POST/api/sms/v1/schools/{school_ref}/verification/resend
Push the prompt again. This is the answer to “the school integrated and never got the STK” — the phone was off, or the prompt timed out, or someone tapped it away. Safe to call again; rate-limited to 5/min.
{ "sent": true, "verified": false, "payout_phone": "•••5678",
"hint": "Prompt sent. The payout phone must approve the KES 1 request on the handset." }
"sent": false means M-Pesa itself refused — check the number is a valid, reachable Safaricom line.
GET/api/sms/v1/schools
{ "count": 2, "schools": [ { "school_ref": "sunrise-primary", "name": "Sunrise Primary", "verified": true, "fees": 1 } ] }
POST/api/sms/v1/schools/{school_ref}/roster
Idempotent — send it whenever your roster changes; pupils match on admission_no.
{
"students": [
{ "admission_no": "SUN-001", "name": "Amina", "class": "Grade 1", "parent_phone": "0720000001" },
{ "admission_no": "SUN-002", "name": "Brian", "class": "Grade 1", "parent_phone": "0720000002" }
]
}
{ "received": 2, "created": 2, "updated": 0, "skipped": [] }
GET/api/sms/v1/schools/{school_ref}/fees
GET/api/sms/v1/schools/{school_ref}/students/{admission_no}
{ "admission_no": "SUN-001", "name": "Amina", "class": "Grade 1",
"fees": [ { "fee_id": 1, "name": "Term 1", "required": 5000, "paid": 2000, "balance": 3000 } ],
"payments": [ { "amount": 2000, "method": "mpesa", "receipt": "QGH7XYZ001", "paid_at": "..." } ] }
POST/api/sms/v1/schools/{school_ref}/payments/stkpush
Sends the M-Pesa prompt to the parent's phone. When they enter their PIN, the money lands in the school's wallet and your payment.recorded webhook fires. Leave phone out — we use the parent number on the pupil's roster record.
phone, it must match parent_phone or parent2_phone on that pupil — anything else is refused with 403 not_the_guardian. To change the number that gets asked, push the roster with the correct parent number first.stkpush, stk-push, stk_push or plain stk, with or without the /payments/ in front. Every spelling hits the same call; none of them 404 while a parent is trying to pay.curl -s -X POST \
https://pregota.com/api/sms/v1/schools/sunrise-primary/payments/stkpush \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{ "admission_no": "SUN-001", "amount": 1000 }'const r = await fetch(
"https://pregota.com/api/sms/v1/schools/sunrise-primary/payments/stkpush",
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.PREGOTA_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
admission_no: "SUN-001",
amount: 1000,
// no phone: the prompt goes to the guardian on the pupil's roster row.
}),
},
);
const data = await r.json(); // { payment_id, checkout_id, status_url, ... }$ch = curl_init("https://pregota.com/api/sms/v1/schools/sunrise-primary/payments/stkpush");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PREGOTA_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'admission_no' => 'SUN-001',
'amount' => 1000,
// no phone: the prompt goes to the guardian on the pupil's roster row.
]),
]);
$data = json_decode(curl_exec($ch), true); // ['payment_id' => …, 'status_url' => …]import os, requests
r = requests.post(
"https://pregota.com/api/sms/v1/schools/sunrise-primary/payments/stkpush",
headers={"Authorization": f"Bearer {os.environ['PREGOTA_KEY']}"},
json={
"admission_no": "SUN-001",
"amount": 1000,
# no phone: the prompt goes to the guardian on the pupil's roster row.
},
)
data = r.json() # {"payment_id": …, "checkout_id": …, "status_url": …}The response carries a payment_id and a status_url. You don't need to poll it — the webhook tells you the moment she pays — but section 1b's verification and /payments/{'{'}payment_id{'}'}/status are there if you want to check.
GET/api/sms/v1/schools/{school_ref}/payments?since=2026-01-01T00:00:00Z
Every confirmed payment newest-first. Use this to backfill anything your webhook missed.
We POST a signed JSON event to your webhook URL the moment anything happens. Set the URL at sign-up or change it any time in your console.
payment.recorded | a parent paid (M-Pesa or cash) |
payment.amended | a cash record was corrected — use the new amount |
payment.voided | a cash record was entered by mistake — drop it, the parent still owes |
school.verified | the payout phone approved the KES 1 prompt — the school can now collect |
fee.published | a new fee exists |
fee.updated | a fee's amount changed — what every parent owes changed |
webhook.test | a test ping you sent yourself from the console — verify its signature like any other, reply 2xx, then ignore it (it carries "test": true) |
payment_id. Key your records on it, so an amended or voided event lands on the right row instead of creating a second one.POST (your webhook url)
X-Pregota-Event: payment.recorded
X-Pregota-Signature: sha256=<hmac>
{
"event": "payment.recorded",
"school_ref": "sunrise-primary",
"admission_no": "SUN-001",
"name": "Amina",
"amount": 2000,
"method": "mpesa",
"receipt": "QGH7XYZ001",
"fee": "Term 1",
"paid_at": "2026-07-10T12:00:00+00:00"
}
Compute an HMAC-SHA256 of the raw request body (before any JSON parsing) with your webhook signing secret, and compare it to the header in constant time. Reject anything that doesn't match — that's how you know the event is really from us.
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_PREGOTA_SIGNATURE'] ?? '';
$secret = getenv('PREGOTA_WEBHOOK_SECRET'); // whsec_…
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (! hash_equals($expected, $sig)) {
http_response_code(401);
exit; // not from Pregota — drop it
}
$event = json_decode($raw, true); // safe to trust now
// key your record on $event['payment_id']
const crypto = require('crypto');
// mount with express.raw so req.body is the untouched bytes
app.post('/pregota/webhook', express.raw({ type: '*/*' }), (req, res) => {
const expected = 'sha256=' +
crypto.createHmac('sha256', process.env.PREGOTA_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const sig = req.get('X-Pregota-Signature') || '';
const a = Buffer.from(expected), b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body); // trusted
res.sendStatus(200); // ack fast, process after
});
2xx quickly. If your endpoint is down, no problem — we retry with backoff and you can reconcile any time with the payment feed (section 6).On every screen where a parent pays through your system, display “Powered by Pregota Pay”. The Pregota-hosted fee pages already show it; if you build your own payment screen, you must include it too.