# Thinkific Webhooks

Thinkific is an online course platform. Webhooks report leads, orders, transactions, subscriptions,
users, enrollments, courses, lessons, quizzes, products and app uninstalls for one Thinkific site,
registered per topic through the Webhooks API or the admin UI. Each request is signed: the
`X-Thinkific-Hmac-Sha256` header carries the hex HMAC-SHA256 of the raw request body.

## When to Use This Skill

* How do I receive Thinkific webhooks?
* How do I verify the `X-Thinkific-Hmac-Sha256` signature?
* How do I handle `order.created`, `enrollment.completed` or `user.signup` events?
* Which secret signs a Thinkific webhook: the signing secret, the API key or the client secret?
* Why does my Thinkific webhook endpoint return 401?

## Verification (core)

```javascript
const crypto = require('crypto');
const HEX_SHA256 = /^[0-9a-fA-F]{64}$/;

// rawBody: the exact bytes received (Buffer), never re-serialised JSON
function verifyThinkificSignature(rawBody, signature, secret = process.env.THINKIFIC_WEBHOOK_SECRET) {
  if (!secret || typeof signature !== 'string' || !HEX_SHA256.test(signature)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(signature.toLowerCase(), 'hex'); // safe: format validated above
  return crypto.timingSafeEqual(received, expected);
}

```

* Header: `X-Thinkific-Hmac-Sha256: <64 hex characters>`, the bare digest with no prefix. Compare the
  whole value as one digest. Hex is case-insensitive.
* Signed content: the raw request body bytes only. No timestamp or id is signed, so there is no replay
  window to check.
* Key: the secret's UTF-8 bytes, used as-is (not hex- or base64-decoded).
* Python: `hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()`, then
  `hmac.compare_digest` on **bytes** (a non-ASCII `str` raises `TypeError`).
* Return `401` on a missing, empty or malformed header, or a mismatch; `400` for a signed body that
  isn't a JSON object; `200` once handled.
* Read the body raw (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`,
  `await request.body()`) and parse it only after the check.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/thinkific-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/thinkific-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/thinkific-webhooks/examples/fastapi/).

## Event Types

Dispatch on the body's `resource` and `action` fields (topic = `resource.action`). Thinkific documents
27 topics; the ones below are the ones the examples handle or that have documented sample payloads.

| Topic | Triggered when |
| --- | --- |
| `order.created` | A student places an order |
| `order_transaction.succeeded` / `.failed` / `.refunded` | A payment succeeds, fails or is refunded |
| `enrollment.created` / `.completed` / `.progress` / `.trial` | A student enrols, finishes, progresses or starts a trial |
| `user.signup` / `.signin` / `.updated` / `.deleted` | A user signs up, signs in, changes or is removed |
| `course.created` / `.updated` / `.deleted` | A course changes |
| `lesson.completed`, `quiz.attempted` | A student completes a lesson or attempts a quiz |
| `product.created` / `.updated` / `.deleted`, `plan.updated` | Catalogue changes |
| `subscription.past_due` / `.cancelled` / `.unpaid` | Subscription state changes |
| `lead.created`, `app.uninstalled` | A lead is captured, an app is uninstalled |
| `cart.abandoned` | A cart is abandoned (different envelope: `name` and `data`, no `resource`) |

Handle the few you need and log the rest. The `X-Thinkific-Topic` header also names the topic, but its
only documented example is `orders.created`, so dispatch on the body.

## Environment Variables

```bash
THINKIFIC_WEBHOOK_SECRET=your-thinkific-signing-secret

```

Which secret signs a webhook depends on how it was created: its own signing secret (admin UI, at
least 16 characters, shown once; still being rolled out), the site's API key, or the OAuth app's
client secret.

### Using Hookdeck

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

## Delivery

A 2xx within 5 seconds acknowledges. Otherwise Thinkific retries up to 14 times over about 16 hours
(10 s, 30 s, 1 min, 2 min, ... 1 h at the 11th, 8 h at the 14th), and webhooks that keep failing may be
deactivated. Delivery logs are kept 30 days. Make handlers idempotent (key on the event `id`).

## Local Development

Thinkific can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

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

```

Use the printed URL as the webhook URL in Thinkific.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/thinkific-webhooks/references/overview.md) - What Thinkific webhooks are, topics, payload shape and delivery
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/thinkific-webhooks/references/setup.md) - Register a webhook, pick the signing secret, Hookdeck setup
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/thinkific-webhooks/references/verification.md) - Signature check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/thinkific-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests