Skip to content

feat: add webhook endpoint for Open edX course enrollment#3372

Merged
Anas12091101 merged 18 commits intomainfrom
anas/implement-openedx-course-staff-webhook
Apr 16, 2026
Merged

feat: add webhook endpoint for Open edX course enrollment#3372
Anas12091101 merged 18 commits intomainfrom
anas/implement-openedx-course-staff-webhook

Conversation

@Anas12091101
Copy link
Copy Markdown
Contributor

@Anas12091101 Anas12091101 commented Mar 10, 2026

What are the relevant tickets?

https://github.com/mitodl/hq/issues/1209 > https://github.com/mitodl/hq/issues/10518

Description (What does it do?)

This PR adds a webhook endpoint (api/openedx_webhook/enrollment/) that receives enrollment notifications from Open edX. When a user needs to be enrolled in a course (e.g., staff/instructor role added), the Open edX plugin POSTs the event to MITx Online, which then creates a local enrollment record for the user as an auditor in the corresponding course run (without calling back to Open edX, since the enrollment already exists there).

The endpoint authenticates requests using an OAuth2 Bearer access token (Django OAuth Toolkit / OAuth2Authentication), and handles user/course lookup, idempotent behavior (repeated webhook calls), and appropriate error responses. This enables seamless synchronization of course enrollment state from Open edX into MITx Online.

Screenshots (if appropriate):

  • Desktop screenshots
  • Mobile width screenshots

How can this be tested?

Prerequisites

  • MITx Online running locally (docker compose up)
  • Open edX instance running locally (only needed for end-to-end testing with the plugin)
  • Create an OAuth2 access token in MITx Online to authorize the webhook requests (Django OAuth Toolkit)

Automated Tests

  • Run: docker compose run --rm web uv run pytest openedx/views_test.py -v

Manual Testing via cURL

  1. Create/Get an OAuth2 token (MITx Online)

    • Create an access token in Django admin (or via shell) using Django OAuth Toolkit.
    • Use the token value as <ACCESS_TOKEN> below.
  2. Successful enrollment (happy path)

    • Create a user and a course run in MITx Online (via admin or shell)
    • Run:
      curl -X POST http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer <ACCESS_TOKEN>" \
        -d '{"email": "<user-email>", "course_id": "<courseware_id>", "role": "instructor"}'
    • Expected: 201 Created with {"message": "Enrollment successful", "enrollment_id": ..., "active": true, "edx_enrolled": true}
    • Verify: In Django admin, confirm a CourseRunEnrollment exists for the user with enrollment_mode=audit
  3. Idempotency — already enrolled user

    • Run the same cURL command from step 1 again
    • Expected: 200 OK (should not error; enrollment remains present/active)
  4. Missing Authorization header

    • Run:
      curl -X POST http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Content-Type: application/json" \
        -d '{"email": "test@example.com", "course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"}'
    • Expected: 401 Unauthorized
  5. Invalid/expired token

    • Run:
      curl -X POST http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer invalid-token" \
        -d '{"email": "test@example.com", "course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"}'
    • Expected: 401 Unauthorized
  6. User not found

    • Run with a non-existent email:
      curl -X POST http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer <ACCESS_TOKEN>" \
        -d '{"email": "nonexistent@example.com", "course_id": "<valid-courseware_id>", "role": "instructor"}'
    • Expected: 404 Not Found
  7. Course run not found

    • Run with a non-existent course ID:
      curl -X POST http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer <ACCESS_TOKEN>" \
        -d '{"email": "<valid-email>", "course_id": "course-v1:MITx+FAKE+2025_T1", "role": "instructor"}'
    • Expected: 404 Not Found
  8. Missing required fields

    • Run without email:
      curl -X POST http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer <ACCESS_TOKEN>" \
        -d '{"course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"}'
    • Expected: 400 Bad Request
  9. GET method rejected

    • Run:
      curl http://mitxonline.odl.local:9080/api/openedx_webhook/enrollment/ \
        -H "Authorization: Bearer <ACCESS_TOKEN>"
    • Expected: 405 Method Not Allowed

