SMS Integration — Developer Guide

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

Five minutes, start to finish

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"
Prefer clicking to typing? Run these exact calls live against the sandbox in the try-it console — no setup, no curl. Or generate a client from the full contract at openapi.json.

Build in the sandbox first

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.

Both are refused (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.

Retrying is free

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.

Authentication

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
The key can create schools and read their fee data. It can never move money — payouts always require the school’s own phone-verified number. You can only ever see schools you created.

1. Add a school

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.

1b. The KES 1 approval — the gate on all money

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.

2. List your schools

GET/api/sms/v1/schools

{ "count": 2, "schools": [ { "school_ref": "sunrise-primary", "name": "Sunrise Primary", "verified": true, "fees": 1 } ] }

3. Push a school’s roster

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": [] }

4. Read fee structure & balances

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": "..." } ] }

5. Ask a parent to pay (STK Push)

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.

The prompt only ever goes to that pupil's own parent. A fee prompt is an M-Pesa PIN request with a school's name on it, so the number is taken from the pupil's roster row and nowhere else. If you do send 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.
Reach for whatever you wrote 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.

6. Payment feed (catch-up)

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.

7. Webhooks — everything that changes, as it changes

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.recordeda parent paid (M-Pesa or cash)
payment.amendeda cash record was corrected — use the new amount
payment.voideda cash record was entered by mistake — drop it, the parent still owes
school.verifiedthe payout phone approved the KES 1 prompt — the school can now collect
fee.publisheda new fee exists
fee.updateda fee's amount changed — what every parent owes changed
webhook.testa test ping you sent yourself from the console — verify its signature like any other, reply 2xx, then ignore it (it carries "test": true)
Every payment event carries a stable 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"
}

Verify the signature

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.

PHP

$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']

Node (Express)

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
});
Frameworks that parse and re-serialize the body will change the bytes and break the signature. Always hash the raw body.
Always reply 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).

Branding requirement

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.

🔒 Powered by Pregota Pay