Moodle's web services let external systems read and write data through a single REST endpoint. Getting from off-by-default to a production-grade integration requires understanding the authorization layers, the functions worth knowing, and the failure modes that only surface under real load.

How Moodle's web services work

Moodle exposes a single HTTP endpoint — webservice/rest/server.php — that dispatches calls to hundreds of registered functions. Each function has a name like core_user_get_users_by_field or core_enrol_get_enrolled_users and a typed parameter schema. Every call follows the same shape:

POST /webservice/rest/server.php
  ?wstoken=<TOKEN>
  &wsfunction=<FUNCTION_NAME>
  &moodlewsrestformat=json

Body (application/x-www-form-urlencoded):
  <function-specific parameters>

Both GET and POST are accepted — Moodle's own documentation describes this as "not RESTful". Every call, regardless of HTTP method, goes to the same server.php script. The moodlewsrestformat=json parameter controls response format; without it you get XML. Most application-level errors are returned as JSON in the response body with HTTP 200, so your integration must parse the payload rather than rely only on the status code. The exception is infrastructure-level failures: if the REST protocol is disabled in Moodle, the server returns HTTP 403 before any application code runs.

Arrays in the POST body follow PHP's bracket notation, not JSON. A call to core_user_create_users passes its payload as users[0][username]=alice&users[0][email]=alice@example.com&users[0][password]=... — not as a JSON array. Sending JSON will produce an invalidparameter error with no further explanation.

Three conditions must all be true before a call succeeds: web services are enabled globally (they are off by default), the function is included in an external service that your caller is authorized to use, and the calling user holds the capabilities that the function requires. Miss one and the call fails — typically with a webservice_access_exception for access control problems, or a moodle_exception for conditions like a suspended account, an expired token, or site maintenance mode. Most integration failures trace back to a capability that was never explicitly granted.

On protocols: Moodle officially supports REST and SOAP. XML-RPC was removed from Moodle core in 4.1 when PHP dropped the php-xmlrpc extension; a third-party plugin exists for sites that need it but it should not be used for new integrations. SOAP has known WSDL compatibility issues with Java and .NET stacks (open tracker items MDL-28988 and MDL-28989). Use REST for all new work.

The authorization model

Authorization works in three stacked layers. Understanding all three before you write a line of code saves hours of debugging later.

External services

An external service is a named bundle of functions. You create one per integration — "HR Sync Service", "Reporting Exporter" — and explicitly add the functions that integration will call. Functions not in the service cannot be called regardless of what capabilities the token holder has. The service can be restricted to a list of authorized users, which gives you a second line of control beyond the token itself.

The service user

A token belongs to a Moodle user. For integrations, create a dedicated service user per integration. Do not reuse a human admin account. Human accounts change passwords, get suspended when staff leave, and make audit logs unreadable. A service user named svc-hr-sync or svc-reporting makes every log entry attributable and keeps capability grants minimal. Give it a real email address so account recovery works, and disable password-based web login — the account should never be used interactively.

Capability checks in Moodle are not always binary. core_user_get_users_by_field, for example, does not simply refuse calls from users without all listed capabilities — it filters which fields are returned based on what the caller can see. A service user with only moodle/user:viewdetails can call the function successfully but will receive fewer fields than a user with moodle/user:viewalldetails. Test with the minimal role first and add capabilities only when you observe missing data.

The role and capabilities

Create a dedicated role per integration. Start from "No role" or "Authenticated user" — not "Manager" — and grant only the capabilities the integration's functions require. A good starting point for discovering which capabilities a function needs is the auto-generated documentation at /admin/webservice/documentation.php on your own Moodle instance, filtered to your integration's external service. The listed capabilities are indicative — the actual runtime checks inside each function can be more granular and context-dependent, so treat the documentation page as a starting point and test with the real account under real conditions.

Assign the role at the system context for site-wide integrations. If any function needs to operate within a specific course or category, assign the role there as well. That is where most "works in dev, fails in production" bugs originate.

Tokens

The token ties everything together. It belongs to one user, is restricted to one external service, can be locked to a specific egress IP, and can be revoked at any time from the admin UI. Token metadata is stored in mdl_external_tokens, including iprestriction (comma-separated IPs or ranges), validuntil (Unix timestamp, 0 for no expiry), and lastaccess.

Treat tokens like credentials. Store them in a secret manager — Vault, AWS Secrets Manager, or your deployment environment's equivalent. Never put a token in a URL query string that hits a log. Even though the REST convention uses ?wstoken=..., pass it as a POST body parameter in systems that log full request URLs.

Set an expiry date when you create the token and plan for rotation from day one: create a new token before the old one expires, deploy it, verify it works, then revoke the old one. Moodle supports overlapping tokens for the same user and service, so rotation can be zero-downtime. Ninety days is a common rotation cadence in enterprise environments, though the right interval depends on your organization's security policy.

There are no native Moodle CLI scripts for token management. For scripted provisioning, the options are moosh webservice-install (creates user, role, capabilities, service, and token in a single command), a bootstrap script using Moodle's external_generate_token() API, or the HTTP endpoint at /login/token.php?username=USER&password=PASS&service=SHORTNAME for non-admin accounts — though this will fail silently if the site enforces multi-factor authentication via tool_mfa, since the script cannot handle an MFA challenge. On enterprise Moodle instances where MFA is mandatory, moosh or a bootstrap script is the only reliable programmatic path.

Enabling web services end to end

1. Enable web services globally

Site administration Advanced features Enable web services.

2. Enable the REST protocol

Site administration Server Web services Manage protocols. Enable REST.

3. Create the service user

Site administration Users Accounts Add a new user. Use a descriptive username like svc-hr-sync. Set a long random password — Moodle requires one, but the account will not log in interactively. Use a team inbox for the email address.

4. Create the dedicated role

Site administration Users Permissions Define roles Add a new role. Set the context types to System (add Course and Category if your functions need those). Grant only the capabilities the integration's functions require.

5. Assign the role

Site administration Users Permissions Assign user roles System context. Add the service user with the role you just created.

6. Create the external service

Site administration Server Web services External services Custom services Add. Name it after the integration, set Enabled to yes, and enable Authorized users only. Under Functions, add exactly the functions the integration will call — not a broad selection, just the specific ones. Under Authorized users, add the service user.

7. Create the token

Site administration Server Web services Manage tokens Add. Set the user, the service, an IP restriction if your caller has a stable egress IP, and a Valid until date. Copy the token to your secret store immediately — you cannot retrieve it again.

Smoke test

With the token in hand, confirm everything is wired correctly:

curl -s "https://your-moodle.example.com/webservice/rest/server.php" \
  -d "wstoken=${MOODLE_TOKEN}" \
  -d "wsfunction=core_webservice_get_site_info" \
  -d "moodlewsrestformat=json" | jq

core_webservice_get_site_info requires no capabilities beyond a valid token and a working service. A response containing sitename and functions means the plumbing is working. If it fails, the JSON body will tell you why — web services not enabled, protocol not enabled, token invalid or expired, function not in the service, or the service user missing a capability.

The function catalog

Moodle has roughly 800 web service functions. Most integrations use twenty of them.

The authoritative reference for your instance is at /admin/webservice/documentation.php, filtered to your integration's external service. It shows each function's parameter schema, return schema, and required capabilities for your exact Moodle version — more reliable than any third-party list.

User lookups

core_user_get_users_by_field — exact-match lookups on a single indexed field: id, idnumber, username, or email. Accepts an array of values and returns matching user records. For broader filtering across multiple criteria, core_user_get_users accepts key/value pairs — but note that neither function supports filtering by timemodified, which has implications for delta sync covered in the next section.

User lifecycle