End-to-End Testing (with Open edX plugin)

  • Check the testing instructions here
  • Ensure the plugin is configured to send an Authorization: Bearer <ACCESS_TOKEN> header.

Dashboard Fix (null start_date)

  • Enroll a user in a course run that has no start date set
  • Navigate to the dashboard (/dashboard/)
  • Verify: The dashboard loads without errors and the course card shows "Coming Soon" instead of crashing

Additional Context

@Anas12091101 Anas12091101 marked this pull request as draft March 10, 2026 19:24
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 10, 2026

OpenAPI Changes

Show/hide ## Changes for v0.yaml:
## Changes for v0.yaml:
No changes detected

## Changes for v1.yaml:
No changes detected

## Changes for v2.yaml:
No changes detected

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Comment thread openedx/views.py Outdated
@pdpinch
Copy link
Copy Markdown
Member

pdpinch commented Mar 10, 2026 via email

@Anas12091101 Anas12091101 force-pushed the anas/implement-openedx-course-staff-webhook branch from e7a2816 to c1a9944 Compare March 11, 2026 07:59
Comment on lines +527 to +531
{enrollment.run.start_date
? `Starts ${formatPrettyMonthDate(
parseDateString(enrollment.run.start_date)
)}`
: "Coming Soon"}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enrollment in a course run with a null start date was breaking the dashboard page.

@Anas12091101
Copy link
Copy Markdown
Contributor Author

Anas12091101 commented Mar 11, 2026

How/is this limited to course staff?

Our plugin checks the user’s role first. If it’s admin or staff, it sends the request to the webhook; otherwise, it doesn’t. The logic is implemented here in the plugins PR.

Screenshot 2026-03-11 at 2 27 27 PM

@pdpinch
Copy link
Copy Markdown
Member

pdpinch commented Mar 11, 2026

Should we make this more generic on the mitxonline side, so it can handle more enrollments and not just staff? Maybe we just need to change the URL of the API.

Also, how does this handle the possibility that a staff is added, but they don't have an account set up in mitxonline for some reason? I think raising an exception would be best.

@Anas12091101
Copy link
Copy Markdown
Contributor Author

Should we make this more generic on the mitxonline side, so it can handle more enrollments and not just staff? Maybe we just need to change the URL of the API.

Yes, it's already generic. I'll update the URL in the next commit.

Also, how does this handle the possibility that a staff is added, but they don't have an account set up in mitxonline for some reason? I think raising an exception would be best.

The endpoint returns a 404 response with {"error": "User with email ... not found"} and logs a warning. This behavior is intentional. Returning a 404 (instead of a 500) prevents the Open edX plugin from retrying the request, since it only retries on 5xx responses. In this case, retries would not resolve the issue because the missing account would still not exist.

The 404 is logged on the MITx Online side so we can monitor these cases and investigate further if needed.

@Anas12091101 Anas12091101 changed the title feat: add webhook endpoint for Open edX course staff enrollment feat: add webhook endpoint for Open edX course enrollment Mar 12, 2026
@Anas12091101 Anas12091101 force-pushed the anas/implement-openedx-course-staff-webhook branch from 4351e31 to c280bc6 Compare March 13, 2026 07:06
Comment on lines +523 to +527
{enrollment.run.start_date ?
`Starts ${formatPrettyMonthDate(
parseDateString(enrollment.run.start_date)
)}` :
"Coming Soon"}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this change for? At best, Its is not related to this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it’s not directly related to this PR. Enrollment in a course run with a null start date was causing the dashboard page to break. As a result, if someone is added as a staff member to a course in MITxOnline without a start date set through the endpoint added in this PR, their dashboard would break without this change.

