teachable.expert
Building a Teachable-to-Arbiter bridge: webhooks, queues, and the things the docs don't tell youarchitecture

Building a Teachable-to-Arbiter bridge: webhooks, queues, and the things the docs don't tell you

How I built a webhook-driven integration that writes Teachable course completions into Arbiter Sports: the queue and worker setup, why email is the only identity bridge, a failure taxonomy that actually helps, and the Partner API quirks I wish I'd known on day one.

teachablewebhooksintegrationsbullmqcase-study

Building a Teachable-to-Arbiter bridge: webhooks, queues, and the things the docs don't tell you

The problem in one sentence

A state officials association trains officials in Teachable and manages them in Arbiter Sports. When an official passes a course, the completion date has to appear in a custom field on their Arbiter profile. Automatically, reliably, and in a way a non-developer can supervise.

Why not Zapier

Two reasons. First, the Arbiter side isn't a simple "create a record" call: you have to find the official by email, work out which of their custom fields corresponds to which Teachable course, and handle the response carefully because Arbiter returns HTTP 200 with per-field failures inside the body. Second, this needed to be reconcilable: if a webhook is missed, or an official's email is corrected weeks later, something has to notice and catch up. That's a service, not a zap.

Architecture

Node and TypeScript on Render, as two processes from one repo:

  • a web service that receives Teachable webhooks, validates them, and pushes a job onto a queue; also serves a small admin UI
  • a background worker that consumes the queue and does the actual Arbiter work Between them, BullMQ on Redis (Render's managed Valkey) for the queue and MongoDB Atlas for state: officials, course-to-field mappings, and a ledger of every event and what happened to it.

The Teachable side goes through two libraries of mine: the public optio-teachable SDK for the API, and a private webhooks package that handles signature checks, event typing and the dull parts of receiving webhooks. Every integration I build starts from those rather than from scratch.

Teachable Bridge to Arbiter

Identity is the whole problem

There's no shared ID. Teachable has a user_id; Arbiter has a userId; they are unrelated namespaces and the only bridge between them is the email address. That has consequences:

  • Matching is by email, full stop. Arbiter's Partner API has no name lookup.
  • If an official's email is wrong in one system, the fix is to correct it in Teachable, never to delete and recreate the Teachable account, because deleting destroys the completion history the bridge relies on.
  • The "emails not found in Arbiter" list is a permanent feature of the system, not a bug to be fixed once. New officials, typos, people who registered with a different address. The bridge's job is to keep trying for the ones that might resolve later and to surface the rest to a human. In the admin UI, the two IDs are shown as different-coloured pills so nobody ever confuses them.

The Arbiter Partner API: things I wish I'd known on day one

  • 200 doesn't mean success. A write to /api/Official/CustomField can return 200 with success: false on individual fields. Check the body, always.
  • Reading custom fields for a group is /api/CustomField/{groupId}/all, not the endpoint you'd guess from the write path.
  • GetOfficials on a large roster will time out unless you pass lastModifiedDate and use the lowercase groupid parameter.
  • A leading space in an error message means something. " UserId should be greater than zero." (note the space) isn't a validation error about the ID you sent; it's what comes back when the official has been deleted on Arbiter's side.
  • Custom field IDs are non-contiguous and assigned by Arbiter; treat them as opaque and store the mapping.

Failure taxonomy

Sorting failures into classes was the single most useful design decision, because each class wants a different response:

  1. Never resolved (email not found in Arbiter). Deferrable. Keep the event, schedule a recheck, don't alert anyone yet.
  2. Resolved, then deleted (was found once, now gone). Terminal. Don't retry; flag it in the admin UI for a person.
  3. Transient (timeouts, 5xx, rate limits). Retry with backoff. And the rule that follows from it: never replay a deterministic failure. If the same input produced the same rejection twice, a third attempt is noise.

Reconciliation

Webhooks are best-effort. Teachable will occasionally not send one, or the service will be mid-deploy when it arrives. So alongside the live path there are two scheduled jobs:

  • a nightly run that fetches completions changed since the last run and reconciles them against what the bridge has recorded
  • a weekly unscoped pass over everything, to catch the gaps a --since filter can't see Both are paced against Arbiter's rate limit and cache the course structure once per run rather than per official.

What the client sees

An admin area with three pages: look up an official (both IDs, mapping, event history), manage the course-to-field mappings, and browse the event ledger. Plus Bull Board for the queues. The executive director uses the lookup page to answer "why isn't this person showing as certified?" without emailing me, which is the real measure of whether the admin UI works.

Later: section-level tracking

A change order added tracking for individual sections within a course, not just whole-course completion. Because the mapping layer already existed, it was mostly a matter of extending the model and the handler, and it shipped as a small fixed-price addition.

If you're building something similar

  • Design for reconciliation from the start. The live webhook path is the easy half.
  • Decide your failure classes before writing the retry logic.
  • Store a ledger. When a client asks "what happened to this person on 13 August?", you want an answer, not a shrug.
  • Read the third-party API's error bodies as data, not as strings to log. I build this kind of bridge for Teachable schools as my main line of work. If you'd rather not build it yourself, purplehippo.io explains what that involves and roughly what it costs.

Purple Hippo Web Studio

Need this built, not just documented?

We build custom Teachable integrations, webhook infrastructure, and B2B enrollment portals. Fixed scope, fixed price, no surprises.

Get in touch