core_user_create_users and core_user_update_users handle provisioning and updates. For deprovisioning, prefer update_users with suspended = 1 over core_user_delete_users. Calling delete_users triggers Moodle's soft-delete routine: the row is kept, but the username is rewritten to a generated string and the email is replaced with an MD5 hash — destroying that data irreversibly. The original username and email are freed by the scramble and become available for reuse, but there is no way to recover what they were. Suspension is the safer option when you may need to reverse the action later.

Cohorts

core_cohort_get_cohorts, core_cohort_add_cohort_members, and core_cohort_delete_cohort_members cover cohort management. The separate core_cohort_get_cohort_members function returns all members for the given cohort IDs in a single response — it has no limitfrom or limitnum parameters. For large cohorts this produces a very large payload and can hit PHP memory limits. If that is a concern, fetching enrolled users per course with core_enrol_get_enrolled_users (which does support pagination) is often a better approach.

Enrolments

enrol_manual_enrol_users and enrol_manual_unenrol_users handle enrolment via the manual method, which must be enabled in each course. core_enrol_get_enrolled_users retrieves enrolled users with limitfrom and limitnum for pagination.

Courses and completion

core_course_get_courses_by_field and core_course_create_courses cover course lifecycle. The field lookup function accepts a sectionid parameter from Moodle 4.5 onward. core_completion_get_course_completion_status returns per-user course completion status.

Rate limiting and self-throttling

Moodle has no built-in rate limiting for web service callers. Your integration can saturate the application server during a bulk sync and block login requests for real users. All throttling must come from your side.

As a starting point for your own tuning: two concurrent connections per Moodle instance, 100–250 ms sleep between sequential calls, and a cap of 60–120 calls per minute depending on function cost. These are operational heuristics, not documented Moodle limits — adjust based on observed server load. Course search functions and anything that fans out across many users are expensive at the database layer.

If Moodle sits behind a reverse proxy you control, enforce a rate limit there as a second line of defense. An nginx example for the web services endpoint:

limit_req_zone $binary_remote_addr zone=moodle_ws:10m rate=60r/m;

location /webservice/rest/server.php {
    limit_req zone=moodle_ws burst=10 nodelay;
}

Beyond per-call throttling, implement a circuit breaker: track response times alongside error rates. When Moodle starts responding slowly — not just erroring — back off immediately. A Moodle under load often shows elevated response times for 30–60 seconds before it starts returning errors. If your integration waits for errors before backing off, the window of damage is already large.

A simple circuit breaker has three states: closed (normal operation), open (backing off, not calling Moodle), and half-open (a probe call to test recovery). Open the circuit when your error rate or response time exceeds a threshold over a rolling window. Move to half-open after a fixed backoff period. Resume normal operation only after a successful probe.

Sync architecture

The timemodified limitation

The most common mistake in Moodle integration design is using mdl_user.timemodified as a cursor for delta sync — "give me all users updated since timestamp X". The field is updated when core_user_update_users is called, but not when an auth plugin synchronizes the account, not when a user's enrolments or profile fields change, and not when an admin's SSO action updates related tables. For many real integration scenarios, timemodified misses a significant fraction of the changes you care about.

Making this worse: neither core_user_get_users_by_field nor core_user_get_users supports filtering by timemodified. If you want delta sync by timestamp, you need a custom web service function, a direct database query via a local plugin, or a different strategy entirely.

Maintaining sync state on your side

The most reliable approach is to maintain sync state in your own integration's database, not to query Moodle for what changed:

sync_records(
  external_id      VARCHAR  -- ID in the source system (HR, CRM, etc.)
  moodle_id        INT      -- mdl_user.id once known
  external_hash    VARCHAR  -- hash of the last synced payload
  synced_at        DATETIME
  sync_status      ENUM('ok', 'pending', 'error', 'conflict')
)

On each sync run, pull the current state from your source system. Compare each record's hash against the stored hash. Send only records where the hash changed. This is independent of Moodle's timemodified and survives the cases where Moodle's internal timestamps are not updated.

