Skip to content
OS Digital EducationResearch Assessment Portal Sign in

API documentation

REST API v1 · single sign-on · webhooks — for developers integrating an LMS, student information system, student portal or script with the portal.
The API is currently switched off on this portal: requests are refused with HTTP 503 until the Super Admin switches it on (Settings → Integrations & API).

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:

ScopeAllows
read:academicRead departments, programmes, academic sessions and research workflows
read:studentsRead students and their profiles
write:studentsCreate and update students
read:staffRead supervisors / staff and their profiles
write:staffCreate and update supervisors / staff
read:projectsRead research projects, supervisors, milestones, submissions (metadata) and grades
write:projectsCreate research projects
read:eventsRead the activity feed (events)
syncPush 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).

StatusMeaning
200 / 201Success / created
400The body is not valid JSON
401Missing, invalid, revoked or expired key
403The key lacks the scope, or your IP address is not allowed
404Record or endpoint not found
405Wrong HTTP method for this endpoint
409 / 422Business rule or validation error — see message / errors
429Too many requests
503The 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.

GET/pingCheck the key
Returns the portal name and version and the key's scopes. Use it to test a new key.
GET/departmentsList departments read:academic
GET/programmesList programmes read:academic
Each programme with its department, degree level and default research workflow.
GET/sessionsList academic sessions read:academic
GET/workflowsList research workflows read:academic
Workflows (stage sets) with their stages in order.
GET/studentsList students read:students
Paginated. Filter with the query parameters; combine updated_since with your last sync time for incremental syncs.
page (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
GET/students/{id}Get a student read:students
Includes the student's projects.
id (path, integer) Portal ID
POST/studentsCreate a student write:students
Required: matric_number, first_name, last_name, email. Use department_code / programme_code / entry_session instead of ids if you prefer. activation: none (pending, default), invite or default_password. Send external_id + connection to link the account to a connected system.
PATCH/students/{id}Update a student write:students
Partial update: only the fields you send change. status may be active, suspended or deactivated. POST to the same URL also works.
id (path, integer) Portal ID
GET/staffList supervisors / staff read:staff
Paginated.
page (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
GET/staff/{id}Get a staff member read:staff
Includes the projects they supervise.
id (path, integer) Portal ID
POST/staffCreate a supervisor write:staff
Required: staff_number, first_name, last_name, email. Administrator roles cannot be granted through the API.
PATCH/staff/{id}Update a supervisor write:staff
Partial update. POST to the same URL also works.
id (path, integer) Portal ID
GET/projectsList research projects read:projects
Paginated.
page (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
GET/projects/{id}Get a project read:projects
With members, milestones, submissions (metadata only — documents are not exposed) and grades.
id (path, integer) Portal ID
POST/projectsCreate a project write:projects
Required: student_id or matric_number, and lead_supervisor_id or lead_staff_number. Optional: programme_id / programme_code (defaults to the student's programme), session_id / session, co_supervisor_ids / co_staff_numbers, submission_deadline.
GET/eventsActivity feed read:events
Research events in order. Poll with since_id = meta.next_since_id from the previous call. The same events can be pushed to you as webhooks.
since_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
POST/sync/{key}/studentsPush student records sync
Send records in your own format: they are converted with the connection's field mappings, matched (link → student ID → e-mail), updated, linked or created. Body: a JSON array or {"records": [...], "dry_run": false}. At most 5000 records per call.
key (path, string) Connection keydry_run (query, boolean) Preview without saving
POST/sync/{key}/staffPush staff records sync
As above, with the staff field mappings.
key (path, string) Connection keydry_run (query, boolean) Preview without saving

Example 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.

Student fields that can be mapped
  • external_id — External ID (unique in the other system) required
  • matric_number — Matriculation Number required
  • title — Title
  • first_name — First name required
  • last_name — Last name / surname required
  • other_names — Other names
  • email — E-mail address required
  • phone — Phone
  • department — Department (code or name)
  • programme — Programme (code or name)
  • entry_session — Entry session (name)
  • status — Account status
Staff fields that can be mapped
  • external_id — External ID (unique in the other system) required
  • staff_number — Staff ID required
  • title — Title
  • first_name — First name required
  • last_name — Last name / surname required
  • other_names — Other names
  • email — E-mail address required
  • phone — Phone
  • department — Department (code or name)
  • designation — Designation / rank
  • specialisation — Specialisation
  • max_supervisees — Maximum supervisees
  • status — 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 "", 204

Events (also available by polling GET /events):

project.created Project created
project.status Project status changed
project.completed Project finalised
project.workflow_changed Research workflow changed
supervisor.assigned Supervisor assigned
supervisor.removed Supervisor removed
supervisor.lead_changed Lead supervisor changed
topic.submitted Topic proposals submitted
topic.approved Topic approved
topic.assigned Topic assigned
topic.rejected Topic proposals rejected
topic.revision_requested Topic revision requested
stage.started Research stage started
stage.approved Stage approved
submission.uploaded Document uploaded
submission.resubmitted Revision submitted
submission.rejected Submission rejected
review.completed Review completed
corrections.requested Corrections requested
final.approved Final submission approved
milestone.approved Milestone approved
milestone.updated Milestone updated
grade.recorded Grade recorded
group.member_added Group member added
group.member_removed Group member removed

A 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