LifterLMS AI Agents: The WordPress Abilities API, Used Safely
What LifterLMS 10.x registers with the WordPress Abilities API, how an AI agent can discover and run those abilities over REST or MCP, and the…
Read articleIf a customer pays in another system, such as a CRM, an invoicing tool, a partner portal or your own SaaS product, they still need a LifterLMS account and access to the right course. The LifterLMS API can enroll students from that external app without anyone touching the WordPress admin.
This walk-through is for developers who already know what a REST request is. You will build the full flow: find a student by email, create the account if it does not exist, enroll them in a course or membership, and take access away again without breaking enrollments that came from somewhere else. Routes and parameters were checked against the REST API bundled with LifterLMS 10.3.
If you have not created an API key or made a first request yet, read our introduction to the LifterLMS REST API and webhooks first. This article picks up where that one stops.
An external app usually knows three things: the customer's email, their name, and which product they bought. LifterLMS identifies students by WordPress user ID, so the job is to turn an email into an ID and then enroll that ID.
GET /wp-json/llms/v1/students with a search on the email column.POST /wp-json/llms/v1/students.POST /wp-json/llms/v1/students/{id}/enrollments/{post_id}, where post_id is a course or membership ID.Every call needs HTTPS and an API key with write permission. Create a dedicated WordPress user for the integration and give it the LMS Manager role. That role has the capabilities the enrollment routes check, such as create_students, enroll and unenroll. An Instructor does not, so a key tied to an instructor account will be refused.
The students collection supports search and search_columns. Limit the search to the email column so a partial name match cannot return the wrong person:
curl -G "https://example.com/wp-json/llms/v1/students" \
-u "ck_your_key:cs_your_secret" \
--data-urlencode "search=jane@example.com" \
--data-urlencode "search_columns=email" \
--data-urlencode "context=edit"
The response is a JSON array. If it contains one student, use its id. If it is empty, go to step 2. Adding context=edit returns the email and name fields so you can confirm the match; the default view context leaves them out.
Searching only finds users who already exist in WordPress. A customer who registered on the site through a normal checkout is found here, which is exactly what you want: one person, one account.
Only email is required. Send the name too, because certificates, emails and reports use it:
curl -X POST "https://example.com/wp-json/llms/v1/students" \
-u "ck_your_key:cs_your_secret" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com","first_name":"Jane","last_name":"Doe"}'
A few behaviours are worth knowing before you rely on this route:
username, LifterLMS builds one from the email. If you leave out password, it generates a random one. Do not send passwords from your external app unless you really have to.roles, but only roles the API user is allowed to assign.billing_address_1, billing_city, billing_postcode, billing_country and the rest map to the same user fields the checkout form fills in.existing_user_email. Treat that as "go back to step 1", not as a fatal error.The success response has status 201 and includes the new id.
Creating a student through the API fires the llms_rest_student_registered action. LifterLMS uses it in two places: the Student Welcome notification and engagements set to the Student creates a new account trigger. The welcome email contains the student's login, a link to the dashboard and a password reset link, so a student with a generated password can still get in. Check that notification is set up the way you want under LifterLMS > Settings > Notifications before you send real customers through the flow.
The enrollment route takes the student ID and the course or membership ID in the URL. The optional trigger parameter records why the enrollment exists:
curl -X POST \
"https://example.com/wp-json/llms/v1/students/123/enrollments/456" \
-u "ck_your_key:cs_your_secret" \
-H "Content-Type: application/json" \
-d '{"trigger":"crm_sync"}'
Without a trigger the enrollment is stored as unspecified. Always send one. It costs nothing now and, as step 5 shows, it makes removing access much safer later.
A successful call returns 201 with student_id, post_id, status, trigger and dates. The enrollment goes through the same code as any other enrollment, so welcome engagements, enrollment notifications and webhooks fire as usual. Enrolling in a membership also runs the membership's own rules, including any courses it is set to enroll members in automatically.
Two things the call does not do: it does not create an order, and it does not apply an access plan. The student has access with no expiry until something removes it. If access should end on a date or be tied to a payment, either sell through a LifterLMS access plan or have the external app remove the enrollment when the time comes.
Here is the whole flow as a single function that an external PHP application could call after a sale. It uses plain cURL, so it does not need WordPress on the calling side.
<?php
function llmsx_api( $method, $path, $body = null ) {
$base = 'https://example.com/wp-json/llms/v1';
$ch = curl_init( $base . $path );
curl_setopt_array( $ch, array(
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'X-LLMS-CONSUMER-KEY: ' . getenv( 'LLMS_CK' ),
'X-LLMS-CONSUMER-SECRET: ' . getenv( 'LLMS_CS' ),
),
) );
if ( null !== $body ) {
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $body ) );
}
$raw = curl_exec( $ch );
$status = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
return array( $status, json_decode( (string) $raw, true ) );
}
function llmsx_enroll_customer( $email, $first, $last, $product_id ) {
// 1. Look up by email.
$query = http_build_query( array(
'search' => $email,
'search_columns' => 'email',
'context' => 'edit',
) );
list( $status, $found ) = llmsx_api( 'GET', '/students?' . $query );
if ( 200 === $status && ! empty( $found[0]['id'] ) ) {
$student_id = (int) $found[0]['id'];
} else {
// 2. Create the student.
list( $status, $created ) = llmsx_api( 'POST', '/students', array(
'email' => $email,
'first_name' => $first,
'last_name' => $last,
) );
if ( 201 !== $status ) {
throw new RuntimeException( 'Create failed: ' . json_encode( $created ) );
}
$student_id = (int) $created['id'];
}
// 3. Enroll with a trigger that identifies this integration.
list( $status, $enrollment ) = llmsx_api(
'POST',
"/students/{$student_id}/enrollments/{$product_id}",
array( 'trigger' => 'crm_sync' )
);
// 400 here usually means the enrollment already exists.
if ( ! in_array( $status, array( 201, 400 ), true ) ) {
throw new RuntimeException( 'Enroll failed: ' . json_encode( $enrollment ) );
}
return $student_id;
}
Keep the key and secret in environment variables, as above, or in your platform's secret store. Never put them in a front-end script: anyone who can read the page could then enroll themselves in anything.
If the student is already enrolled, the POST returns 400 with the message "Cannot create existing enrollment. Use the PATCH method if you want to update an existing enrollment". That is normal on a repeat purchase or a retried job.
The enrollment statuses are enrolled, expired and cancelled. A student whose access expired still has an enrollment record, so a renewal is a status change rather than a new enrollment:
curl -X PATCH \
"https://example.com/wp-json/llms/v1/students/123/enrollments/456" \
-u "ck_your_key:cs_your_secret" \
-H "Content-Type: application/json" \
-d '{"status":"enrolled"}'
A simple rule for your sync code: try POST; on a 400, read the enrollment with GET on the same route; if its status is not enrolled, PATCH it back. That makes the operation safe to repeat, which matters when jobs are retried after a timeout.
When a subscription in the external system ends, the external app should take access away. You have two options:
| Call | Effect | Use it when |
|---|---|---|
PATCH with "status":"cancelled" or "expired" | Access ends; progress, grades and history stay | Almost always. The student can be re-enrolled later and picks up where they left off |
DELETE | The enrollment record is erased | Only to clean up a mistake, such as enrolling the wrong person |
This is where the trigger pays off. The PATCH and DELETE routes accept the same trigger parameter. If you send "trigger":"crm_sync", LifterLMS only acts when the stored trigger matches. When it does not match, for example because the student later bought the same course through your LifterLMS checkout, the request returns a not found error and the paid access stays in place. Without a trigger the default is any, which removes access however it was granted.
curl -X PATCH \
"https://example.com/wp-json/llms/v1/students/123/enrollments/456" \
-u "ck_your_key:cs_your_secret" \
-H "Content-Type: application/json" \
-d '{"status":"cancelled","trigger":"crm_sync"}'
email.enrollment.created and enrollment.updated webhooks so the external app knows about enrollments it did not create.No. Each enrollment is one request to one student and one course or membership. If several courses always go together, put them in a membership with automatic course enrollment and enroll the student in the membership instead.
Yes, if you have one set up. The API calls the same enrollment method as the admin and checkout, so notifications and engagements tied to enrollment run normally.
Yes, on an existing enrollment. PATCH accepts date_created, which is useful when you migrate students and want reports to show the original start date.
The API is plain REST with JSON, so any HTTP client works. The LifterLMS REST API reference documents every route and field.
Build the flow against a staging site first, with a test course and a key tied to a test LMS Manager account. Run each step by hand with curl, then wire the function above into your app's background job runner and test a repeat purchase, a renewal and a cancellation.
If the external system also needs progress and completions back, or you are deciding between the API, webhooks and a no-code tool, see our overview of LifterLMS integrations. When enrollment logic decides who gets paid access, it is worth having it designed with retries, logging and reconciliation from day one. Our LifterLMS integrations service does exactly that, and you can tell us which systems need to connect.
What LifterLMS 10.x registers with the WordPress Abilities API, how an AI agent can discover and run those abilities over REST or MCP, and the…
Read article
What the LifterLMS WP-CLI commands bundled with LifterLMS 10.x can do: list and create courses, read course structure and enrollments, manage add-ons…
Read article
A practical reference to the LifterLMS hooks developers use most: enrollment, completion, quiz, order and registration actions, plus the filters that…
Read articleSkip the trial and error. Get specialist LifterLMS help and ship faster.