Bootstrap versus incremental

The first sync run is fundamentally different from subsequent ones. It may cover tens of thousands of records and take hours. Build a dedicated bootstrap path: process in batches of 100–500 records, committing your sync state after each batch so a failure mid-run is resumable; look up each record with core_user_get_users_by_field before deciding create or update; and log a start and end timestamp for the bootstrap run so you know exactly what was processed.

Once the bootstrap is complete, switch to incremental runs that only process records where the hash changed since the last successful sync.

Reconciliation

Even with a well-designed incremental sync, drift accumulates. Events are missed, errors go unretried, records are created in Moodle through another path. A nightly reconciliation run that does a full diff — source system vs. Moodle — catches everything the incremental path missed. It does not need to be fast; it runs when the site is quiet. What it produces is a list of discrepancies that the reconciliation logic then resolves, typically with the source system winning.

Concurrent write hazards

InnoDB locking and your own integration

Moodle does not use application-level locking for web service handlers. Two simultaneous calls to core_user_update_users for the same user serialize at the InnoDB row-lock level. One call waits; if the wait exceeds innodb_lock_wait_timeout (typically 50 seconds on a default MySQL install), you receive a dmlwriteexception. This is generally safe to retry — it was a timeout, not a logic error.

A dmlwriteexception is Moodle's catch-all for any database write failure. The underlying cause matters for retry decisions: a deadlock or lock timeout is transient and safe to retry with backoff; a unique constraint violation (duplicate username, duplicate email) is a logic error and should not be retried until the conflict is resolved. Parse the exception message to distinguish them before deciding what to do.

Collision with Moodle's own scheduled tasks

Moodle's HR Import plugin, scheduled tasks that manage enrolments or cohorts, and other background processes write to the same tables your integration writes to. They use Moodle's Lock API for application-level coordination with each other — but that lock does not extend to web service calls. The only contention between them and your integration is at the InnoDB level.

The practical consequence: if an HR Import run is bulk-updating thousands of users inside a transaction and your integration sends a write for one of those users, you will wait on the row lock. If the transaction is large and slow, you may timeout. Schedule integration sync runs so they do not overlap with known-expensive Moodle tasks, and treat dmlwriteexception with a lock-timeout message as a signal to back off and retry rather than immediately failing.

Running multiple instances of your integration

If your integration ever runs as multiple parallel processes — because of a deployment overlap, a manual rerun, or horizontal scaling — they will contend with each other. Add a distributed lock before starting a sync run. A simple file lock (flock) is enough on a single server. For multi-server deployments, use your database, Redis, or another shared lock source.

Error handling and recovery

Error response format

Moodle web service errors always arrive with HTTP 200 and a JSON body:

{
  "exception": "invalid_parameter_exception",
  "errorcode": "invalidparameter",
  "message": "Invalid parameter value detected",
  "debuginfo": "Username already exists: testuser1"
}

The debuginfo field is only present when $CFG->debug is set to NORMAL or higher. On production Moodle instances with debugging disabled, debuginfo is absent. Your error logging must handle both formats and record message (always present) as the primary human-readable error string.

Retry taxonomy

Not every error should be retried. Getting this wrong creates self-inflicted load spikes.

HTTP 502/503/504 from the web server, dmlwriteexception with a deadlock or timeout message, and connection timeouts are transient. Retry with exponential backoff.

invalidparameter, schema mismatch errors, and dmlwriteexception with a constraint violation message are client faults. Do not retry. Log the full error, alert, and pause until the issue is diagnosed.

invalidtoken, accessexception, and requiredcapability mean authorization has broken. Stop the run immediately. Do not retry any further calls until the authorization problem is resolved. These errors during a batch sync should page an operator — continuing the run just accumulates failures and masks the actual problem.

