Gareth Wilson Gareth Wilson

Guide to Thinkific Webhooks: Features and Best Practices

Published


Thinkific is a platform for creating and selling online courses, memberships and communities. Its webhooks tell your systems when a student buys, enrols, progresses or finishes a course, when payments and subscriptions change, and when courses and products are edited. Course creators and app developers use them to sync students to a CRM, grant access in other tools, issue certificates and react to failed payments.

This guide covers registering Thinkific webhooks per topic, the resource/action payload and its exceptions, working out which of three keys signs your webhooks, and the short 5-second response window with its 14-retry schedule.

What are Thinkific webhooks?

Each Thinkific webhook links one topic, such as order.created, to one HTTPS URL for one site. To receive ten topics you create ten webhooks. You can create them in the admin UI under Settings > Code & Analytics > Webhooks, which also sends test events, or through the Webhooks API, which is how apps create them when a site installs the app.

Thinkific refuses localhost, made-up domains and thinkific.com as targets. Every delivery carries an X-Thinkific-Hmac-Sha256 signature, plus X-Thinkific-Topic and X-Thinkific-Subdomain headers that tell you the topic and the site.

Thinkific webhook features

FeatureDetails
Signaturehex HMAC-SHA256
Signature headerx-thinkific-hmac-sha256
BodyJSON
Event routingBody field resource

Events and payloads

The topic is resource.action. Thinkific documents 27 topics:

TopicTriggered WhenCommon Use Cases
order.createdA student places an orderFulfilment, sales records
order_transaction.succeeded / order_transaction.failed / order_transaction.refundedA payment succeeds, fails or is refundedPayment state, revenue reporting
enrollment.created / enrollment.completed / enrollment.progress / enrollment.trialA student enrols, finishes, progresses or starts a trialGrant access, certificates, nudges
user.signup / user.signin / user.updated / user.deletedA user signs up, signs in, is updated or deletedCRM sync, GDPR deletion
course.created / course.updated / course.deletedA course changesCatalogue sync
lesson.completed / quiz.attemptedA student completes a lesson or attempts a quizProgress tracking
product.created / product.updated / product.deleted / plan.updatedCatalogue changesPricing and product sync
subscription.past_due / subscription.cancelled / subscription.unpaidA subscription changes stateDunning, access removal
lead.createdA lead is capturedEmail marketing
app.uninstalledAn OAuth app is uninstalledClean up tokens
cart.abandonedA cart is abandonedRecovery emails

The transaction and subscription topics apply to payments processed through Thinkific Payments. enrollment.completed fires at 100% progress, quiz.attempted when a student answers a quiz's final question, and cart.abandoned after the abandoned-cart delay you configure.

Event Payload Structure

Most topics share one envelope:

  • id, unique per event and the right key for deduplication
  • resource and action, which together give the topic
  • tenant_id and created_at, and on newer events tenant_global_id and a numeric timestamp
  • payload, the order, user, enrolment or other object

Three exceptions are worth coding for. cart.abandoned uses a different, versioned envelope with name, version, tenant_guid, correlation_id, timestamp and data. The subscription.cancelled example in Thinkific's docs has action: "canceled", with one L. And the only documented X-Thinkific-Topic value is orders.created, plural, for order.created, so don't assume the header matches the topic names above.

Published payload example

This is the app.uninstalled sample from the webhook samples library, taken from Thinkific's documentation:

{
  "id": "01EWG55HHGG8BA3E2PGMPRH04E",
  "resource": "app",
  "action": "uninstalled",
  "created_at": "2021-01-20T15:02:50.000Z",
  "payload": {
    "site": {
      "id": "zd29cd8r-l18e-4288-adce-5f999c27e00a",
      "subdomain": "connecting-the-dots"
    }
  }
}

Setting up Thinkific webhooks

Prerequisites

  • A Thinkific site where you can manage webhooks, or API access for the Webhooks API
  • A public HTTPS URL (use npx hookdeck-cli listen 3000 thinkific --path /webhooks/thinkific locally)

Which Secret Signs My Webhooks?

The key depends on how the webhook was created:

  1. Signing secret: webhooks created in the admin UI with a Signing Secret you type (16 characters or more) or generate. It's shown once. Thinkific is still rolling this option out, so not every site has it yet.
  2. Site API key: webhooks created with an API key, which includes most older webhooks, are signed with that key.
  3. App client secret: webhooks created by an OAuth app are signed with the app's client secret.

Set WEBHOOK_SECRET to whichever applies, used as-is. If one site has webhooks created in different ways, they won't share a key.

Paste the webhook's signing secret. Older webhooks are signed with the site API key, and OAuth app webhooks with the app client secret.

Register Your Endpoint

  1. In the admin UI, open Settings > Code & Analytics > Webhooks and click New webhook, or call the Webhooks API.
  2. Choose the topic and enter your URL.
  3. Set or generate a signing secret if the option is there, and save it straight away.
  4. Repeat for each topic.

Test a delivery

Start a local receiver and forward requests with the Hookdeck CLI:

npx hookdeck-cli listen 3000 thinkific --path /webhooks/thinkific

Create a webhook for enrollment.created pointing at the CLI URL, send a test event from the admin UI, then enrol a test student in a free course to see a real delivery.

Securing Thinkific webhooks

X-Thinkific-Hmac-Sha256 is the hex HMAC-SHA256 of the raw body, keyed with the secret from the previous section. Compute it over the bytes as received and compare in constant time. No timestamp is part of the signature, so the event id is your protection against repeats. See Using Webhooks.

Node.js verification

const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-thinkific-hmac-sha256'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

The handler normalises the topic from either envelope, maps canceled to cancelled, and deduplicates on id. It must finish within Thinkific's 5-second window, so the handlers it calls should only queue work.

const express = require('express');

function createThinkificApp({ claimEvent, releaseEvent, handlers }) {
  const app = express();
  app.post('/webhooks/thinkific', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let event;
    try {
      event = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!event || typeof event !== 'object' || Array.isArray(event)) return res.sendStatus(400);
    if (!event.id || (typeof event.resource !== 'string' && typeof event.name !== 'string')) {
      return res.sendStatus(400);
    }
    const action = event.action === 'canceled' ? 'cancelled' : event.action;
    const topic = event.resource ? `${event.resource}.${action}` : event.name;
    const site = req.headers['x-thinkific-subdomain'];
    try {
      if (!(await claimEvent(event.id))) return res.sendStatus(200);
      const handler = handlers[topic];
      if (handler) await handler(event.payload ?? event.data, site);
      else console.log('Unhandled Thinkific topic', topic);
    } catch {
      await Promise.resolve()
        .then(() => releaseEvent(event.id))
        .catch(() => {});
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

The cart.abandoned branch uses name as the topic. Check the exact value in a real delivery before relying on it, since the docs' example is abridged.

When deliveries go to a Hookdeck Thinkific source, Hookdeck computes the hex HMAC of the body with the key you configured and compares it with the header. Thinkific gets a 200 well within its 5 seconds, regardless of the result. Failed checks are recorded and not forwarded. Since signing uses one of three keys, a run of failed verifications usually means the source has the wrong one.

Give Thinkific events room to breathe. Hookdeck Event Gateway answers Thinkific inside the 5-second window, verifies every topic and lets your enrolment and payment handlers take as long as they need.

Thinkific webhook limitations and pain points

Five seconds to respond

The Problem: Handlers that call a CRM or LMS synchronously time out and trigger retries.

Why It Happens: Thinkific counts only a 2xx within 5 seconds as acknowledged.

Workarounds:

  • Verify, store and respond, then do the work from a queue.

How Hookdeck Can Help: Hookdeck acknowledges instantly and delivers to your service with a timeout you set.

Retries end, then webhooks may be deactivated

The Problem: After a long outage, events stop arriving altogether.

Why It Happens: Thinkific retries up to 14 times over about 16 hours (10 s, 30 s, 1 min, 2 min and so on, up to 8 hours for the last), then gives up. Webhooks that keep failing may be deactivated.

Workarounds:

  • Check delivery logs, kept for 30 days, after any incident and reactivate webhooks if needed.
  • Reconcile orders and enrolments through Thinkific's API.

How Hookdeck Can Help: Hookdeck accepts deliveries while your service is down, so Thinkific's retries and deactivation rules don't come into play. Events wait in Hookdeck for retry or replay.

Three possible signing keys

The Problem: Verification fails because the receiver has a different key from the one Thinkific used.

Why It Happens: The key is the webhook's signing secret, the site API key or the app client secret, depending on how the webhook was created. Signing secrets are also still being rolled out.

Workarounds:

  • Record how each webhook was created alongside its key.
  • When rotating a site API key, remember it also changes the signature on webhooks created with it.

How Hookdeck Can Help: Use one Hookdeck source per key, and failed verifications on a source point straight at a mismatched key.

Inconsistent names

The Problem: Routing on the header or on documented topic names misses some events.

Why It Happens: The X-Thinkific-Topic example uses a plural resource, the cancelled subscription example uses canceled, and cart.abandoned has a different envelope.

Workarounds:

  • Route on the body, normalise spellings, and handle the cart.abandoned envelope separately.

How Hookdeck Can Help: Hookdeck filters can match body fields such as resource and action, so you can route by body rather than by header.

One webhook per topic

The Problem: Full coverage means managing dozens of webhooks per site.

Why It Happens: Each webhook has a single topic.

Workarounds:

  • Create webhooks through the API from a script, and point them all at one URL.

How Hookdeck Can Help: All topics can share a Hookdeck source, with connections and filters fanning them out to the right services.

Best practices

Keep the subdomain with every event

X-Thinkific-Subdomain identifies the site. Apps installed on many sites should store it with each event and use it to pick the right access token.

Grant access on enrolment, not order

order.created tells you about a sale, but enrollment.created confirms the student has course access. Drive access in other tools from the enrolment.

Clean up on app.uninstalled

When app.uninstalled arrives, revoke stored tokens for that site and stop scheduled jobs.

Handle user.deleted properly

Delete or anonymise the user's data in connected systems when user.deleted arrives, and keep a record that you did.

Conclusion

Thinkific covers the full student lifecycle with clear resource.action topics and a generous retry schedule. The tricky parts are the 5-second timeout, a signing key that depends on how each webhook was made, and a few naming inconsistencies between headers, examples and the topic list.

Hookdeck Event Gateway takes the timeout pressure off and keeps every Thinkific event available for retry and replay.

Get started with Hookdeck for free and handle Thinkific webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.