Would you prefer that I keep this in the current PR, or should I move it to a separate one?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A separate PR might be better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I don't think these changes are related. Could you open a separate PR for these if needed?

Comment thread main/settings.py Outdated
Comment on lines +1258 to +1263
OPENEDX_WEBHOOK_KEY = get_string(
name="OPENEDX_WEBHOOK_KEY",
default=None,
description="Shared secret token used to authenticate incoming webhook requests from Open edX",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rhysyngsun what's your opinion on this? Do you think a pre-generated config-based string bearer token is fine here, or should we rather go with a staff OAuth token with expiry, or HMAC maybe (Is it worth it)?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go with a standard OAuth token because there's a few key benefits to that:

  • OAuth tokens can quickly be revoked without needing to redeploy
  • OAuth tokens can be rotated without downtime

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. fyi @Anas12091101

Comment thread openedx/views.py Outdated
@extend_schema(exclude=True)
@api_view(["POST"])
@permission_classes([AllowAny])
def edx_enrollment_webhook(request): # noqa: PLR0911, C901
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not right now, but we may want to move it to ol-django at some point to make it common between other applications that might want to use this API endpoint. I'll re-think more on this.

Comment thread openedx/views.py Outdated
log.error("OPENEDX_WEBHOOK_KEY is not configured")
return Response(
{"error": "Webhook is not configured"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be a 500. It should be a 400 instead.

Comment thread openedx/views.py Outdated
{"error": f"User with email {email} not found"},
status=status.HTTP_404_NOT_FOUND,
)
except User.MultipleObjectsReturned:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this ever going to happen? Can there be multiple users in the Users table with same email?

Comment thread openedx/views.py Outdated
role,
)
return Response(
{"error": f"User with email {email} not found"},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PII caring

Suggested change
{"error": f"User with email {email} not found"},
{"error": f"User not found"},

Comment thread openedx/views.py Outdated

# --- Enroll user as auditor ---
try:
enrollments, edx_request_success = create_run_enrollments(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_run_enrollments is for creating enrollments for edX as well. In this case, the API request itself is coming from enrollment in edX, so should we call this method? Or create a new one so that we just create a local enrollment upon the Webhook call? Apparently, from the method docstr this is all this method eventually does:

    Creates local records of a user's enrollment in course runs, and attempts to enroll them
    in edX via API.
    Updates the enrollment mode and change_status if the user is already enrolled in the course run
    and now is changing the enrollment mode, (e.g. pays or re-enrolls again or getting deferred)
    Possible cases are:
    1. Downgrade: Verified to Audit via a deferral
    2. Upgrade: Audit to Verified via a payment
    3. Reactivation: Audit to Audit or Verified to Verified via a re-enrollment

So the point is, do we want to go this route? cc: @pdpinch

Comment thread openedx/views.py
)

# --- Enroll user as auditor ---
try:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do an early return at this point or maybe at the start of the API to make it idempotent? The thing we should ideally do is to check if the enrollment exists in the system, before doing anything else, and if the enrollment does exist already, we may return 409 conflict in that case actually otherwise proceed with whatever needs to happen.

Comment thread openedx/views.py Outdated

if enrollments:
enrollment = enrollments[0]
if not edx_request_success:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will this status be? A false? Because the user would already be enrolled in the course in edX

@arslanashraf7 arslanashraf7 self-assigned this Mar 18, 2026
@Anas12091101 Anas12091101 force-pushed the anas/implement-openedx-course-staff-webhook branch from 42febec to 3e558f1 Compare March 27, 2026 11:23
Comment thread openedx/views.py
Comment thread courses/api.py
@Anas12091101
Copy link
Copy Markdown
Contributor Author

@arslanashraf7, this is ready for another look. A test is failing but it doesn't seem to be related to the changes in this PR

Copy link
Copy Markdown
Contributor

@arslanashraf7 arslanashraf7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks fine, But I'm unable to test it end-to-end with edX. Did you test it end-to-end?

I was able to test using Postman/curl but not when I'm integrated with edX, I get 403 errors while edX tried to enroll the user.

@Anas12091101
Copy link
Copy Markdown
Contributor Author

Yes, I tested it end-to-end. It’s most likely a rate-limiting issue, probably caused by the frequent execution of the retry registration task in the MITxOnline Celery workers. The simplest solution would be to temporarily comment out the code here:

https://github.com/openedx/openedx-platform/blob/master/openedx/core/djangoapps/user_authn/views/register.py#L580-L582

Comment thread openedx/views.py Outdated
@extend_schema(exclude=True)
@api_view(["POST"])
@authentication_classes([OAuth2Authentication])
@permission_classes([IsAuthenticated])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should only be a staff token-based API call, not just authenticated. The tests should update accordingly.

Suggested change
@permission_classes([IsAuthenticated])
@permission_classes([IsAdminUser])

Copy link
Copy Markdown
Contributor

@arslanashraf7 arslanashraf7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 with a few nits. There is also an openapi check failing.

Comment thread openedx/views_test.py Outdated
Comment on lines +163 to +173
def test_missing_email(self, api_client, oauth_token):
"""Test request missing email returns 400"""
payload = {"course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"}
response = self._post_webhook(api_client, payload, token=oauth_token.token)
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_missing_course_id(self, api_client, oauth_token):
"""Test request missing course_id returns 400"""
payload = {"email": "instructor@example.com", "role": "staff"}
response = self._post_webhook(api_client, payload, token=oauth_token.token)
assert response.status_code == status.HTTP_400_BAD_REQUEST
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can be a single parametrized test.

Comment thread openedx/views_test.py Outdated
Comment on lines +138 to +147
def test_invalid_token(self, api_client, webhook_payload):
"""Test request with invalid Bearer token returns 401"""
response = self._post_webhook(
api_client,
webhook_payload,
token="invalid-token", # noqa: S106
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

def test_expired_token(self, api_client, webhook_payload, expired_oauth_token):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be a single parametrized test.

Comment thread openedx/views_test.py Outdated
Comment on lines +163 to +174
def test_missing_email(self, api_client, oauth_token):
"""Test request missing email returns 400"""
payload = {"course_id": "course-v1:MITx+1.001x+2025_T1", "role": "staff"}
response = self._post_webhook(api_client, payload, token=oauth_token.token)
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_missing_course_id(self, api_client, oauth_token):
"""Test request missing course_id returns 400"""
payload = {"email": "instructor@example.com", "role": "staff"}
response = self._post_webhook(api_client, payload, token=oauth_token.token)
assert response.status_code == status.HTTP_400_BAD_REQUEST

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can be a single parametrized test.

Comment thread openedx/views_test.py Outdated
Comment on lines +175 to +187
def test_user_not_found(self, api_client, oauth_token):
"""Test returns 404 when the user email doesn't exist"""
course_run = CourseRunFactory.create()
payload = {
"email": "nonexistent@example.com",
"course_id": course_run.courseware_id,
"role": "instructor",
}
response = self._post_webhook(api_client, payload, token=oauth_token.token)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "not found" in response.data["error"]

def test_course_run_not_found(self, api_client, oauth_token):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can be a single parametrized test.

@Anas12091101 Anas12091101 force-pushed the anas/implement-openedx-course-staff-webhook branch from 115e47f to 52195a6 Compare April 16, 2026 13:33
Comment thread openedx/views.py
@Anas12091101 Anas12091101 force-pushed the anas/implement-openedx-course-staff-webhook branch from 7753eea to 6847635 Compare April 16, 2026 14:22
@Anas12091101 Anas12091101 merged commit 88542bf into main Apr 16, 2026
10 checks passed
@Anas12091101 Anas12091101 deleted the anas/implement-openedx-course-staff-webhook branch April 16, 2026 14:40
@odlbot odlbot mentioned this pull request Apr 16, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants