LifterLMS API: Enroll Students From an External App

A code walk-through for creating students and enrollments in LifterLMS from a CRM, sales tool or custom app: look up by email, create the account, enroll with a trigger, and remove access safely.

  • Add as a preferred source on Google
Illustration for “LifterLMS API: Enroll Students From an External App”

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

How to enroll students with the LifterLMS API

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.

  1. Look up the student: GET /wp-json/llms/v1/students with a search on the email column.
  2. Create the student if the search came back empty: POST /wp-json/llms/v1/students.
  3. Enroll them: POST /wp-json/llms/v1/students/{id}/enrollments/{post_id}, where post_id is a course or membership ID.
  4. Store the returned user ID in the external app so the next call can skip the lookup.

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.

Step 1: find the student by email

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.

Step 2: create the student 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 and password are generated. If you leave out 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.
  • The role defaults to student. You can pass roles, but only roles the API user is allowed to assign.
  • Billing fields are accepted. billing_address_1, billing_city, billing_postcode, billing_country and the rest map to the same user fields the checkout form fills in.
  • A duplicate email fails. WordPress refuses the insert with the error code 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.

What the student receives

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.

Step 3: enroll in a course or membership

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.

A complete PHP example

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.

Step 4: handle an existing enrollment

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.

Step 5: remove access without collateral damage

When a subscription in the external system ends, the external app should take access away. You have two options:

CallEffectUse it when
PATCH with "status":"cancelled" or "expired"Access ends; progress, grades and history stayAlmost always. The student can be re-enrolled later and picks up where they left off
DELETEThe enrollment record is erasedOnly 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"}'

Common mistakes and how to avoid them

  • Calling over HTTP. Key authentication only works over HTTPS. Over HTTP the request is treated as anonymous and you get a 401 that looks like a permissions problem.
  • Using an Instructor's key. Instructors cannot create students or enroll anyone. Use a dedicated LMS Manager account.
  • Enrolling in a lesson ID. Only courses and memberships can be enrolled in. Use the course ID, which you can see in the admin URL when you edit the course.
  • Searching without search_columns. A general search can match names and URLs too. Restrict it to email.
  • Running enrollments in the customer's request. If your app enrolls during its own checkout, put the API call in a background job with retries. A slow WordPress host should not fail your sale.
  • Forgetting the reverse direction. If LifterLMS also sells the same products, subscribe to the enrollment.created and enrollment.updated webhooks so the external app knows about enrollments it did not create.

Frequently asked questions

Can I enroll a student in several courses in one request?

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.

Does enrolling through the API send the enrollment email?

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.

Can I set the enrollment date?

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.

Is there an official SDK?

The API is plain REST with JSON, so any HTTP client works. The LifterLMS REST API reference documents every route and field.

What to do next

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.

Add LifterLMS Expert to your preferred sources

Add lifterlmsexpert as a preferred source on Google, or open this article in your AI assistant to use it as a source.

  • Add as a preferred source on Google
Free consultation

Prefer expert help over DIY?

Skip the trial and error. Get specialist LifterLMS help and ship faster.

Talk to a LifterLMS Expert Explore our services

Replies within one business day