Business-logic errors like usernametaken and emailalreadyexists are conflicts, not failures. Surface them to the source system, record them in your sync state, and move on to the next record.

Exponential backoff with jitter

When retrying transient errors, use exponential backoff with added jitter to avoid thundering-herd effects when multiple processes back off simultaneously:

import random, time

def retry_with_backoff(fn, max_attempts=5, base_delay=1.0, max_delay=60.0):
    for attempt in range(max_attempts):
        try:
            return fn()
        except TransientError as e:
            if attempt == max_attempts - 1:
                raise
            delay = min(base_delay * (2 ** attempt), max_delay)
            jitter = random.uniform(0, delay * 0.2)
            time.sleep(delay + jitter)

Cap the total number of retry attempts. After the cap, write the record to a dead-letter store — a database table or queue — for manual review or a later automated retry pass. Do not discard failures silently.

Authorization failures mid-batch

If an accessexception or invalidtoken arrives partway through a long sync run, stop immediately. Do not log it as a single-record error and continue — every subsequent call will fail the same way, producing hundreds of identical errors unnecessarily. Abort the run, record which records were processed and which were not in your sync state, and alert. The run can resume cleanly once the authorization issue is fixed.

Audit logging

Moodle's internal logging records that web service calls occurred but omits what you need for operational visibility: request parameters, response outcome, call duration, and the caller's IP as seen from your integration.

Log per call: function name, parameters (redacted where sensitive), response outcome, duration, and HTTP status. Log per sync run: records in, records out, records skipped, records errored, and total duration. Log the last-used timestamp per token as a cross-check for misuse.

In regulated environments, your logs combined with Moodle's internal logs typically satisfy auditors. Without them, you cannot reconstruct what your integration did six months ago when someone asks.

Monitoring

Moodle has a Check API (available via admin/cli/checks.php on the command line, and surfaced in various admin report pages) that covers cron health, failed tasks, and configuration problems. Moodle core does not ship an unauthenticated HTTP health check endpoint. The Catalyst IT tool_heartbeat plugin fills that gap: it adds a lightweight HTTP probe that returns 200 when the site is healthy and 503 when it is not, compatible with Nagios, Icinga, and load balancer health checks.

For integration-specific monitoring, track these signals:

Use core_webservice_get_site_info as a heartbeat probe. Run it every few minutes from your integration host; a failure or timeout indicates a network or Moodle availability problem before your next real sync run hits it.

Track error rates by category (transient, client fault, authorization, business) over a rolling window. Transient errors up to 5–10% in a single run are typically noise on a busy site. Sustained transient errors above that threshold, or any authorization error, should alert immediately.

Track sync run duration and records-per-minute. A run that takes twice as long as usual is worth investigating even if no errors appear — Moodle under database load often shows elevated response times long before it starts returning errors.

Alert on: any invalidtoken or accessexception; any sync run that fails to complete within a defined time budget; transient error rate sustained above threshold; token age within 30 days of expiry. Do not alert on individual transient errors that resolve in the next attempt, or on usernametaken/emailalreadyexists conflicts.

Poll versus event-driven

Most integrations start as a polling cron job and later need event-driven updates. Planning for both from the start is cheaper than retrofitting.

Polling works well when data volume is low to medium, latency of minutes to hours is acceptable, and Moodle is the target system rather than the source of change events. Common traps: the timemodified limitation described above means polling Moodle for "what changed" is unreliable for most record types; the first sync run is far larger than subsequent ones and needs its own path; overlapping runs need a lock.

When you need near-real-time updates, or when Moodle needs to push changes to an external system, an event observer plugin is the right tool. Keep it thin — its only job is to detect the event and enqueue a task. A minimal observer in a local plugin:

db/events.php:

$observers = [
    [
        'eventname' => '\core\event\user_updated',
        'callback'  => '\local_pluginname\observer::user_updated',
        'priority'  => 0,
        'internal'  => false,
    ],
];

classes/observer.php:

