Edit

Webhooks

LearnHouse can send events like course_completed to your server as signed POST requests. Set up an endpoint first; see Webhooks. The SDK checks the signature and gives you the event.

Handle a delivery

parseWebhook takes the raw body, the request headers and your endpoint's signing secret (whsec_…). It returns the event, or throws a LearnHouseError when the signature or the body is wrong.

app/api/webhooks/learnhouse/route.ts
import { isLearnHouseError, parseWebhook } from '@learnhouse/sdk'

export async function POST(req: Request) {
  try {
    const { event, deliveryId, data } = await parseWebhook({
      payload: await req.text(), // the raw body: never req.json()
      headers: req.headers,
      secret: process.env.LEARNHOUSE_WEBHOOK_SECRET!,
    })
    if (event === 'course_completed') {
      // handle it, keyed by deliveryId
    }
    return Response.json({ received: true })
  } catch (err) {
    if (isLearnHouseError(err)) return Response.json({ error: err.code }, { status: err.status })
    throw err
  }
}

The event has these fields, all read from the signed body:

Field
eventThe event name, e.g. course_completed. See the Event Catalogue
deliveryIdThe delivery's id (dlv_…). Retries of one delivery share it
timestampWhen LearnHouse first tried the delivery, in ISO 8601 UTC
orgIdThe organization the event belongs to
dataThe event's payload. Pass a type to describe it: parseWebhook<MyEvent>(…)

When it throws, answer with err.status:

err.codeerr.statusCause
INVALID_SIGNATURE401The X-Webhook-Signature header is missing or does not match
INVALID_PAYLOAD400The body is signed but is not a LearnHouse event

payload can be a string, a Uint8Array (a Node Buffer is one) or an ArrayBuffer. Headers can be a Headers object or a plain object like Node's req.headers. Header names are matched without regard to case.

Check the signature only

verifyWebhookSignature resolves to true or false and never throws for a bad signature:

import { verifyWebhookSignature } from '@learnhouse/sdk'

const ok = await verifyWebhookSignature({
  payload: rawBody,
  signature: req.headers.get('x-webhook-signature'),
  secret: process.env.LEARNHOUSE_WEBHOOK_SECRET!,
})

It computes the HMAC-SHA256 of the body with your secret and compares it in constant time.

Duplicates and replays

The signature covers the body only. There is no separate signed timestamp header, so a captured request stays valid if someone sends it again. Guard against that, and against normal retries, with the delivery id:

  1. Store each deliveryId you have handled, for example in a table with a unique key.
  2. When a deliveryId comes back, return 200 and do nothing else.

LearnHouse tries a delivery up to 3 times within about half a minute, with the same body each time. That makes the signed timestamp a useful second check: reject deliveries older than a few minutes, and you only need to keep delivery ids for that long.

const age = Date.now() - Date.parse(timestamp)
if (age > 5 * 60_000) return Response.json({ error: 'stale' }, { status: 400 })

Runtimes

The helpers use Web Crypto (crypto.subtle), so they work in Node 19 and later, Bun, Deno and edge runtimes such as Cloudflare Workers and Vercel Edge. On Node 18, set it up once before you verify:

import { webcrypto } from 'node:crypto'
globalThis.crypto ??= webcrypto as Crypto

Verify webhooks on a server only. Your signing secret must never reach a browser or an app.