Overview Authentication Provisioning Sign-in Collection Payments Webhooks Get your API key →
Developer Documentation
reenjar / Developers v1 · early access

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.

Your Product
BackendMints tokens · provisions schools, classes, students · receives webhooks
Frontend / BrowserRenders the school sign-in handoff
API calls, webhooks
redirect (SSO)
Reenjar
Partner API/api/partners/v1/
Dashboard & mobile appSchool staff · parents

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

  1. Sign in to the Partner Portal.
  2. Open Developer in the sidebar.
  3. 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.
  4. 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
}
StatusCodeMeaning
400missing_fieldsOne or more required fields weren't sent.
409conflictThe 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.
429rate_limitedMore 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.

StatusCodeMeaning
400missing_fieldsOne of external_id/label/start_date/end_date wasn't sent.
400invalid_dateA date isn't YYYY-MM-DD, or end_date isn't after start_date.
404not_foundThis school doesn't exist, or isn't under your rate card.
409conflictA 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.

StatusCodeMeaning
400missing_fieldsexternal_id or name wasn't sent.
404not_foundThe school doesn't exist/isn't under your rate card, or session_external_id doesn't match a session you've created for it.
409conflictA 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:

ClaimRequiredDescription
issYesYour partner id, shown to you in the Partner Portal alongside your API key.
phoneYesThe parent's Nigerian mobile number: with or without +234/0, spaces, or dashes.
iatYesIssued-at time, as a Unix timestamp.
expYesExpiry time. Must be no more than 120 seconds after iat. Mint this token immediately before sending it, not ahead of time.
jtiYesA 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.

StatusCodeMeaning
400missing_tokenNo token field in the request body.
400invalid_phoneThe token verified, but its phone claim isn't a valid phone number.
401invalid_tokenMalformed JWT, bad signature, a missing required claim, or a lifetime over 120s.
401expired_tokenThe token's own exp has passed.
401replayed_tokenThis exact token (by jti) was already exchanged once.
401unknown_partnerThe iss doesn't match a partner with an API key on file.
401partner_inactiveThe partner account exists but is suspended.
429rate_limitedToo 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 SchoolUser at 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

  1. A school admin is signed in on your product.
  2. Your backend mints a short-lived JWT: same construction as token exchange, but with an email claim instead of phone.
  3. 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.
  4. 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

ClaimRequiredDescription
issYesYour partner id.
emailYesThe SchoolUser's email to sign in as.
iat, expYesNo more than 120 seconds apart. Mint this immediately before the page redirects, not ahead of time.
jtiYesA 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

EventFires when
fee.paidA fee reaches full settlement: every ordinary fee's only payment, or the round that closes out a partial-payment fee's balance.
fee.payment_receivedA 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.