Moodle introduced a dedicated payment subsystem in version 3.11, replacing the older standalone PayPal enrolment plugin with a modular architecture under Site administration → Payments. Paid course access now works through two cooperating components: an enrolment on payment method attached to a course, and a payment gateway plugin (PayPal or Stripe via the official and community plugins) that handles the actual transaction. When a student clicks the enrol button, Moodle generates a payment record, redirects the browser to the gateway, and then waits for confirmation to arrive before granting access. That confirmation path is where most shared-hosting problems surface, because it depends on both outbound HTTPS requests from your account and inbound callbacks from the gateway reaching your Moodle site cleanly.
Understanding the direction of each network call is the single most useful diagnostic skill here. Outbound traffic (your server calling the gateway's API to verify a payment) relies on PHP's cURL extension, a current CA certificate bundle, and a TLS 1.2+ handshake. Inbound traffic (the gateway or the returning browser hitting a Moodle URL) relies on your rewrite rules, firewall-friendly paths, and PHP not timing out mid-verification. A failure in either direction produces the same visible symptom for the learner: the payment appears to succeed at PayPal or Stripe, but the course never unlocks. The sections below separate those causes and fix each with tools available to an unprivileged hosting user.
Configuring gateways and verifying API credentials
Start inside Moodle rather than assuming a server fault. Navigate to Site administration → Payments → Payment accounts and confirm a payment account exists and is enabled. Each account holds one or more gateway configurations. Open the gateway (PayPal or Stripe) and check that you are using the correct credential type for your environment. PayPal's modern gateway expects a Client ID and Secret from a REST API app created in the PayPal Developer dashboard, not the legacy email-based IPN values. Stripe expects a publishable key beginning with pk_live_ and a secret key beginning with sk_live_. A classic mistake is leaving sandbox keys (pk_test_, sk_test_) in a production account, which produces a checkout that completes on the gateway's test infrastructure but never settles against the live store.
After saving credentials, attach the method to a course under Course → Participants → Enrolment methods → Add method → Enrolment on payment. Set the cost and currency, and confirm the currency is one the gateway supports for your merchant country. Moodle validates credentials lazily, meaning a bad secret often only reveals itself when the first real learner tries to pay. To force an early test, create a low-cost hidden course, enrol with a real card or PayPal account, and watch the behaviour. If the checkout window never loads the gateway at all, the credentials are rejected at initialisation and the issue is configuration, not networking.
Diagnosing outbound cURL and TLS failures
Once credentials are correct, the next layer is Moodle's ability to call the gateway's servers. Moodle wraps all outbound HTTP in its curl class, so the PHP cURL extension must be active and the OpenSSL library must negotiate TLS 1.2 or higher. On CloudLinux the active extensions are controlled per-domain through the cPanel Select PHP Version interface (or PHP Selector under DirectAdmin's Evolution skin). Open the Extensions tab and confirm both curl and openssl are ticked. Switching your domain to a supported, current PHP branch (Moodle 4.x requires PHP 8.1 or 8.2) also pulls in a modern OpenSSL build that gateways will accept; PayPal and Stripe have both retired TLS 1.0 and 1.1 and reject older handshakes outright.
The most common outbound failure on shared hosting is a stale or misreferenced CA bundle, which throws SSL certificate problem: unable to get local issuer certificate in the gateway response. Moodle ships its own CA bundle and normally points cURL at it, but an overridden curl.cainfo or openssl.cafile value can break that. You can set a clean per-account value without touching server files by creating or editing a .user.ini in your Moodle document root (usually /home/USER/public_html):
; .user.ini in the Moodle web root
curl.cainfo = /home/USER/public_html/lib/cacert.pem
openssl.cafile = /home/USER/public_html/lib/cacert.pem
max_execution_time = 120Replace USER with your actual account name and confirm the path with File Manager. The extended max_execution_time matters because gateway verification can take several seconds, and LiteSpeed will kill a PHP worker that exceeds the limit mid-handshake, leaving a half-recorded payment. Changes to .user.ini are picked up within the user_ini.cache_ttl window (300 seconds by default), so wait a few minutes before retesting. To watch results, run a test payment and immediately check the per-domain error_log that LiteSpeed writes into the directory where the failing script lives; cURL errors surface there with their numeric code, and code 60 points squarely at the CA bundle while code 35 points at a rejected TLS version.
Fixing inbound callbacks, webhooks, and blocked paths
Gateways confirm payments two ways: a browser redirect back to Moodle and, for Stripe, an asynchronous webhook posted server-to-server. The return URL lands on /payment/gateway/ endpoints, and anything that interrupts that URL breaks enrolment. A frequent culprit is an over-eager .htaccess rule or a security plugin blocking requests that carry query strings or POST bodies from an external IP. Confirm your rewrite base is intact and that Moodle's own routing is not being shadowed. A minimal, safe block for a Moodle root looks like this:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# allow gateway callbacks and webhooks to pass untouched
RewriteCond %{REQUEST_URI} ^/payment/ [NC]
RewriteRule .* - [L]
</IfModule>If you use Stripe's webhook-driven confirmation, create the endpoint in the Stripe Dashboard pointing at the exact URL Moodle displays in the gateway settings, and select only the events the plugin documents (typically checkout.session.completed). A mismatched signing secret causes Stripe to mark deliveries as failed with a 400 response; you can see every attempt, status code, and response body in Stripe's Developers → Webhooks log, which is your authoritative record when your own logs are sparse. Because webhook verification is a background POST, it must not be caught by password protection, hotlink rules, or a Deny from directive. Review any IP allowlists you added during security hardening and make sure gateway ranges are not swept up by them.
Finally, verify that transactions are actually recorded. Moodle stores payment rows in the mdl_payments table (your prefix may differ), and you can inspect them read-only through phpMyAdmin in cPanel or DirectAdmin. A row with a gateway reference but a learner still unenrolled tells you the payment captured but the post-payment enrolment step failed, usually from a PHP timeout or a cron backlog. Moodle's scheduled task runner finalises some payment states, so a stalled cron starves the queue exactly the way background jobs stall in other frameworks; the same cron-without-SSH approach documented for Laravel scheduler and queue workers applies here — add a cPanel or DirectAdmin cron job calling php /home/USER/public_html/admin/cli/cron.php every minute so enrolment completes promptly after capture. With credentials verified, cURL and TLS healthy, callback paths unblocked, and cron running, the full payment loop closes and paid enrolments activate the moment a learner finishes checkout.