API reference
The Webinly REST API lets your own code and the tools you already use talk to your account. Pull registrations into a CRM, register people from your own form, read attendance and analytics, manage bookings and deals, enroll students, and get a signed webhook the moment something happens.
The API and webhooks are included on the Scale and Agency plans. Create a key in your dashboard to get started.
Overview
Every endpoint lives under https://app.webinly.com/api/v1, speaks JSON, and is scoped to the account that owns the key. A request never sees another account's data. All write bodies are JSON; send Content-Type: application/json.
Authentication
Authenticate every request with a secret key in the Authorization header as a bearer token. Keys start with wbl_.
curl https://app.webinly.com/api/v1/webinars \
-H "Authorization: Bearer wbl_your_secret_key"- Create and revoke keys in Settings › API. The secret is shown once, when you create it. Store it somewhere safe; if you lose it, delete the key and make a new one.
- Each key carries only the scopes you tick. A call that needs a scope the key does not have returns
403. To change a key's scopes, make a new key. - Keys can expire. Pick 30 days, 90 days, 1 year, or never when you create one. An expired key is rejected like an unknown one.
- Keep keys server-side. A key is a password for your whole account's data in its scopes. Never ship one in a browser, a mobile app, or a public repository.
Scopes
A scope is one permission. Tick exactly the ones a key needs.
| Scope | Grants |
|---|---|
| read:webinars | List the webinars on your account. |
| read:registrations | Read a webinar's registrations and attendance. |
| read:analytics | Read a webinar's roll-up analytics. |
| write:registrations | Register someone for a webinar from your own code. |
| read:scheduling | Read bookable event types, open slots, and bookings. |
| write:scheduling | Create and cancel bookings. |
| read:contacts | Read your CRM contacts. |
| read:deals | Read your CRM deals and pipelines. |
| read:courses | List the courses on your account. |
| write:courses | Give a person access to a course. |
| read:webhooks | List your webhook endpoints. |
| write:webhooks | Create and delete webhook endpoints. |
Keys created before scopes existed carry a legacy read:* wildcard. It opens only read:webinars, read:registrations and read:analytics; anything newer needs its own box on a new key.
Rate limits
Requests are limited on two dimensions:
- 120 / minute / IP
- Across every key calling from one address.
- 1000 / minute / key
- Per API key, on top of the per-IP budget.
Over the limit returns 429 with a Retry-After header (seconds until capacity frees up). Back off and retry after that many seconds.
Errors
Errors use standard HTTP status codes and a JSON body with an error string.
- 200 / 201
- Success. 201 when a new resource was created.
- 400
- Bad request: invalid JSON, a missing field, or a malformed value.
- 401
- Missing or invalid key.
- 403
- Valid key, but the plan does not include the API or the key lacks the scope.
- 404
- No such resource on your account.
- 409
- A conflict, such as a booking that is already cancelled.
- 429
- Rate limited. See Retry-After.
{
"error": "Forbidden: key lacks read:contacts scope"
}Pagination
List endpoints that can grow large use cursor pagination. Pass limit for the page size and follow the cursor the response returns until it is null. A cursor is opaque: pass back exactly what you got, do not build one yourself.
- GET /contacts, /deals
- limit (1-200, default 50). Response carries next_cursor; pass it as ?cursor= for the next page.
- GET /webinars/{slug}/registrations
- limit (1-500, default 100). Response carries nextCursor; pass it as ?cursor=.
- GET /scheduling/bookings
- limit (1-200, default 50) with a numeric offset.
Webinars
List every webinar on your account, newest first.
curl https://app.webinly.com/api/v1/webinars \
-H "Authorization: Bearer wbl_..."
{
"webinars": [
{
"id": "0f3d...",
"slug": "launch-masterclass",
"title": "Launch Masterclass",
"name": "Launch Masterclass",
"description": "...",
"status": "published",
"scheduledAt": "2026-11-02T17:00:00.000Z",
"timezone": "America/New_York",
"durationSeconds": 3600,
"createdAt": "2026-10-01T09:12:00.000Z"
}
]
}List a webinar's registrations with attendance metrics. Cursor paginated.
- limit
- 1-500, default 100.
- cursor
- The nextCursor from the previous page.
- includeTest
- Set to 1 to include host test registrations (excluded by default).
curl "https://app.webinly.com/api/v1/webinars/launch-masterclass/registrations?limit=100" \
-H "Authorization: Bearer wbl_..."
{
"registrations": [
{
"id": "a1b2...",
"name": "Sara Lee",
"email": "sara@example.com",
"registeredAt": "2026-10-10T14:03:00.000Z",
"sessionAt": null,
"utm": { "source": "newsletter" },
"attended": true,
"attendedAt": "2026-11-02T17:01:10.000Z",
"watchPct": 82,
"completed": false
}
],
"nextCursor": "eyJyZWdpc3RlcmVkQXQiOiIuLi4ifQ",
"limit": 100
}Register someone from your own code. The response returns a signed watch link you can email them. The same person posted twice gets the same link back (idempotent per email), with alreadyRegistered: true.
- name
- Required. 1-200 characters.
- Required. A valid address.
- utm
- Optional. { source, medium, campaign, term, content }.
- referrerRid
- Optional. The registration id that referred them.
curl -X POST https://app.webinly.com/api/v1/webinars/launch-masterclass/registrations \
-H "Authorization: Bearer wbl_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Sara Lee", "email": "sara@example.com" }'
{
"registration": {
"id": "a1b2...",
"name": "Sara Lee",
"email": "sara@example.com",
"sessionAt": null,
"registeredAt": "2026-10-10T14:03:00.000Z",
"utm": null,
"referrerRegistrationId": null
},
"watchUrl": "https://app.webinly.com/w/launch-masterclass/registered?rid=a1b2...&sig=...",
"signature": "...",
"alreadyRegistered": false
}The same roll-ups your results page shows, in one JSON shape.
curl https://app.webinly.com/api/v1/webinars/launch-masterclass/analytics \
-H "Authorization: Bearer wbl_..."
{
"webinar": { "id": "0f3d...", "slug": "launch-masterclass", "title": "Launch Masterclass" },
"totals": {
"registrations": 1240,
"attended": 712,
"noShow": 528,
"completed": 401,
"offerClicks": 96,
"offerLinkPeople": 71,
"reportedSales": 18,
"attendanceRate": 57.4,
"completionRate": 56.3,
"avgWatchPct": 64.2
},
"utmSources": [ { "source": "newsletter", "n": 410 } ],
"referralLeaderboard": [ { "referrerId": "a1b2...", "n": 22 } ]
}Scheduling
Read what is bookable, read open slots, and create or cancel bookings. Every slot the API offers is one the booking page would offer: the same hours, buffers, caps, min-notice and calendar busy rules apply. The API never charges a card; a paid event returns a hosted checkout URL for the attendee to pay.
List your active, public event types. Secret event types are omitted.
curl https://app.webinly.com/api/v1/scheduling/event-types \
-H "Authorization: Bearer wbl_..."
{
"event_types": [
{
"id": "e7c0...",
"slug": "intro-call",
"name": "Intro call",
"duration_minutes": 30,
"timezone": "Europe/London",
"location_kind": "google_meet",
"deposit_cents": 0,
"requires_approval": false
}
]
}Open times for one event type in a date range.
- from, to
- Required. YYYY-MM-DD.
- tz
- Optional IANA timezone. Defaults to the event type's.
- duration
- Optional minutes, for events that offer a choice.
curl "https://app.webinly.com/api/v1/scheduling/event-types/e7c0.../slots?from=2026-11-01&to=2026-11-07&tz=Europe/London" \
-H "Authorization: Bearer wbl_..."
{
"timezone": "Europe/London",
"slots": [
{
"start_utc": "2026-11-03T09:00:00.000Z",
"end_utc": "2026-11-03T09:30:00.000Z",
"host_account_id": "h1...",
"seats_remaining": 1,
"seat_capacity": 1
}
],
"days_with_slots": ["2026-11-03", "2026-11-04"]
}List bookings on your account, newest first.
- status
- Filter by lifecycle state, e.g. cancelled.
- from, to
- Filter by slot time (ISO).
- limit, offset
- 1-200, default 50, with a numeric offset.
Create a booking on the attendee's behalf. Use a start_utc from the slots endpoint. A paid event responds payment_required with a checkout URL; an approval event responds pending_approval.
- event_type_id
- Required. UUID.
- slot_start_utc
- Required. The slot's start_utc.
- attendee_name, attendee_email
- Required.
- attendee_timezone
- Optional IANA timezone.
- duration_minutes
- Optional, for variable-length events.
- host_account_id
- Optional. Pin a round-robin host (from the slot).
- answers
- Optional map of question answers.
- repeat_weeks
- Optional 1-12 to book a weekly series.
curl -X POST https://app.webinly.com/api/v1/scheduling/bookings \
-H "Authorization: Bearer wbl_..." \
-H "Content-Type: application/json" \
-d '{
"event_type_id": "e7c0...",
"slot_start_utc": "2026-11-03T09:00:00.000Z",
"attendee_name": "Sara Lee",
"attendee_email": "sara@example.com"
}'
{ "status": "booked", "booking_id": "bk_...", "recurrence": null }Cancel a booking. This runs the same refund, calendar cleanup and cancellation email the dashboard does. Set scope: "following" to also cancel later weeks of a series.
curl -X POST https://app.webinly.com/api/v1/scheduling/bookings/bk_.../cancel \
-H "Authorization: Bearer wbl_..." \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer requested" }'
{ "status": "cancelled", "cancelled_count": 1, "deposit_refund": null }Contacts and deals
Read your CRM contacts. Cursor paginated.
- q
- Search name or email.
- stage
- Filter by lifecycle stage.
- tag
- Filter by a tag.
- limit
- 1-200, default 50.
- cursor
- The next_cursor from the previous page.
curl "https://app.webinly.com/api/v1/contacts?limit=50" \
-H "Authorization: Bearer wbl_..."
{
"contacts": [
{
"id": "c1...",
"email": "sara@example.com",
"name": "Sara Lee",
"first_name": "Sara",
"last_name": "Lee",
"phone": "+15551234567",
"lifecycle_stage": "lead",
"tags": ["webinar"],
"created_at": "2026-10-10T14:03:00.000Z"
}
],
"next_cursor": "2026-10-10T14:03:00.000Z|c1..."
}Read your pipeline. Cancelled deals are excluded by default. Cursor paginated.
- status
- open, won, or lost.
- pipeline_id
- Filter to one pipeline (UUID).
- limit
- 1-200, default 50.
- cursor
- The next_cursor from the previous page.
curl "https://app.webinly.com/api/v1/deals?status=open&limit=50" \
-H "Authorization: Bearer wbl_..."
{
"deals": [
{
"id": "d1...",
"title": "Acme - annual",
"contact_name": "Sara Lee",
"contact_email": "sara@example.com",
"value_cents": 120000,
"currency": "usd",
"stage_name": "Proposal",
"pipeline_name": "Sales",
"status": "open",
"created_at": "2026-10-10T14:03:00.000Z"
}
],
"next_cursor": "2026-10-10T14:03:00.000Z|d1..."
}Courses
List your courses with how many students can open each one. Archived courses are omitted.
curl https://app.webinly.com/api/v1/courses \
-H "Authorization: Bearer wbl_..."
{
"courses": [
{
"id": "co_...",
"slug": "launch-course",
"title": "Launch Course",
"status": "published",
"billingType": "one_time",
"priceCents": 29900,
"currency": "usd",
"students": 142,
"createdAt": "2026-09-01T00:00:00.000Z"
}
]
}Give one person access to a course from another system. Free for the student. Provide an email or a contactId. Someone the host removed is not put back (409).
- The student's email (or use contactId).
- contactId
- An existing contact's UUID.
- name
- Optional display name.
- sendEmail
- Set false to skip the sign-in email.
curl -X POST https://app.webinly.com/api/v1/courses/co_.../enroll \
-H "Authorization: Bearer wbl_..." \
-H "Content-Type: application/json" \
-d '{ "email": "sara@example.com", "name": "Sara Lee" }'
{
"enrolled": true,
"alreadyHad": false,
"reactivated": false,
"emailed": true,
"enrollmentId": "en_..."
}Webhook endpoints
Manage the URLs Webinly posts events to. You can also add these on the Integrations page; the API is for tools that subscribe on their own, such as a Zapier or n8n trigger.
List your webhook endpoints. The secret is never returned here.
Create an endpoint. The signing secret is returned once, on create. The URL must be https and a public host. An empty events list means every event.
- url
- Required. An https URL, max 500 characters.
- events
- Optional array of event ids, max 10. Empty = all.
- label
- Optional name, max 80 characters.
curl -X POST https://app.webinly.com/api/v1/hooks \
-H "Authorization: Bearer wbl_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/webinly", "events": ["registration.created"] }'
{
"hook": {
"id": "wh_...",
"url": "https://example.com/webinly",
"label": null,
"events": ["registration.created"],
"active": true,
"createdAt": "2026-10-15T00:00:00.000Z",
"secret": "whsec_..."
}
}Remove an endpoint so Webinly stops posting to it.
curl -X DELETE https://app.webinly.com/api/v1/hooks/wh_... \
-H "Authorization: Bearer wbl_..."
{ "ok": true, "id": "wh_..." }Receiving webhooks
When an event fires, Webinly sends a POST to each matching endpoint with this JSON body:
{
"event": "registration.created",
"created_at": 1760000000,
"data": {
"registration_id": "a1b2...",
"email": "sara@example.com",
"name": "Sara Lee",
"webinar_id": "0f3d...",
"webinar_slug": "launch-masterclass",
"webinar_title": "Launch Masterclass",
"registered_at": "2026-10-10T14:03:00.000Z",
"source": "api"
}
}Events
| Event | Fires when |
|---|---|
| registration.created | Someone registered for a webinar. |
| payment.completed | A purchase or payment completed (a course sale too). |
| payment.refunded | A refund was issued. |
| booking.created | A new booking was made. |
| booking.cancelled | A booking was cancelled. |
| booking.rescheduled | A booking moved to a new time. |
| booking.completed | A booking was marked completed. |
| deal.won | A deal was marked won. |
| deal.lost | A deal was marked lost. |
| deal.stage_changed | A deal moved to a new stage. |
| course.enrolled | A student joined a course. |
| course.completed | A student finished a course. |
| course.access_removed | A student lost access to a course. |
Verifying the signature
Every delivery carries two headers: webinly-event (the event id) and webinly-signature. The signature is Stripe-style: t=<unix>,v1=<hmac>, where the HMAC is HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex. The timestamp is folded into the signed bytes so a captured payload cannot be replayed under a new timestamp. Verify against the raw request body, before any JSON parse.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const expected = createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && timingSafeEqual(a, b);
}Reject anything that does not verify, and optionally reject a t more than a few minutes old. Respond 2xx quickly; do the real work after you answer.
Need a hand?
Questions about the API, a scope you need, or an event that is not listed yet? Email contact@webinly.com or head back to the docs home.