Skip to content

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.

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

Incoming sales panel

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.

POST /wp-json/learnomy/v1/webhooks/enrollment

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

  1. Click Regenerate to create a secret.
  2. 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.

  • 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-Signature header. A sha256= prefix is accepted and optional.
  • Optional replay protection: send X-Learnomy-Timestamp with 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.

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.

Make

  1. Trigger: the “new sale” module of your store.
  2. Create JSON module with the fields below.
  3. HTTP > Make a request: method POST, URL = the endpoint, body type Raw, content type JSON, request content = the JSON from step 2.
  4. Header X-Learnomy-Signature = sha256(<the JSON>; "hex"; <your secret>). Make’s sha256 function computes an HMAC when you give it a key.

Zapier

  1. Trigger: “New sale” in your store.

  2. 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 };
  3. Webhooks by Zapier > Custom Request: method POST, URL = the endpoint, Data = body from step 2, header Content-Type: application/json, header X-Learnomy-Signature = signature from 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.

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

REST routes (namespace learnomy/v1):

  • POST /webhooks/enrollment – the public endpoint. The permission callback is __return_true because 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 a course_id and user_email, then removes it again; the response carries cleaned_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 the WP_REST_Request.
  • learnomy_external_enrollment_requested (action) – after a buyer has access, with $user_id, $course_id and the WP_REST_Request.
  • A sale also fires learnomy_purchase_completed and learnomy_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.