# Guide to Thinkific Webhooks: Features and Best Practices

[Thinkific](https://www.thinkific.com) 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

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-thinkific-hmac-sha256` |
| Body | JSON |
| Event routing | Body field `resource` |

## Events and payloads

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

| Topic | Triggered When | Common Use Cases |
| --- | --- | --- |
| `order.created` | A student places an order | Fulfilment, sales records |
| `order_transaction.succeeded` / `order_transaction.failed` / `order_transaction.refunded` | A payment succeeds, fails or is refunded | Payment state, revenue reporting |
| `enrollment.created` / `enrollment.completed` / `enrollment.progress` / `enrollment.trial` | A student enrols, finishes, progresses or starts a trial | Grant access, certificates, nudges |
| `user.signup` / `user.signin` / `user.updated` / `user.deleted` | A user signs up, signs in, is updated or deleted | CRM sync, GDPR deletion |
| `course.created` / `course.updated` / `course.deleted` | A course changes | Catalogue sync |
| `lesson.completed` / `quiz.attempted` | A student completes a lesson or attempts a quiz | Progress tracking |
| `product.created` / `product.updated` / `product.deleted` / `plan.updated` | Catalogue changes | Pricing and product sync |
| `subscription.past_due` / `subscription.cancelled` / `subscription.unpaid` | A subscription changes state | Dunning, access removal |
| `lead.created` | A lead is captured | Email marketing |
| `app.uninstalled` | An OAuth app is uninstalled | Clean up tokens |
| `cart.abandoned` | A cart is abandoned | Recovery 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/thinkific/latest/app.uninstalled.json), taken from Thinkific's documentation:

```json
{
  "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](/docs/cli):

```bash
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](https://support.thinkific.dev/hc/en-us/articles/4422685850775-Using-Webhooks).

### Node.js verification

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

```javascript
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](/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](https://hookdeck.com) takes the timeout pressure off and keeps every Thinkific event available for retry and replay.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Thinkific webhooks reliably in minutes.