Skip to content

Webhooks

The API receives three kinds of webhook. All are public routes (no Authorization header): the signature or shared secret is the credential, and the request is rejected without it. There is no outgoing webhook mechanism today; see Events and listeners. The commerce package has a provider contract for verified order events (CommerceProvider) but registers no webhook route.

Webhooks are per host: send them to the tenant’s API host (for example http://coffee.localhost), never to the platform host.

Webhook Route Credential Setup
Living Course, Git push POST /api/living-course/webhooks/{webhookId} HMAC-SHA256 of the raw body with the connection’s own secret Living Course admin
Stripe payment events POST /api/payments-gateways/webhook/stripe Stripe-Signature with PAYMENTS_STRIPE_WEBHOOK_SECRET Sales
Jitsi / JaaS recordings POST /api/jitsi/recorded-video X-Jaas-Signature with JITSI_WEBHOOK_SECRET, or Authorization: Bearer $JITSI_WEBHOOK_TOKEN Webinars

webhookId is the 26-character lowercase id of a connection (shown in the studio with its secret; rotate it with POST /api/living-course/connections/{connection}/webhook-secret). A delivery is accepted when its signature matches the secret of that connection. Accepted signatures:

Header Hosts
X-Ulams-Signature: [sha256=]<hex> every Git host (generic, useful for tests and CI)
X-Hub-Signature-256: sha256=<hex> GitHub
X-Gitea-Signature or X-Forgejo-Signature Gitea, Forgejo
X-Gitlab-Token: <secret> GitLab (the secret itself)

The hex value is HMAC-SHA256(raw body, secret). Only push events to the connection’s tracked branch (ref equal to refs/heads/<branch>) that touch the tracked paths queue a check; the check is debounced by LIVING_COURSE_WEBHOOK_DEBOUNCE_SECONDS (600). Bodies over 1 MiB are refused. The payload is not stored, only its SHA-256 digest.

Terminal window
SECRET='<webhook secret of the connection>'
BODY='{"ref":"refs/heads/main","after":"0123abcd","commits":[]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST "http://coffee.localhost/api/living-course/webhooks/<webhookId>" \
-H 'Content-Type: application/json' \
-H "X-Ulams-Signature: sha256=$SIG" \
--data-binary "$BODY"
# 202 {"success":true,"data":{"queued":true}}
Status Meaning
202 data.queued A check is queued
200 data.ignored Not a push, other branch, or no tracked path changed
200 data.duplicate The delivery id was already seen
200 data.dropped The connection is paused or hit its daily automatic check limit (48, living_course.poll.checks_per_connection_per_day)
401 Invalid signature (logged; one audit entry per connection and minute)
404 Unknown id, or a connector without webhooks. Answers after a fixed delay so timing reveals nothing
413 Body larger than 1 MiB
429 More than 60 requests per minute for this webhookId

Add an endpoint in the Stripe dashboard with the tenant’s URL and subscribe it to payment_intent.succeeded, payment_intent.payment_failed and payment_intent.canceled. The Stripe-Signature header (t=<unix time>,v1=<hex>) is verified as HMAC-SHA256("<t>.<raw body>") with the signing secret, within PAYMENTS_STRIPE_WEBHOOK_TOLERANCE seconds (default 300). To test locally use the Stripe CLI, which signs for you:

Terminal window
stripe listen --forward-to http://coffee.localhost/api/payments-gateways/webhook/stripe

Answers: 200 Ignored for verified events that do not belong to a ulams payment (so Stripe stops retrying), 400 Invalid signature when the signature or secret is wrong or missing, 400 when the Stripe driver is not enabled. The route has no ulams throttle. See Sales for how a payment is confirmed.

POST /api/jitsi/recorded-video is accepted with a valid X-Jaas-Signature (t=<timestamp>,v1=<signature>, HMAC-SHA256 of <t>.<raw body>, base64 or hex, tolerance JITSI_WEBHOOK_TOLERANCE, default 300) or with Authorization: Bearer <JITSI_WEBHOOK_TOKEN>. Answers 403 when neither secret is configured and 401 when the credential is wrong. Throttled to 60 requests per minute; download rules and hosts are in Webinars.

See Rate limits for the throttles.