Incoming Sales Webhook
Learnomy overviewFree + ProGet Learnomy →
The incoming sales webhook records a course sale that happened somewhere else and gives the buyer access. You sell on Make, Zapier, Gumroad, a CRM or your own checkout, and that system POSTs one signed request to Learnomy.
The sale is fulfilled exactly like a sale on your own checkout. The buyer is enrolled, the transaction shows in Transactions and revenue reports, instructor commission is calculated, and the purchase events other tools listen to (WP Fusion, outgoing webhooks) fire. A refund sent the same way goes through the same refund process as a Stripe or PayPal refund.
What you can do
Section titled “What you can do”- Enroll a buyer in any course as soon as they pay elsewhere. Courses set to Access granted externally (closed) are the usual case, and priced courses work too.
- Create the buyer’s account if they do not have one yet.
- Record the amount, tax, coupon and access period of the outside sale.
- Refund a sale, in full or in part. A full refund removes access.
- Remove access without touching money (
revoke), for example after a chargeback you handle elsewhere. - See every call, accepted or refused, in Payment Log > Incoming sales.
- Send a signed test sale from the admin screen.

How to use it
Section titled “How to use it”Step 1 – open the Webhooks screen
Section titled “Step 1 – open the Webhooks screen”Go to LMS Settings -> Webhooks (?page=learnomy-webhooks). The Incoming sales panel is at the top. Under the heading it shows when the last call arrived, how many arrived in the last 24 hours, and a link to the log.
Step 2 – copy the endpoint URL
Section titled “Step 2 – copy the endpoint URL”POST /wp-json/learnomy/v1/webhooks/enrollmentStep 3 – create and copy the signing secret
Section titled “Step 3 – create and copy the signing secret”Every request must be signed. With no secret set, every request is refused.
- Click Regenerate to create a secret.
- Use the show and copy buttons to copy it into your tool.
When you regenerate later, the old secret keeps working for 24 hours, so you can update each connected tool without losing a sale. If you regenerated because the secret leaked, click Stop accepting it now under the secret and the old one is refused at once.
Step 4 – sign each request
Section titled “Step 4 – sign each request”- Compute the HMAC-SHA256 of the exact request body (the raw bytes you send), keyed with your secret, as hex.
- Send it in the
X-Learnomy-Signatureheader. Asha256=prefix is accepted and optional. - Optional replay protection: send
X-Learnomy-Timestampwith the current unix time, and sign{timestamp}.{body}instead of the body. A request more than five minutes old is then refused.
JSON and form-encoded bodies are both accepted.
Step 5 – send a test sale
Section titled “Step 5 – send a test sale”Pick a course and an email under Send a test sale and click Send test. Learnomy signs the body the same way your tool must and sends it through the live endpoint, so the whole sale and enrollment run. It then removes the test sale and enrollment again, and the account if the test created one. Nobody is notified and no outgoing webhook fires. The call stays in Payment Log > Incoming sales.
Step 6 – connect your tool
Section titled “Step 6 – connect your tool”Make
- Trigger: the “new sale” module of your store.
- Create JSON module with the fields below.
- HTTP > Make a request: method POST, URL = the endpoint, body type Raw, content type JSON, request content = the JSON from step 2.
- Header
X-Learnomy-Signature=sha256(<the JSON>; "hex"; <your secret>). Make’ssha256function computes an HMAC when you give it a key.
Zapier
-
Trigger: “New sale” in your store.
-
Code by Zapier (JavaScript): build the body string and compute the HMAC:
const crypto = require('crypto');const body = JSON.stringify({user_email: inputData.email,course_id: Number(inputData.course_id),external_ref: inputData.order_id,amount: Number(inputData.amount),});const signature = crypto.createHmac('sha256', inputData.secret).update(body).digest('hex');return { body, signature }; -
Webhooks by Zapier > Custom Request: method POST, URL = the endpoint, Data =
bodyfrom step 2, headerContent-Type: application/json, headerX-Learnomy-Signature=signaturefrom step 2.
Always send external_ref, your order or execution id. Make and Zapier retry on timeouts, and with external_ref a retry is recorded once.
The request body
Section titled “The request body”| Field | Required | Notes |
|---|---|---|
user_email |
Yes | The buyer. Matched case-insensitively; a new account is created if none exists. |
course_id |
Yes | The course. item_id is accepted as an alias. Numbers sent as text are fine. |
external_ref |
Yes for purchase and refund |
Your order id, at most 191 characters. One order can cover several courses: send one request per course with the same external_ref. |
op |
No | purchase (default), refund or revoke. The older action: enroll / action: revoke still works. |
amount |
No | Gross amount including tax, 0 or more. Defaults to 0. For refund, the amount to refund; 0 or missing refunds what is left. |
tax |
No | Tax inside amount. It is kept out of the commission base. |
currency |
No | Must be the site’s currency. Missing means the site’s currency. A different currency is refused, so a EUR sale is never shown under another currency’s symbol. |
access_days |
No | Fixed-term access in days. 0 uses the course’s own setting. |
coupon |
No | Code of a Learnomy coupon the sale used, so earnings split on the paid amount and the coupon’s usage count moves. |
full_name |
No | Used when a new account is created. |
provider |
No | Label shown as the sale’s payment provider, for example make or gumroad. Defaults to external. |
refund_ref |
No | For refund: your refund id, to tell two partial refunds of the same amount apart. |
purchase_type |
No | course. Membership plans, learning paths, Space seats and gifts from outside sales are planned. |
Responses
Section titled “Responses”| Status | When |
|---|---|
| 200 | Done. result is enrolled, already_recorded (a retry; access is re-checked and restored if missing), refunded, already_refunded, revoked or no_access. |
| 400 | The body is malformed: bad email, missing external_ref, negative amount, unknown op, and so on. Nothing was written. |
| 401 / 403 | No secret configured, missing signature, wrong signature, or a stale timestamp. Nothing was written. |
| 404 | The course, or for a refund the sale, was not found. |
| 409 | The external_ref belongs to a different buyer, the order was already refunded (send a new external_ref), or the sale was recorded but enrollment was refused. Fix the cause and send the same request again; it will enroll. |
| 422 | A currency other than the site’s, an unknown coupon, a refund larger than what is left, or an unsupported purchase_type. Nothing was written. |
Every call, whatever its status, is logged in Payment Log, filter Incoming sales. The log keeps the status, error code, external_ref, course and a masked email; it never stores the full body. Rows follow the Data retention setting.
For developers
Section titled “For developers”REST routes (namespace learnomy/v1):
POST /webhooks/enrollment– the public endpoint. The permission callback is__return_truebecause the signature is verified inside the handler, over the raw body.POST /admin/webhooks/incoming/regenerate-secret– rotates the secret (admin only). The previous secret is accepted for 24 hours.POST /admin/webhooks/incoming/revoke-previous-secret– stops accepting the previous secret now (admin only).POST /admin/webhooks/incoming/test– sends a signed test sale for acourse_idanduser_email, then removes it again; the response carriescleaned_up(admin only).
Adding a source with its own wire format (Gumroad, Teachable, a webhook that signs differently): implement Learnomy\Adapters\Inbound_Purchase_Source (get_id, matches, verify, normalise) and register it:
add_filter( 'learnomy_incoming_purchase_sources', function ( array $sources ) { $sources['gumroad'] = new My_Gumroad_Source(); return $sources;} );Sources are asked in order; the first whose matches() returns true handles the request, and the built-in generic source is asked last. A source only verifies and reads the request. Fulfilment always goes through Incoming_Purchase_Service, which calls Purchase_Provisioner::provision() for a sale and Refund_Service::apply() for a refund.
Hooks:
learnomy_incoming_purchase_sources(filter) – the inbound sources,id => Inbound_Purchase_Source.learnomy_incoming_webhook_payload(filter) – the normalised operation (op,user_email,item_id,external_ref,amount,tax,currency,access_days,coupon,refund_ref,provider) before it is carried out, plus theWP_REST_Request.learnomy_external_enrollment_requested(action) – after a buyer has access, with$user_id,$course_idand theWP_REST_Request.- A sale also fires
learnomy_purchase_completedandlearnomy_payment_completed, like every gateway sale.
Ledger identity: a sale is stored with provider_transaction_id = external_ref. When the same external_ref already pays for another course, the next course’s line is stored as {external_ref}:course:{course_id}.
Options: learnomy_incoming_webhook_secret (current secret) and learnomy_incoming_webhook_secret_previous (the replaced secret and when it stops working). Neither autoloads.

