API documentation
1. Overview
The portal offers four ways to work with other systems; use any combination:
- REST API — read and write students, staff, projects and academic data from any language (JSON over HTTPS). Base URL:
https://rapp.simdols.com/api/v1 - Record synchronisation — your system pushes its own records (in its own field names) to the portal, or the portal pulls them from your API. The Super Admin defines how your fields map to portal fields, so you do not need to change your data format.
- Single sign-on — users open the portal from your LMS or student portal without signing in again.
- Webhooks — the portal notifies your system when research events happen (project created, stage approved, grade recorded…).
Every account keeps a link to its ID in each connected system (external_ids), so you can always look people up by your own identifiers.
2. Authentication & scopes
Every request needs an API key. Keys are created by the Super Admin under Integrations & API and shown only once. Send the key in either header:
Authorization: Bearer rap_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
X-API-Key: rap_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx A key can be limited to IP addresses / ranges, given an expiry date, restricted to one connection and revoked at any time. Keep keys on the server: never put them in browser JavaScript or mobile apps. Each key has one or more scopes:
| Scope | Allows |
|---|---|
read:academic | Read departments, programmes, academic sessions and research workflows |
read:students | Read students and their profiles |
write:students | Create and update students |
read:staff | Read supervisors / staff and their profiles |
write:staff | Create and update supervisors / staff |
read:projects | Read research projects, supervisors, milestones, submissions (metadata) and grades |
write:projects | Create research projects |
read:events | Read the activity feed (events) |
sync | Push student / staff records through an integration connection (field mappings) |
Changes made through the API are recorded in the audit log under the Super Admin who created the key. Administrator roles cannot be granted through the API.
3. Requests, responses & errors
- Send JSON bodies with
Content-Type: application/json(form-encoded bodies are also accepted). Use UTF-8. - Successful responses:
{"ok": true, "data": …}; lists add"meta": {"total", "page", "per_page", "pages"}. Use?page=and?per_page=(max 200). - Dates are ISO 8601 in UTC (
2026-11-04T09:30:00Z). Filter changed records with?updated_since=for incremental syncs. - Rate limit: 60 requests per minute per key. Above that you receive HTTP 429: wait a minute and retry.
Errors: {"ok": false, "message": "…", "errors": {"field": ["…"]}} (errors only for validation errors).
| Status | Meaning |
|---|---|
| 200 / 201 | Success / created |
| 400 | The body is not valid JSON |
| 401 | Missing, invalid, revoked or expired key |
| 403 | The key lacks the scope, or your IP address is not allowed |
| 404 | Record or endpoint not found |
| 405 | Wrong HTTP method for this endpoint |
| 409 / 422 | Business rule or validation error — see message / errors |
| 429 | Too many requests |
| 503 | The API is switched off on this portal |
4. Quick start
Test your key with GET /ping, then list students:
curl -H "Authorization: Bearer $RAP_KEY" https://rapp.simdols.com/api/v1/ping
curl -H "Authorization: Bearer $RAP_KEY" "https://rapp.simdols.com/api/v1/students?programme=MSC-CSC&per_page=100"
# Create a student and e-mail an invitation
curl -X POST https://rapp.simdols.com/api/v1/students \
-H "Authorization: Bearer $RAP_KEY" -H "Content-Type: application/json" \
-d '{"matric_number":"CSC/2026/0101","first_name":"Ngozi","last_name":"Okafor","email":"[email protected]","programme_code":"MSC-CSC","activation":"invite"}'<?php
$key = getenv('RAP_KEY');
function rap(string $method, string $path, ?array $body = null): array
{
global $key;
$ch = curl_init('https://rapp.simdols.com/api/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Content-Type: application/json', 'Accept: application/json'],
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
CURLOPT_TIMEOUT => 30,
]);
$json = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException("RAP API $status: " . ($json['message'] ?? 'error'));
}
return $json;
}
print_r(rap('GET', '/ping')['data']);
// All students of a programme, page by page
$page = 1;
do {
$res = rap('GET', '/students?programme=MSC-CSC&per_page=200&page=' . $page);
foreach ($res['data'] as $s) {
echo $s['matric_number'], ' ', $s['first_name'], ' ', $s['last_name'], PHP_EOL;
}
} while ($page++ < $res['meta']['pages']);
// Update a student's phone number (partial update)
rap('PATCH', '/students/42', ['phone' => '+234 803 000 0000']);import os, requests
API = "https://rapp.simdols.com/api/v1"
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {os.environ['RAP_KEY']}", "Accept": "application/json"})
print(s.get(f"{API}/ping", timeout=30).json()["data"])
# All students, page by page
page = 1
while True:
r = s.get(f"{API}/students", params={"per_page": 200, "page": page}, timeout=30)
r.raise_for_status()
body = r.json()
for st in body["data"]:
print(st["matric_number"], st["first_name"], st["last_name"])
if page >= body["meta"]["pages"]:
break
page += 1
# Create a project for a student with a lead supervisor
r = s.post(f"{API}/projects", json={"matric_number": "CSC/2026/0101", "lead_staff_number": "FUD/STAFF/0042"}, timeout=30)
if r.status_code >= 400:
print(r.status_code, r.json()["message"], r.json().get("errors"))
else:
print("Created project", r.json()["data"]["id"])// Node.js 18+ (fetch is built in). Run on a server: never expose the key in a browser.
const API = 'https://rapp.simdols.com/api/v1';
const headers = { Authorization: `Bearer ${process.env.RAP_KEY}`, 'Content-Type': 'application/json' };
async function rap(method, path, body) {
const res = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const json = await res.json();
if (!res.ok) throw new Error(`RAP API ${res.status}: ${json.message}`);
return json;
}
const { data } = await rap('GET', '/projects?status=awaiting_review');
for (const p of data) console.log(p.reference, p.student.name, p.current_stage?.name);
// Poll the event feed
let since = 0;
const feed = await rap('GET', `/events?since_id=${since}`);
since = feed.meta.next_since_id;5. Endpoint reference
Base URL https://rapp.simdols.com/api/v1. The machine-readable OpenAPI 3 description can be imported into Postman, Insomnia or Swagger UI, or used to generate a client library.
/pingCheck the key
/departmentsList departments
read:academic/programmesList programmes
read:academic/sessionsList academic sessions
read:academic/workflowsList research workflows
read:academic/studentsList students
read:studentspage (query, integer) Page number (default 1)per_page (query, integer) Items per page, 1–200 (default 50)q (query, string) Search name, e-mail or ID numberemail (query, string) Exact e-mail addressstatus (query, string) pending, active, suspended or deactivateddepartment (query, string) Department codeupdated_since (query, string) Only records changed since this ISO 8601 date/timeexternal_id (query, string) ID in a connected systemconnection (query, string) Connection key (with external_id)matric_number (query, string) Exact student IDprogramme (query, string) Programme code/students/{id}Get a student
read:studentsid (path, integer) Portal ID/studentsCreate a student
write:students/students/{id}Update a student
write:studentsid (path, integer) Portal ID/staffList supervisors / staff
read:staffpage (query, integer) Page number (default 1)per_page (query, integer) Items per page, 1–200 (default 50)q (query, string) Search name, e-mail or ID numberemail (query, string) Exact e-mail addressstatus (query, string) pending, active, suspended or deactivateddepartment (query, string) Department codeupdated_since (query, string) Only records changed since this ISO 8601 date/timeexternal_id (query, string) ID in a connected systemconnection (query, string) Connection key (with external_id)staff_number (query, string) Exact staff ID/staff/{id}Get a staff member
read:staffid (path, integer) Portal ID/staffCreate a supervisor
write:staff/staff/{id}Update a supervisor
write:staffid (path, integer) Portal ID/projectsList research projects
read:projectspage (query, integer) Page number (default 1)per_page (query, integer) Items per page, 1–200 (default 50)student_id (query, integer) Student (owner or group member)matric_number (query, string) Student ID of the ownersupervisor_id (query, integer) Current supervisorstatus (query, string) topic_development, in_progress, awaiting_review, awaiting_revision, awaiting_approval, completed, suspended, archivedprogramme (query, string) Programme codesession (query, string) Academic session nameupdated_since (query, string) ISO 8601 date/time/projects/{id}Get a project
read:projectsid (path, integer) Portal ID/projectsCreate a project
write:projects/eventsActivity feed
read:eventssince_id (query, integer) Return events after this id (default 0)limit (query, integer) 1–200 (default 100)event (query, string) Comma-separated event namesproject_id (query, integer) Only this project/sync/{key}/studentsPush student records
synckey (path, string) Connection keydry_run (query, boolean) Preview without saving/sync/{key}/staffPush staff records
synckey (path, string) Connection keydry_run (query, boolean) Preview without savingExample response of GET /students/{id}:
{
"ok": true,
"data": {
"id": 42, "matric_number": "CSC/2026/0101", "title": null,
"first_name": "Ngozi", "last_name": "Okafor", "other_names": null,
"email": "[email protected]", "phone": null, "status": "active",
"department": {"id": 1, "code": "CSC", "name": "Computer Science"},
"programme": {"id": 2, "code": "MSC-CSC", "name": "M.Sc. Computer Science"},
"entry_session": {"id": 3, "name": "2025/2026"},
"custom_workflow": null, "managed_by": null,
"external_ids": [{"connection": "sis", "external_id": "10045"}],
"last_login_at": "2026-11-03T08:12:44Z", "created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-11-03T08:12:44Z",
"projects": [{"id": 7, "reference": "RAP-2026-0007", "title": "…", "status": "in_progress", "is_group": false}]
}
}6. Synchronising records
A connection (created by the Super Admin) describes your system and maps your field names to portal fields — for example your matric_no to the portal's student ID, or your programme code UG-CS to BSC-CSC. Records are matched to portal accounts by their link (your external ID), then by student / staff ID, then by e-mail address; they are then updated and linked, or created according to the connection's rules. Empty values never erase portal data.
Push: send up to 5,000 records per call to POST /sync/{connection-key}/students or /staff (scope sync) in your own format. Add "dry_run": true to see what would happen without saving.
curl -X POST https://rapp.simdols.com/api/v1/sync/sis/students \
-H "Authorization: Bearer $RAP_KEY" -H "Content-Type: application/json" \
-d '{"dry_run": true, "records": [
{"id": "10045", "matric_no": "CSC/2026/0101", "firstname": "Ngozi", "surname": "Okafor",
"email": "[email protected]", "programme": "UG-CS"}
]}'<?php
$records = $pdo->query('SELECT id, matric_no, firstname, surname, email, programme FROM students WHERE updated_at > NOW() - INTERVAL 1 DAY')->fetchAll(PDO::FETCH_ASSOC);
foreach (array_chunk($records, 1000) as $chunk) {
$res = rap('POST', '/sync/sis/students', ['records' => $chunk]);
$s = $res['data']['summary'];
echo "created {$s['created']}, updated {$s['updated']}, linked {$s['linked']}, errors {$s['errors']}\n";
foreach ($res['data']['results'] as $row) {
if ($row['action'] === 'error') {
echo " {$row['external_id']}: {$row['message']}\n";
}
}
}records = fetch_students_from_my_system() # list of dicts, your own field names
for i in range(0, len(records), 1000):
r = s.post(f"{API}/sync/sis/students", json={"records": records[i:i + 1000]}, timeout=120)
r.raise_for_status()
data = r.json()["data"]
print(data["summary"])
for row in data["results"]:
if row["action"] == "error":
print(row["external_id"], row["message"])Response: {"ok": true, "data": {"summary": {"total", "created", "updated", "linked", "unchanged", "skipped", "errors"}, "results": [{"n", "external_id", "name", "action", "message", "user_id"}], "run_id"}}. Errors in one record do not stop the others.
Pull: alternatively the portal reads your JSON endpoint (with bearer, basic, header or query-string authentication, and ?page= pagination) when the Super Admin clicks Synchronise now or on a schedule.
external_id— External ID (unique in the other system) requiredmatric_number— Matriculation Number requiredtitle— Titlefirst_name— First name requiredlast_name— Last name / surname requiredother_names— Other namesemail— E-mail address requiredphone— Phonedepartment— Department (code or name)programme— Programme (code or name)entry_session— Entry session (name)status— Account status
external_id— External ID (unique in the other system) requiredstaff_number— Staff ID requiredtitle— Titlefirst_name— First name requiredlast_name— Last name / surname requiredother_names— Other namesemail— E-mail address requiredphone— Phonedepartment— Department (code or name)designation— Designation / rankspecialisation— Specialisationmax_supervisees— Maximum superviseesstatus— Account status
7. Single sign-on
To open the portal for a signed-in user of your system, create a JSON Web Token signed with HMAC-SHA256 (HS256) using the connection's SSO secret, and redirect the user's browser to:
https://rapp.simdols.com/sso/{connection-key}?token=<JWT>&redirect=/dashboard Claims: sub (or the claim the Super Admin chose) = the user's ID in your system — the student / staff ID or e-mail address also work the first time, after which the account stays linked; exp (required, at most 5 minutes ahead); recommended iat and a unique jti (each token then works only once); optional aud = https://rapp.simdols.com. redirect must be a portal path. Accounts created as pending by a synchronisation are activated by their first single sign-on; suspended accounts are refused. The browser form can also POST token and redirect to the same URL.
<?php
function b64url(string $d): string { return rtrim(strtr(base64_encode($d), '+/', '-_'), '='); }
$secret = getenv('RAP_SSO_SECRET');
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$claims = b64url(json_encode([
'sub' => (string) $currentUser->id, // the user's ID in your system
'iat' => time(),
'exp' => time() + 120,
'jti' => bin2hex(random_bytes(16)),
]));
$token = "$header.$claims." . b64url(hash_hmac('sha256', "$header.$claims", $secret, true));
header('Location: https://rapp.simdols.com/sso/moodle?token=' . urlencode($token) . '&redirect=/dashboard');
exit;import time, secrets, jwt # pip install PyJWT
token = jwt.encode(
{"sub": str(user.id), "iat": int(time.time()), "exp": int(time.time()) + 120, "jti": secrets.token_hex(16)},
RAP_SSO_SECRET, algorithm="HS256")
return redirect(f"https://rapp.simdols.com/sso/portal?token={token}&redirect=/dashboard")import crypto from 'node:crypto';
const b64url = (b) => Buffer.from(b).toString('base64url');
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const claims = b64url(JSON.stringify({ sub: String(user.id), iat: now, exp: now + 120, jti: crypto.randomUUID() }));
const sig = crypto.createHmac('sha256', process.env.RAP_SSO_SECRET).update(`${header}.${claims}`).digest('base64url');
res.redirect(`https://rapp.simdols.com/sso/lms?token=${header}.${claims}.${sig}&redirect=/dashboard`);8. Webhooks
When a connection has a webhook URL, the portal POSTs the selected events to it as JSON. Respond with any 2xx status within 15 seconds; other responses are retried with increasing delays (6 attempts). Discussion messages, comments, documents and AI output are never sent.
POST /your-webhook HTTP/1.1
Content-Type: application/json
X-RAP-Event: stage.approved
X-RAP-Delivery: 5821
X-RAP-Timestamp: 1793785200
X-RAP-Signature: sha256=4f1c…
{
"id": "9b2e61c0a4f7d3e8b1c2a9f0",
"event": "stage.approved",
"occurred_at": "2026-11-04T09:40:00Z",
"summary": "Chapter One approved",
"project": {"id": 7, "reference": "RAP-2026-0007", "status": "in_progress", "title": "…", "programme_code": "MSC-CSC",
"current_stage": {"code": "chapter_two", "name": "Chapter Two"},
"student": {"id": 42, "matric_number": "CSC/2026/0101", "email": "…", "external_ids": [{"connection": "sis", "external_id": "10045"}]}},
"actor": {"id": 3, "name": "Dr Bola Adeyemi"},
"subject": {"type": "stage", "id": 12}
} Verify every delivery: compute HMAC-SHA256 of timestamp + "." + raw body with the connection's signing secret, compare it with the signature in constant time, and reject old timestamps (e.g. older than 5 minutes). Use the id to ignore duplicates.
<?php
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_RAP_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, getenv('RAP_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_RAP_SIGNATURE'] ?? '') || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// … handle $event['event'] …
http_response_code(204);import hmac, hashlib, time
from flask import Flask, request, abort
@app.post("/rap-webhook")
def rap_webhook():
ts = request.headers.get("X-RAP-Timestamp", "")
expected = "sha256=" + hmac.new(SECRET.encode(), f"{ts}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-RAP-Signature", "")) or abs(time.time() - int(ts or 0)) > 300:
abort(401)
event = request.get_json()
# … handle event["event"] …
return "", 204Events (also available by polling GET /events):
project.created Project createdproject.status Project status changedproject.completed Project finalisedproject.workflow_changed Research workflow changedsupervisor.assigned Supervisor assignedsupervisor.removed Supervisor removedsupervisor.lead_changed Lead supervisor changedtopic.submitted Topic proposals submittedtopic.approved Topic approvedtopic.assigned Topic assignedtopic.rejected Topic proposals rejectedtopic.revision_requested Topic revision requestedstage.started Research stage startedstage.approved Stage approvedsubmission.uploaded Document uploadedsubmission.resubmitted Revision submittedsubmission.rejected Submission rejectedreview.completed Review completedcorrections.requested Corrections requestedfinal.approved Final submission approvedmilestone.approved Milestone approvedmilestone.updated Milestone updatedgrade.recorded Grade recordedgroup.member_added Group member addedgroup.member_removed Group member removedA ping event is sent when the Super Admin clicks Send a test event.
9. Scheduled tasks (portal server)
Webhooks are delivered and pull connections synchronised by command-line tasks. The portal's regular php bin/rap cron job (every 15 minutes) already sends due webhooks; for near-real-time delivery also run webhooks:send every minute. Nightly pulls are scheduled separately:
# Linux crontab
*/15 * * * * php /path/to/rap/bin/rap cron
* * * * * php /path/to/rap/bin/rap webhooks:send
30 1 * * * php /path/to/rap/bin/rap integrations:sync # every active pull connection
# Windows (XAMPP) Task Scheduler, action:
C:\xampp\php\php.exe C:\xampp\htdocs\rap.simdols.com\bin\rap integrations:sync sis students