Partner API
The Reenjar Partner API lets you collect school fees, sign parents and school staff in, and provision schools directly from your own product.
Not what you're looking for? If you're a school or a reseller looking for the product guide, see the Documentation instead. This page is for engineers integrating a partner's own product with Reenjar.
/api/partners/v1/Everything below is one of those two arrows: a backend call to the Partner API, or a browser handoff to Reenjar.
Base URL & versioning
Every partner endpoint is versioned and mounted under one prefix:
https://app.reenjar.com/api/partners/v1/
A version never changes underneath you. A breaking change ships as /v2/ alongside
the existing /v1/, not as a silent change to this one.
1. Authentication
Every partner is represented by a single API key, generated from your Partner Portal account. It's a shared secret that never travels in a request to Reenjar. Instead, you use it to sign tokens (see Sign-in), the same way a webhook provider signs a payload instead of passing a password around.
1.1 Getting an API key
- Sign in to the Partner Portal.
- Open Developer in the sidebar.
- Click Generate API key. The full key is shown once. Store it in your backend's secrets manager immediately: Reenjar can't show it to you again.
- If a key is ever compromised, click Regenerate key. The old key stops working immediately.
Never ship this key to a mobile app, a browser, or any client you don't control. It must only ever live on your own server.
2. Provisioning
If you already manage schools in your own system, you don't need to onboard each one through the Partner Portal by hand. Provisioning uses a different authentication mode from the sign-in flows below: your backend authenticates as itself, not on behalf of a parent. There's no token exchange step. Send your API key directly.
Authorization: Bearer <your API key>
Different risk profile. These calls create real, billing-capable schools. Treat your API key with the same care you already do for signing token-exchange requests, and never expose it to a browser or mobile client.
2.1 Create a school
POST /api/partners/v1/schools/
Authorization: Bearer <your API key>
Content-Type: application/json
{
"external_id": "your-own-id-for-this-school",
"name": "Greenview Academy",
"email": "admin@greenview.example.com",
"phone": "08011112222",
"address": "12 Greenview Road, Lagos",
"cac_number": "RC123456",
"admin_first_name": "Amaka",
"admin_last_name": "Obi",
"admin_email": "amaka@greenview.example.com"
}
Creates the school and its admin account, active immediately (the same as a Partner Portal
onboarding), under your existing wholesale/margin rate card. external_id is your own
reference for this school. Calling again with the same one returns the original school
(200) instead of creating a duplicate, so retries are always safe.
{
"id": "56c7de4b-b891-4487-9ec1-3c1f41f84ef1",
"external_id": "your-own-id-for-this-school",
"name": "Greenview Academy",
"email": "admin@greenview.example.com",
"phone": "08011112222",
"address": "12 Greenview Road, Lagos",
"cac_number": "RC123456",
"is_active": true
}
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_fields | One or more required fields weren't sent. |
| 409 | conflict | The school email, CAC number, or admin email is already registered to a different school. |
| 401 | — | Missing/invalid API key, or your account isn't active. |
| 429 | rate_limited | More than 60 requests in a minute. This is a provisioning endpoint, not a polling one. |
2.2 List schools you've provisioned
GET /api/partners/v1/schools/
Authorization: Bearer <your API key>
Returns only schools created through this endpoint (matched by your external_ids),
for reconciling your own records against Reenjar's rather than listing every school your account
has ever touched.
2.3 Create a session
"Session" is what Reenjar calls an academic year; every classroom belongs to one. You can usually skip this step: creating a classroom with no session specified auto-creates a school's first session for you. Create one explicitly for control over the dates, or when adding a school's second session, since the auto-fallback only ever handles the first.
POST /api/partners/v1/schools/<school_id>/sessions/
Authorization: Bearer <your API key>
Content-Type: application/json
{
"external_id": "your-own-id-for-this-session",
"label": "2026/2027",
"start_date": "2026-09-01",
"end_date": "2027-07-31",
"is_current": true
}
is_current: true retires whichever session was previously current for this school,
exactly like starting a new session in the Partner Portal. Omit it, or set it false, to
add a past or future session without disturbing the current one. A school's first session is always
current regardless of what you send. GET the same URL to list sessions you've
provisioned.
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_fields | One of external_id/label/start_date/end_date wasn't sent. |
| 400 | invalid_date | A date isn't YYYY-MM-DD, or end_date isn't after start_date. |
| 404 | not_found | This school doesn't exist, or isn't under your rate card. |
| 409 | conflict | A different session already has this label for this school. |
2.4 Create a classroom
POST /api/partners/v1/schools/<school_id>/classrooms/
Authorization: Bearer <your API key>
Content-Type: application/json
{"external_id": "your-own-id-for-this-class", "name": "Primary 1"}
school_id is any school under your rate card, not only ones you provisioned
through 2.1. A school onboarded through the Partner Portal works the same
way. Omitting the session lands the classroom in the school's current one (see
2.3). Pass session_external_id (an id from 2.3) to target a
specific session instead, e.g. a school's second one:
{"external_id": "your-own-id-for-this-class", "name": "Primary 1", "session_external_id": "your-own-id-for-this-session"}
Same idempotency rule as schools: repeating a call with the same external_id returns
the classroom created the first time. GET the same URL to list classrooms you've
provisioned for that school.
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_fields | external_id or name wasn't sent. |
| 404 | not_found | The school doesn't exist/isn't under your rate card, or session_external_id doesn't match a session you've created for it. |
| 409 | conflict | A different classroom already has this name in that session. |
2.5 Push students (bulk)
POST /api/partners/v1/schools/<school_id>/students/bulk/
Authorization: Bearer <your API key>
Content-Type: application/json
{
"students": [
{
"external_id": "your-own-id-for-this-student",
"first_name": "Amina",
"last_name": "Bello",
"classroom_external_id": "your-own-id-for-this-class",
"parents": [
{"phone": "08012345678", "name": "Fatima Bello", "email": "fatima@example.com"}
]
}
]
}
Up to 200 students per call. classroom_external_id must be an id you already used in
2.4 for this school. The response is itemized: one bad row never
fails the rest of the batch.
{
"created": 1,
"existing": 0,
"errors": 0,
"results": [
{"external_id": "your-own-id-for-this-student", "status": "created", "id": "...", "needs_review": false}
]
}
Each result's status is created, exists (already
provisioned) or error (with a detail string). needs_review
mirrors the Portal's duplicate-name flag: true when another active student already has this exact
name. If a parent's phone belongs to a registered Reenjar parent, they get an immediate pending
connection and push notification, without waiting for them to open the app.
3. Sign-in
Two flows, each a short-lived signed JWT from your backend, for two different personas. Parents get back an API token your backend uses to call fee endpoints. School staff get their browser handed a real Reenjar dashboard session instead. Same construction underneath (HS256, your API key, ≤120 seconds, single-use), different claim, different result.
3.1 Parent Sign-In (Token Exchange)
Token exchange is a server-to-server handoff. Your backend already knows who a parent is (they're signed in to your product), so it mints a short-lived signed token vouching for their phone number instead of sending them through Reenjar's phone/OTP screen. The response is identical to a normal OTP login, so your backend can treat it exactly the same way.
When to use it
Use this the moment a parent already authenticated in your product needs to see or pay a Reenjar fee. It replaces Reenjar's phone/OTP screen only, not a general-purpose identity provider integration: the only claim it carries is the parent's phone number, already Reenjar's identity key for every parent.
Request
POST https://app.reenjar.com/api/partners/v1/auth/exchange/
Content-Type: application/json
{
"token": "<JWT signed HS256 with your API key>"
}
The JWT you mint must carry these claims:
| Claim | Required | Description |
|---|---|---|
iss | Yes | Your partner id, shown to you in the Partner Portal alongside your API key. |
phone | Yes | The parent's Nigerian mobile number: with or without +234/0, spaces, or dashes. |
iat | Yes | Issued-at time, as a Unix timestamp. |
exp | Yes | Expiry time. Must be no more than 120 seconds after iat. Mint this token immediately before sending it, not ahead of time. |
jti | Yes | A unique id for this token. Each token can be exchanged exactly once; a reused jti is rejected. |
Example, using Node's jsonwebtoken:
const jwt = require('jsonwebtoken');
const { randomUUID } = require('crypto');
const now = Math.floor(Date.now() / 1000);
const token = jwt.sign({
iss: PARTNER_ID,
phone: parent.phoneNumber,
iat: now,
exp: now + 60,
jti: randomUUID(),
}, process.env.REENJAR_API_KEY, { algorithm: 'HS256' });
const res = await fetch('https://app.reenjar.com/api/partners/v1/auth/exchange/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const session = await res.json();
// session.access / session.refresh: use exactly like a normal parent login
Response
200 OK on success:
{
"access": "<JWT access token>",
"refresh": "<JWT refresh token>",
"name_confirmed": false,
"first_name": "",
"last_name": "",
"is_new_user": true
}
access and refresh work exactly like tokens issued by Reenjar's own
phone/OTP login. Use access as a bearer token against any parent-facing endpoint.
is_new_user tells you whether this is the parent's first time on Reenjar, useful for
deciding whether to show a name-confirmation step.
Errors
Every error response has the shape {"detail": "...", "code": "..."}. Branch on
code; detail is for humans reading logs.
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_token | No token field in the request body. |
| 400 | invalid_phone | The token verified, but its phone claim isn't a valid phone number. |
| 401 | invalid_token | Malformed JWT, bad signature, a missing required claim, or a lifetime over 120s. |
| 401 | expired_token | The token's own exp has passed. |
| 401 | replayed_token | This exact token (by jti) was already exchanged once. |
| 401 | unknown_partner | The iss doesn't match a partner with an API key on file. |
| 401 | partner_inactive | The partner account exists but is suspended. |
| 429 | rate_limited | Too many requests from your IP. See Rate limits below. |
3.2 School Sign-In (SSO)
If school staff already sign in to your product, they shouldn't have to log in to Reenjar separately. This hands a school admin's browser off to Reenjar already signed in. That's a real difference from Parent Sign-In: that one hands your backend an API token; this one starts an actual browser session in Reenjar's dashboard.
This signs an existing user in. It never creates one. The email must already belong to a
SchoolUserat a school under your rate card (created when you provisioned the school, or invited since via the school's own Team page). An unrecognized email fails closed, the same as every other check here.
The flow
- A school admin is signed in on your product.
- Your backend mints a short-lived JWT: same construction as token exchange, but with an
emailclaim instead ofphone. - Their browser (not your backend) submits it to Reenjar as a real top-level page navigation: an auto-submitting HTML form, never a link with the token in the URL. A token in a query string ends up in browser history and server access logs.
- Reenjar verifies it, starts a real session for that
SchoolUser, and redirects straight into their dashboard.
<!-- Rendered by your backend, submits itself immediately -->
<form id="reenjar-sso" method="POST" action="https://app.reenjar.com/api/partners/v1/schools/sso/" target="_top">
<input type="hidden" name="token" value="{{ signed_jwt }}">
</form>
<script>document.getElementById('reenjar-sso').submit();</script>
Minting the token
| Claim | Required | Description |
|---|---|---|
iss | Yes | Your partner id. |
email | Yes | The SchoolUser's email to sign in as. |
iat, exp | Yes | No more than 120 seconds apart. Mint this immediately before the page redirects, not ahead of time. |
jti | Yes | A unique id. Each token can be used once. |
const now = Math.floor(Date.now() / 1000);
const token = jwt.sign(
{ iss: PARTNER_ID, email: schoolAdmin.email, iat: now, exp: now + 60, jti: randomUUID() },
process.env.REENJAR_API_KEY,
{ algorithm: 'HS256' }
);
// render the auto-submitting form above with this token
If it fails
Every rejection (expired, replayed, wrong signature, unknown email, inactive user or school, or a school outside your rate card) shows the same generic "sign-in link is no longer valid" page. It never reveals which check failed.
4. Collection Payments
Use an access token from token exchange as a normal bearer token against these endpoints to show a parent their fees and collect payment. This is the same fee-collection logic the Reenjar mobile app runs on. A token minted any other way (e.g. a normal OTP login) is refused, so every call is attributable to your partner id.
Access tokens expire like any JWT. If a call to any endpoint below returns
401, exchange a fresh token rather than treating it as a fee-level error.
4.1 List a parent's fees
GET /api/partners/v1/fees/
Authorization: Bearer <access token>
Returns every fee request the authenticated parent can see: pending, overdue, partially paid,
optional, and paid (as receipts). Each row includes the school, student, amount, and Reenjar's fee;
an installment fee also includes its own payments history.
[
{
"id": "e5bd8811-4253-49e7-88e5-28947c2deefa",
"description": "Third Term Tuition",
"student_name": "Amina Bello",
"class_name": "Primary 4",
"school_name": "Greenview Academy",
"amount": 45000.0,
"reenjar_fee": 200.0,
"total_amount": 45200.0,
"status": "pending",
"due_date": "2026-09-30",
"paid_at": null,
"allow_partial_payment": false,
"is_optional": false,
"variants": []
}
]
4.2 Initiate a payment
POST /api/partners/v1/fees/<id>/initiate/
Authorization: Bearer <access token>
Generates a virtual bank account for the parent to transfer into. Calling this again while an account is still open returns that same account instead of minting a new one, safe to retry on a flaky connection without leaving several open accounts behind.
{
"tx_ref": "reenjar-e5bd8811-...-1758700000",
"bank": "Wema Bank",
"account_number": "7810123456",
"account_name": "Greenview Academy",
"amount": "45200.00",
"school_amount": 45000.0,
"reenjar_fee": 200.0,
"amount_remaining": 45200.0,
"note": "",
"expires_at": "2026-09-25 15:02:00"
}
For a fee with allow_partial_payment: true, pass {"amount": "20000"} in
the request body to pay less than the full balance. For a variant-priced fee (variants
non-empty and not yet chosen), pass {"variant_name": "..."} the first time.
4.3 Pay several fees at once
POST /api/partners/v1/fees/pay-all/
Authorization: Bearer <access token>
Content-Type: application/json
{"ids": ["<fee id>", "<fee id>", "..."]}
Combines up to 20 pending/overdue fees from the same school (e.g. several children)
into a single virtual account and a single transfer. Fees with allow_partial_payment: true
can't be combined; collect those individually via 4.2. The response shape mirrors 4.2, plus an
items array listing each fee that will be settled.
4.4 Poll payment status
GET /api/partners/v1/fees/status/?ids=<id>,<id>
Authorization: Bearer <access token>
A bank transfer's webhook can take anywhere from seconds to a couple of minutes to land. Poll this with the fee ids you just initiated payment for, to learn the outcome without waiting on a webhook of your own. Accepts the ids straight from a 4.2 or 4.3 response, comma-separated.
[{"id": "e5bd8811-...", "status": "paid", "paid_at": "2026-09-25T15:04:12Z"}]
5. Webhooks
Optional and additive: 4.4 keeps working with or without a webhook
configured. Set an endpoint under Developer in the
Partner Portal and Reenjar POSTs
an event the moment one of your fees settles, instead of you polling for it.
5.1 Events
| Event | Fires when |
|---|---|
fee.paid | A fee reaches full settlement: every ordinary fee's only payment, or the round that closes out a partial-payment fee's balance. |
fee.payment_received | A round of a partial-payment fee settles without yet closing the balance. |
5.2 Request
POST <your webhook_url>
Content-Type: application/json
X-Reenjar-Event: fee.paid
X-Reenjar-Timestamp: 1758700000
X-Reenjar-Signature: sha256=<hex hmac>
X-Reenjar-Delivery: 28545d0c-9593-4b62-a739-158424b4e887
{
"event": "fee.paid",
"data": {
"id": "e5bd8811-4253-49e7-88e5-28947c2deefa",
"status": "paid",
"paid_at": "2026-09-25T15:04:12+01:00",
"payment_reference": "FLW-REF-129384",
"student_name": "Amina Bello",
"school_name": "Greenview Academy"
}
}
Respond with any 2xx status to acknowledge. Anything else, including a timeout, is
treated as a failure and retried on a backoff schedule (roughly 1 min, 5 min, 30 min, 2 hours, then
12 hours) for about a day before Reenjar gives up. Retries reuse the same X-Reenjar-Delivery
id, so keep processing idempotent against it.
5.3 Verifying a delivery
Compute HMAC-SHA256(your API key, "{timestamp}." + raw request body) and compare
it, in constant time, to the hex value after sha256= in X-Reenjar-Signature.
Use X-Reenjar-Timestamp as the timestamp, and reject the request if it's more than a few
minutes old, which stops a captured payload from being replayed later. This is the same key and HMAC
construction used to verify anything else Reenjar signs for you.
const crypto = require('crypto');
function isValidReenjarWebhook(rawBody, headers, apiKey) {
const timestamp = headers['x-reenjar-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5 min
const expected = crypto.createHmac('sha256', apiKey)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const given = headers['x-reenjar-signature'].replace('sha256=', '');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
6. Rate limits
Token exchange and school sign-in share one cap: 30 requests per minute per source IP. Neither can be brute-forced into a valid result, since both require a real signature, so this bounds volumetric probing rather than constraining normal traffic. Provisioning endpoints are capped at 60 requests per minute per partner instead (see the table above). If you're hitting either in production, get in touch rather than retrying in a loop.