webhooksTeachable Webhook Events: A Field Guide to Payloads, Gaps, and Workarounds
A practical reference for developers building on Teachable webhooks: what each of the 20 events actually sends, where the payload data is inconsistent, and how to write handlers that survive the gaps.
Teachable currently offers 20 webhook events. This guide covers all of them: what triggers each one, what's in the payload, where the data is inconsistent from one event to the next, and how to build handlers that don't fall over when the payload isn't what you expected. Jump to a group below or start at the introduction.
Enrolment events: Enrollment.created, Enrollment.completed, Enrollment.disabled, Admission.created, Admission.disabled
Sale and transaction events: Sale.created, Sale.subscription_canceled, Transaction.created, Transaction.refunded, AbandonedOrder.created
User events: User.created, User.updated, User.subscribe_to_marketing_emails, User.unsubscribe_from_marketing_emails, EmailLead.created
Tagging events: UserTag.created, UserTag.removed
Content and engagement events: LectureProgress.created, Comment.created, Response.created
Introduction
Every developer who builds on Teachable webhooks hits the same moment. You set up a listener, Teachable fires an event, and you start writing your handler around what came in. Three events later the payload isn't what you expected. A field that was there last time is missing, something is always null, and the docs don't explain why.
This is the article that should have existed before you started. It's a field-level reference for what Teachable's webhooks actually send, where the data differs across event types, and how to write handlers that keep working when the payload doesn't match your assumptions. It's based on the official event documentation and on years of running webhook integrations for Teachable schools.
How Teachable webhooks work
If you're a school owner rather than a developer, this is the part to read.
Something happens in your school. A student enrols, a sale goes through, someone posts a comment. Teachable sends an HTTP POST request to a URL you've set up in your school settings, containing a JSON description of what just happened. Your server receives that payload and does something with it: updates your CRM, triggers an email sequence, writes a row to a database, pings Slack.
That's the whole model, and it's why webhooks matter. They are how Teachable talks to everything else you run. If your CRM isn't updating when students enrol, or your reporting is always a step behind, webhooks are the mechanism that fixes it, provided they're implemented properly. Most of this article is about the "properly" part.
One thing to know up front: Teachable doesn't sign its webhook payloads. There's no signature header your endpoint can check to prove a request really came from Teachable. More on that below.
The payload structure
Every event uses the same outer envelope, which is the one thing you can rely on:
{
"type": "Enrollment.created",
"id": "unique webhook instance id",
"livemode": true,
"created": "2026-05-20T10:15:32Z",
"hook_event_id": "event reference id",
"object": { }
}type tells you which event fired. id is unique to this delivery. hook_event_id identifies the underlying event, which matters for de-duplication (see the patterns section). livemode tells you whether it came from a live school. Everything event-specific lives inside object, and that's where the consistency ends.
The full event list
Enrolment events
| Event | Fires when |
|---|---|
| Enrollment.created | A user is enrolled in a course, whether by purchase or manually by an admin |
| Enrollment.completed | A user reaches 100% completion of a course |
| Enrollment.disabled | A user is unenrolled: manually, through a failed payment, or because access expired |
| Admission.created | A user is enrolled in a coaching product |
| Admission.disabled | A user is unenrolled from a coaching product |
The object for enrolment events carries user_id, course_id, sale_id, is_active, enrolled_at and percent_complete, plus nested user and course objects. These are the only events whose user object includes IP address fields (current_sign_in_ip, and on Enrollment.disabled last_sign_in_ip too). Note that Enrollment.disabled looks almost identical to Enrollment.created except is_active is false, so always check type rather than inferring from the shape.
Coaching products use Admission events rather than Enrollment events. If you're syncing access to a CRM you need to handle both, or coaching clients will never appear.
Sale and transaction events
| Event | Fires when |
|---|---|
| Sale.created | A user enrols in a product, free or paid. A bundle counts as one sale |
| Sale.subscription_canceled | A student cancels a subscription |
| Transaction.created | A user is actually charged, one-off or recurring. Free and 100%-off purchases don't fire this |
| Transaction.refunded | A transaction is refunded |
| AbandonedOrder.created | A user starts checkout and doesn't finish |
Sale.created is the big one. The object includes price, final_price, currency, is_recurring, nested user, course, product and coupon objects, an affiliate field (null when there isn't one) and a transactions array. Transaction.created goes further and embeds the entire sale inside it, plus a shipping_address object and a data object with the Stripe gateway details.
The distinction between Sale and Transaction catches people out. A free enrolment produces a Sale.created but no Transaction.created. A monthly subscription produces one Sale.created and then a Transaction.created every month. If you want revenue, listen to transactions. If you want access, listen to sales or enrolments.
User events
| Event | Fires when |
|---|---|
| User.created | A new account is created, by signup or by an admin |
| User.updated | A user changes their profile |
| User.subscribe_to_marketing_emails | A user opts in to marketing emails |
| User.unsubscribe_from_marketing_emails | A user opts out |
| EmailLead.created | Someone confirms a subscription through an email leads form |
User.created is thin: id, role, email, name, school_id, sign_in_count, phone_number and last_sign_in_at (null, since they haven't signed in yet). No IP fields. User.updated fires on any profile change, including ones you probably don't care about, so expect it to be noisy.
Tagging events
| Event | Fires when |
|---|---|
| UserTag.created | A tag is added to a user |
| UserTag.removed | A tag is removed from a user |
Tags are one of the more useful hooks for automation because they're the only thing in the Teachable admin that an owner can apply by hand to trigger something downstream. Worth knowing: tags can be read through webhooks but at the time of writing there's no way to set them through the API, V1 or V2. They can only be added in the admin, and in bulk only through the bulk actions view.
Content and engagement events
| Event | Fires when |
|---|---|
| LectureProgress.created | A user completes a lecture |
| Comment.created | A user posts a comment in a course |
| Response.created | A user completes a graded quiz |
LectureProgress.created gives you is_completed, percent_complete, course_id, lecture_id and nested course, user and lecture objects. It's the event to use for drip sequences and progress-based automations, though on a busy school it's by far the highest-volume event you'll receive. Comment.created includes the comment body and a commentable object pointing at the lecture with a full_url.
The payload inconsistency problem
This is the section worth bookmarking. The events share an envelope but the nested objects, particularly user, are not the same from one event to the next.
IP addresses are only on enrolment events. Enrollment.created, Enrollment.completed and Enrollment.disabled include current_sign_in_ip in the user object (Enrollment.disabled adds last_sign_in_ip). Sale.created, Transaction.created, Comment.created, User.created and LectureProgress.created do not. For Sale.created in particular that's a real gap. The IP of a purchaser has legitimate fraud-detection uses and it simply isn't there. The workaround is to correlate the sale with the Enrollment.created that fires alongside it, matched on sale_id, and take the IP from that.
Legacy fields that are always null. The enrolment user object carries a last_four field that, in every payload I've seen, is null. It looks like a leftover from an earlier version of the payload. Don't build anything that depends on it having a value.
{
"last_four": null,
"current_sign_in_ip": "xxx.0.113.42",
"last_sign_in_ip": "xxx.0.113.41"
}Nested objects vary in depth. The user inside Sale.created is a basic student record. The user inside Enrollment.created is a fuller one. The sale inside Transaction.created is the whole sale, including its own nested user, course, product and coupon. So the "same" user can arrive with a different set of fields depending on which event delivered them. Map each event to your own internal shape rather than passing the payload straight through.
Nulls rather than absences. Fields that don't apply tend to be present and null (affiliate, last_sign_in_at on a new user) rather than missing. That's fine, but it means a check like if (payload.object.affiliate) behaves differently from if ('affiliate' in payload.object), and you should pick one convention and stick to it.
For each inconsistency you find, the useful habit is the same four steps: what you expected to be there, what actually arrived, what that means for your handler, and what the workaround is.
What Teachable webhooks don't do
No signature verification. Teachable doesn't send a signature header. Anyone who knows your endpoint URL can POST a fake payload and your handler will process it. The practical workaround is a secret token in the URL path or query string (Teachable lets you set any URL you like) that you check on every request, and rejecting anything without it. It isn't cryptographic proof, but it stops casual abuse. For anything that moves money or grants access, confirm the event against the API before acting on it.
No documented retry policy. Teachable does retry failed deliveries, but how many times, at what interval, and what happens after the last failure isn't published. Assume you'll see duplicates and assume you'll occasionally miss one. Build for both.
No ordering guarantee. Enrollment.created and Sale.created for the same purchase can arrive in either order, and under load a Transaction.created can land before its Sale.created. Don't write handlers that depend on one event having already been processed.
No event for everything. There's no event for a course being published, a lecture being edited, a pricing plan changing, or a user being deleted. If you need those, you're polling the API.
No stable contract. Fields have appeared, disappeared and gone permanently null over the years without a changelog entry. Treat the payload as a best-effort description of what happened, not a schema you can trust indefinitely.
Writing reliable webhook handlers
Acknowledge fast, process later. Return a 200 as soon as you've stored the raw payload, then do the real work in a queue or background job. If your handler takes too long, Teachable treats it as a failure and retries, and now you have duplicates.
Validate the event type first.
if (!payload?.type || !payload?.object) {
return res.status(400).json({ error: 'Malformed payload' })
}Never assume a field is there.
const ip = payload.object.user?.current_sign_in_ip ?? null
// Present on enrolment events, absent everywhere else.Make handlers idempotent. Store the id (or hook_event_id) of every payload you process and skip anything you've seen before. For business logic, key on the underlying record instead: an enrolment ID, a sale ID, a transaction ID. Teachable will send duplicates on retry, and the second one must be harmless.
Log the raw payload before you touch it. Full body, event type, timestamp, and the headers. When something breaks months from now, the log is the only way you'll find out what actually arrived.
Reconcile against the API. Webhooks tell you something happened. They don't guarantee you heard about everything. A nightly job that pulls enrolments or sales from the API and compares them to what your webhooks recorded will catch the ones that were missed.
Where this leaves you
None of this is insurmountable, but it does mean a Teachable webhook integration that has to run reliably for years needs some extra defensive code. The inconsistencies are manageable once you know about them. The danger is building on assumptions that look reasonable until an edge case hits your live system at 2am.
Building this yourself vs. having it built
If you're a developer, this guide gives you what you need. If you're a school owner who's just realised this is more involved than expected, that's a reasonable conclusion. This is the kind of infrastructure Purple Hippo builds as a fixed-scope project.Summary table
| Event | IP data | Payload notes |
|---|---|---|
| Enrollment.created | Yes | last_four always null |
| Enrollment.completed | Yes | last_four always null |
| Enrollment.disabled | Yes | Same shape as created, is_active: false |
| Admission.created / disabled | No | Coaching only, mirrors Enrollment events |
| Sale.created | No | Fires for free enrolments too |
| Sale.subscription_canceled | No | |
| Transaction.created | No | Embeds the full sale; skips free purchases |
| Transaction.refunded | No | |
| AbandonedOrder.created | No | |
| User.created | No | Thin payload, last_sign_in_at null |
| User.updated | No | Noisy, fires on any profile change |
| User.subscribe / unsubscribe | No | |
| EmailLead.created | No | |
| UserTag.created / removed | No | Read-only; tags can't be set via the API |
| LectureProgress.created | No | Highest volume event on a busy school |
| Comment.created | No | Includes comment body and lecture URL |
| Response.created | No | Graded quizzes only |
Teachable's own reference for these events is at docs.teachable.com. If you spot a field that behaves differently from what's described here, I'd like to hear about it.
Purple Hippo Web Studio
Need this built, not just documented?
I build custom Teachable integrations, webhook systems and B2B enrollment portals. Fixed scope, fixed price, no surprises.
Get in touch