namespace local_pluginname;

class observer {
    public static function user_updated(\core\event\user_updated $event): void {
        $task = new \local_pluginname\task\sync_user();
        $task->set_custom_data(['userid' => $event->objectid]);
        \core\task\manager::queue_adhoc_task($task, true);
    }
}

Setting internal to false in db/events.php ensures the observer does not fire inside an open database transaction — if it did, a task queued inside an uncommitted transaction may be lost when the transaction rolls back. The second argument to queue_adhoc_task deduplicates by custom data, preventing the same user from accumulating dozens of identical tasks under rapid edits.

The actual HTTP delivery belongs in the ad-hoc task class, not the observer. Doing I/O synchronously in an observer adds latency to Moodle's own request cycle.

In practice, most production integrations end up hybrid: event-driven for high-value changes like user deactivation, enrolment, and completion, with a nightly reconciliation run that catches anything the event path missed.

Version compatibility

Moodle's web service API is generally stable between minor versions. Between major versions, check for deprecations.

Moodle 4.5 LTS receives security fixes until late 2027 (36 months from its October 2024 release) and is the safe baseline for customers on a long-term support track. Functions deprecated in 4.5 will not be finally removed until 6.0 under the current deprecation policy — a more stable window than earlier major-version transitions. Moodle 5.0 removed functions deprecated before 4.5 (MDL-84036). Moodle 5.1 introduced a /public/ directory structure: the web server document root moves on disk, but endpoint URLs that integrations call remain unchanged.

If your integration uses module-level functions for quiz, assign, or forum, test against both 4.5 and 5.1 separately. The core user, cohort, enrolment, course, and completion functions covered in this guide have no confirmed deprecations or removals between 4.5 and 5.1 as of this writing.

For your integration's test suite: contract tests against a live Moodle instance per supported major version, smoke tests on every deployment, and a regression set covering your most-used functions running nightly. The Moodle team publishes Docker images for CI use. Track the functions you call on the Moodle Tracker and subscribe to release notes — a deprecated function typically has two major versions before removal.

Common pitfalls

Service account auto-enrolled as teacher in courses it creates

If your integration creates courses via core_course_create_courses and the service account shows up as an enrolled teacher in those courses, the cause is almost always a role assigned at system or category context that carries teacher-level capabilities. The account inherits that role into every course under the context, which is why it appears as teacher everywhere — not just in courses it created.

The fix is to review the role assignment at the system or category level and tighten it to the capabilities the integration actually needs, such as moodle/course:create and any others the integration genuinely uses.

One setting worth ruling out separately: “Creators’ role in new courses” under Site administration > Users > Permissions > User policies. Based on the Moodle 4.5 source, this setting fires in the UI course creation path (course/edit.php) and in the course request approval path, but not in the web service function core_course_create_courses. So it is unlikely to be the cause for a pure API integration. It still matters if the same account also creates courses through the UI, or if the site uses course requests.

Where integrations grow beyond their original scope

A few integration shapes reliably consume more time than their initial estimate suggests.

Multi-site or federated Moodle is one. Capability and identity management across instances is not simply doing the same thing twice — token scope, role context, and user identity handling all compound.

Writing to module submissions from outside is another. The web services for mod_assign and mod_quiz were designed for the mobile app, not for external systems. External writes to submissions frequently require workarounds that break when Moodle updates its internal module logic.

Reporting dashboards built directly on web service calls do not scale. At volume you need a read replica or a data warehouse export, not another polling loop against core_course_* and core_completion_*.

Custom profile field synchronization is a slow-burn problem. Profile field metadata drifts between environments and between Moodle versions. It needs to be treated as schema, not just data, with explicit version management from day one.

If any of those describe what you are building, sort out the architecture before you sort out the code. If you want a second pair of eyes on a Moodle integration, get in touch.

Solin specializes in Moodle and Totara integrations with HR, CRM, and ERP systems.

Contact us