LearnFloo API

Français · English · Español

LearnFloo live sessions, support, members, groups (classes), courses, feed, calendar, videos and webhooks in an external platform (LMS, WordPress, CRM) · v1 reference · updated 2026-09-16

Authentication

Base URL: https://api.learnfloo.com/api/v1. The paths below are relative to this base: /live/… for live sessions, /support/… for tickets, /users, /courses, /posts, /events, /videos, /webhooks

Every call carries an API key provided by the administrator of the LearnFloo space (Space settings → LMS integration):

Authorization: Bearer lf_live_xxxxxxxx
The key is a server-to-server secret. Never expose it in a browser or in a repository. A revoked key is refused immediately (401). A key gives access to the whole space, with an administrator's rights, according to the access level chosen when it was created: full access or read only (only the GET routes and the read MCP tools; a write returns 403 Read-only API key). The level can be changed at any time in the space settings.

Rate limit

Each key has a token bucket that refills continuously according to the space owner's account and holds at most one minute of requests (bursts possible up to that ceiling):

AccountRequests per minute per key
Free (billing not activated)60
Billing active300

A request uses 1 token, except reports, which use more: /live/sessions/:id/attendance, /courses/:id/progress and /space/usage cost 5 tokens, /users/:id/progress 3 tokens. Every response carries the state of the bucket:

X-RateLimit-Limit: 120        # tokens per minute
X-RateLimit-Remaining: 117    # tokens available right now
X-RateLimit-Reset: 2          # seconds until the bucket is full again

Beyond that, the response is 429 { "error": "Rate limit exceeded" } with a Retry-After header (seconds). Wait for that delay before retrying; don't loop. The counter is spread over several buckets: X-RateLimit-Remaining is an estimate, and a burst very close to the ceiling may trigger a 429 slightly earlier. Under heavy concurrency (dozens of strictly simultaneous calls with the same key), a 503 Too many concurrent requests, retry response with Retry-After may also occur: same handling, retry after the delay. To follow the space's activity, prefer webhooks to repeated polling. Need more? Create several keys (one per use) or contact the LearnFloo team.

Conventions

How live sessions work

Learner                   Your platform                       LearnFloo
   │ clicks “Join”               │                                  │
   │────────────────────────────▶│  POST /live/entry                │
   │                             │─────────────────────────────────▶│ creates or finds the learner,
   │                             │        { url, expiresAt }        │ prepares a single-use ticket
   │   opens url (iframe/tab)    │◀─────────────────────────────────│
   │◀────────────────────────────│                                  │
   │  GET url ────────────────────────────────────────────────────▶ │ automatic sign-in,
   │ ◀──────────────────────────────────────────────────────────── │ live room

POST/live/entry

Personal entry URL for a learner.

{
  "sessionId": "p97…",
  "user": { "externalId": "lms-user-42", "name": "Alice Martin" },
  "role": "viewer"
}
FieldDescription
sessionIdLive session identifier (see GET /live/sessions)
user.externalIdThe learner's stable identifier on your side, 200 characters max
user.nameName shown to the other participants, updated on every call
roleviewer (default: watches, chats, the trainer can give them the floor) or speaker (mic, camera, screen sharing from the start). A learner promoted by the trainer isn't demoted by a viewer call.

Response 200:

{
  "url": "https://app.learnfloo.com/live/enter/4tcgzHpN…",
  "expiresAt": "2026-09-05T20:43:07.570Z",
  "role": "viewer",
  "sessionId": "p97…",
  "userId": "k97…"
}

If the live session hasn't started, the learner waits in the waiting room and enters automatically when it starts. If the space has a custom domain, url is on that domain.

Errors: 400 Session is over (live session ended or cancelled), Session not found, user.externalId is required, role must be "viewer" or "speaker".

POST/live/sessions

Creates a live session in the key's space.

{
  "title": "Module 3 — Questions and answers",
  "description": "optional",
  "mode": "broadcast",
  "scheduledAt": "2026-09-12T14:00:00Z",
  "durationMin": 60,
  "recordingEnabled": true,
  "chat": "open",
  "hostEmail": "trainer@client.com",
  "groups": ["terminale-a"],
  "replay": "attendees"
}
FieldDescription
titleRequired
modebroadcast (webinar: only the host and speakers publish) or conference (everyone publishes). Default broadcast
scheduledAtISO 8601 date or timestamp in milliseconds. Required
durationMinPlanned duration, for the calendar. Default 60
maxParticipants2 to 1000. Default 100
recordingEnabledAutomatic recording. Default true
chatLive chat: closed (available, panel collapsed on arrival, default), open (panel open on arrival) or off (disabled: no message accepted). The host can change it during the live session
hostEmailOptional. The trainer's LearnFloo account, a member of the space with the owner, admin, moderator or teacher role. Defaults to the key's creator
groupsOptional. Groups (ids or slugs) that see and can join the live session; empty = the whole space. /live/entry tickets and invitations aren't affected. When reading: groupIds
replayOptional. Who watches the replay (the host and the space team always can): all (default: everyone who can access the live session), attendees (only the people who attended, in the room or as viewers), groups (members of the replayGroups groups), level (members at level replayMinLevel and above) or none (no replay for the members). For a member who isn't allowed, the app sends no URL and shows “Restricted replay”
replayGroupsWith replay: "groups": ids or slugs of the groups, at least one
replayMinLevelWith replay: "level": minimum level, 2 to 10

Response 201:

{ "session": { … see GET /live/sessions/:id … }, "hostUrl": "https://app.learnfloo.com/<space>/live/<id>" }

hostUrl is the page where the trainer starts the live session, with their LearnFloo account.

GET/live/sessions

Sessions of the space, most recent first, 200 max. Optional filters ?status=scheduled|live|ended|cancelled and ?group=<id or slug>.

{ "sessions": [ { … }, … ] }

GET/live/sessions/:id

{
  "session": {
    "id": "p97…",
    "title": "Module 3 — Questions and answers",
    "description": null,
    "mode": "broadcast",
    "status": "ended",
    "scheduledAt": "2026-09-12T14:00:00.000Z",
    "startedAt": "2026-09-12T14:02:11.000Z",
    "endedAt": "2026-09-12T15:01:40.000Z",
    "participantCount": 0,
    "maxParticipants": 100,
    "recordingEnabled": true,
    "recordingStatus": "ready",
    "replayUrl": "https://…/play_1080p.mp4",
    "replayViews": 12,
    "hlsStatus": "ended",
    "hostId": "k12…",
    "chat": "closed",
    "conversionCount": 0,
    "ctaClickCount": 0,
    "groupIds": [],
    "replay": "all",
    "replayGroupIds": [],
    "replayMinLevel": null,
    "activeSceneId": null,
    "sceneCount": 3,
    "createdAt": "2026-09-01T09:00:00.000Z"
  }
}
FieldDescription
statusscheduled, live, ended, cancelled. Useful to show “Soon”, “Join” or “Replay”
participantCountPeople connected right now (0 outside a live session)
recordingStatusrecording, processing, ready, failed or null
replayUrlReplay MP4 when recordingStatus is ready, otherwise null. Available as soon as the live session ends; the file is re-encoded in the background and the URL may change a few minutes later. See also /replay
hlsStatusWebinar audience stream: starting, live, ended, failed or null
chatopen, closed or off, see POST
conversionCount, ctaClickCountSign-ups reported during the live session and clicks on chat buttons, see /conversions
groupIdsGroups the live session is restricted to (empty = the whole space)
replay, replayGroupIds, replayMinLevelWho watches the replay, see POST. replayUrl is always returned to the key: if your platform shows the replay itself, it applies the rule
The replay MP4 refuses requests without a Referer header: embed it in a page (<video> tag), don't open it as a direct link. To be told when the live session ends and when the replay is ready, subscribe a webhook to the live.session.ended and live.replay.ready events.

PATCH/live/sessions/:id and DELETE/live/sessions/:id

PATCH edits a session. Before the live session: title, description, mode, scheduledAt, durationMin, maxParticipants, recordingEnabled, chat, groups, replay, replayGroups, replayMinLevel; the calendar event follows. During the live session: everything but the date, duration and format. After it: title, description, groups and the replay fields (to open up or restrict a replay afterwards). A field that can no longer change returns 400. Response 200 { "session": { … } }.

DELETE cancels a scheduled session (status cancelled, event removed from the calendar, event credit returned). Response 200 { "session": { … } }. A live session in progress or ended returns 400 Only scheduled sessions can be cancelled.

GET/live/sessions/:id/attendance 5 tokens

Attendance, watch time and audience. During a live session in progress, computed up to the time of the call.

{
  "session": { … },
  "summary": {
    "liveDurationSec": 3540,
    "attended": 42,
    "peakConcurrent": 38,
    "peakAt": "2026-09-12T14:20:00.000Z",
    "averageConcurrent": 31,
    "averageWatchSec": 2610,
    "watchedHalfOrMore": 35,
    "replayViews": 12,
    "spectatorHours": 36.4,
    "interactiveHours": 2.1
  },
  "participants": [
    { "externalId": "lms-user-42", "name": "Alice Martin", "role": "viewer", "source": "lms",
      "joinedAt": "2026-09-12T14:03:10.000Z", "leftAt": "2026-09-12T15:01:40.000Z",
      "watchSec": 3120, "watchPct": 88, "connections": 2, "connected": false, "userId": "k97…" }
  ]
}
FieldDescription
externalIdYour identifier sent in /live/entry. null for people who came directly from LearnFloo
sourcelms (entered through your platform), invite (invitation link), member (LearnFloo member)
rolehost, speaker, viewer
joinedAt, leftAtFirst entry and last exit. leftAt is null while the person is in the room
watchSecActual time spent in the room, all connections combined, capped at the live session's duration
watchPctShare of the live session followed, 0 to 100. The field to use to validate attendance (e.g. >= 80)
connectionsNumber of entries into the room (leaving then coming back = 2)

summary: live session duration, attendees, audience peak and its time, average attendance (simultaneous viewers), average watch time, people who stayed more than half of the live session, replay views. spectatorHours sums the audience's time on the webinar's HLS stream, interactiveHours the time of the people connected to the room (host, speakers, people given the floor): these are the two billing units.

GET/live/sessions/:id/participants

Instant state of the people registered on the session (lightweight, for a dashboard during the live session; statistics are in /attendance).

{ "session": { … }, "participants": [
  { "id": "k97…", "externalId": "lms-user-42", "name": "Alice Martin", "email": null, "image": null,
    "role": "viewer", "source": "lms", "connected": true, "spectator": true, "handRaisedAt": null,
    "joinedAt": "…", "lastSeenAt": "…" }
] }

spectator: watches the webinar's HLS stream (without being in the WebRTC room). handRaisedAt: has asked to speak.

GET/live/sessions/:id/chat

Chat messages kept after the live session, oldest first (?limit=500, 2000 max).

{ "session": { … }, "messages": [ { "id": "…", "userId": "k97…", "externalId": "lms-user-42", "name": "Alice Martin", "text": "Hello!", "imageUrl": null, "link": null, "sentAt": "…" } ] }

The host's messages can carry an image (imageUrl) and a call-to-action button (link: { "url", "label" }), for example a sign-up link sent during the live session. kind is message or conversion (automatic announcement of a sign-up).

POST/live/sessions/:id/conversions

Your LMS reports that a viewer has just signed up or bought during the live session. LearnFloo records the conversion, updates the counter shown under the chat buttons, posts the announcement in the chat (“🎉 Marie just joined the course”, text and anonymity set by the host), shows a banner on the scene and sends the live.conversion webhook.

{
  "externalId": "lms-user-42",
  "name": "Marie Dupont",
  "label": "Advanced SEO course",
  "amountCents": 49900,
  "currency": "eur"
}
FieldDescription
externalIdThe learner's identifier in your LMS (the one from /users). Found in the button link, see below
userIdOr the LearnFloo identifier (lf_user in the link)
emailOr the email of the LearnFloo account
nameOptional, first name shown in the announcement (otherwise the name of the account found, otherwise an anonymous announcement)
label, amountCents, currencyOptional, for the report (product bought, amount in the smallest unit, currency eur by default or usd)

No identifier is required: with neither identifier nor name, the announcement is anonymous (“🎉 New sign-up”). Response 201 { "conversion": { "id", "userId", "externalId", "name", "label", "amountCents", "currency", "createdAt" }, "session": { … } }.

Finding the viewer. When the host sends a call-to-action button in the chat, LearnFloo adds three parameters to the link: lf_live (live session id), lf_user (the viewer's LearnFloo id) and lf_ext (their identifier in your LMS if they were created via /users). Keep them on your sales page (hidden field, cookie) and send them back at payment: lf_live gives the call's :id, lf_ext or lf_user identifies the buyer. Clicks on these buttons are counted (ctaClickCount on the session) and conversions appear in the live session's report.

GET/live/sessions/:id/replay

The replay in all its forms.

{ "replay": {
  "sessionId": "p97…", "status": "ready",
  "replayUrl": "https://…/play_1080p.mp4",
  "hlsUrl": "https://…/playlist.m3u8", "embedUrl": "https://iframe.mediadelivery.net/embed/…",
  "thumbnailUrl": "https://…/thumbnail.jpg", "durationSec": 3540,
  "videoId": "v12…", "lessonId": null, "replayViews": 12
} }

replayUrl is available as soon as the live session ends (raw MP4); hlsUrl, embedUrl, thumbnailUrl and durationSec arrive a few minutes later, once the adaptive copy is encoded (null before). lessonId is set if the trainer published the replay as a course lesson.

GETPOST/live/sessions/:id/invites and DELETE…/invites/:inviteId

Invitation links for people who don't have an account on your platform (external speaker, guest): a shareable, reusable link, without a personal ticket.

POST { "role": "speaker", "label": "Guest speaker", "expiresInHours": 48, "maxUses": 1 }
→ 201 { "invite": { "id": "…", "token": "…", "url": "https://app.learnfloo.com/invite/…", "role": "speaker", "label": "Guest speaker",
                    "uses": 0, "maxUses": 1, "expiresAt": "…", "revoked": false, "active": true, "createdAt": "…" } }

role: viewer (default), speaker, assistant (production: prepares and switches scenes, launches polls, without camera or mic) or moderator (moderates the chat: deleting messages, muting, chat settings, prepared messages, private messages and polls, without camera or mic). expiresInHours and maxUses are optional (no limit by default). 20 active links max per session. DELETE revokes the link (response 200 with the invitation).

Scenes: principle

A LearnFloo live session is run like a studio: named scenes (layout, cameras, screen sharing, images, PDFs, videos, embeds, stickers and banners) that the presenter or the production crew switch in one click. Everything the production crew does in the app can also be done through the API, live: create a scene during the live session, put it on air, change a banner's text, show an announcement, turn a PDF page, start a video. What is on air is seen by all participants, the viewers' stream, the recording and external broadcasts.

{
  "id": "intro", "name": "Introduction", "layout": "spotlight", "active": true,
  "slots": [
    { "id": "sl_a", "kind": "screen" },
    { "id": "sl_b", "kind": "host", "fit": "cover", "shape": "circle", "filter": "warm", "bgMode": "blur" },
    { "id": "sl_c", "kind": "guest", "guestIndex": 1 },
    { "id": "sl_d", "kind": "media", "mediaId": "m8…", "mediaKind": "pdf", "url": "https://…", "title": "Slides" }
  ],
  "overlays": [
    { "id": "ticker", "type": "band", "text": "Registration open until 6 pm", "visible": true,
      "x": 0, "y": 0.86, "w": 1, "h": 0.09, "opacity": 1, "bg": "#dc2626", "color": "#ffffff",
      "fontSize": 48, "bold": true, "scroll": true, "speed": 160, "align": "center" },
    { "id": "ov_live", "type": "sticker", "label": "LIVE", "bg": "#dc2626", "color": "#ffffff", "visible": true,
      "x": 0.82, "y": 0.06, "w": 0.16, "h": 0.09, "opacity": 1 }
  ],
  "background": "#0a0a0a", "backgroundImageUrl": null, "showLogo": true, "framed": false, "transition": "cut",
  "durationSec": 20, "nextSceneId": "offer"
}
FieldDescription
idFree at creation (1 to 40 characters: letters, digits, -, _), otherwise generated. Same for slots and overlays: choose stable ids (ticker, price) to update them later
jingleJingle: the scene plays its duration (durationSec, or else the length of its videos and sounds), then the scene it interrupted resumes where it was (videos and sounds, time left in the auto-advance), even without auto-advance. Reached by auto-advance, a jingle then goes on to the next scene. null to remove.
durationSec, nextSceneIdAuto-advance: time on screen in seconds (1 to 86400, counted after the arrival transition; absent = the scene stays until the next switch; stretched to the end of the videos and sounds that start with the scene, not looped, whose length is known: mediaSec, file length in seconds measured by the editor, also accepted on video slots and in audios), then the next scene (absent = the next one in the list, or the first one when looping). Only active when auto-advance is on (PUT …/scenes/auto)
layoutsolo (first item full screen), grid, spotlight (first one large, the others in a column), sidebyside, pip (first full screen, second as a thumbnail), cinema (first full screen, strip at the bottom), free (each slot carries x, y, w, h)
slots[].kindhost (the host's camera), guest (guestIndex: n-th speaker in order of arrival, or userId to pin a person), screen (first screen share), media (mediaId from the media library, or a direct https url with mediaKind image, pdf or video), embed (mediaId or a YouTube, Vimeo, Loom url)
slots[].fit, loop, muted, titleFraming cover or contain; looping or muted video; title shown in the production desk
slots[].shape, filter, bgMode, bgBlur, bgImage, volume, mutedCameras (host, guest): shape rect (default), rounded, square, squircle (rounded square), circle, portrait; filter none, bw, noir, sepia, vintage, warm, cool, vivid, faded, bright, contrast; background applied on the person's device while the scene is on the air: bgMode none, blur (bgBlur 1 to 20, default 10) or image (bgImage: https URL or preset:ocean, preset:forest, preset:sunset, preset:slate), absent = the person's own choice; sound on the air volume (0 to 1) and muted, for the broadcast, the recording and the audience (speakers always hear each other). null to remove
slots[].autoplay, once, startSec, durationSec, volumeVideo: starts when the scene goes on air (default true, otherwise waits for the production desk's commands), once only the first time, start in seconds, duration of the part played (looped or stopped at the end of the part), volume 0 to 1 (null to remove). Same logic as the scene sound.
overlays[]type band (text, bg, color, fontSize in px at 1080p, bold, scroll, speed in px/s, align), sticker (emoji, or an https imageUrl, or a badge label with bg and color), or a live widget poll / message / viewers (viewer counter, title = label), with its style: bg (hex) and bgOpacity, color, accent (bars, badges), fontScale (0.5 to 2), radius (px at 1080p), borderColor, borderWidth, shadow, font (sans, rounded, serif, mono), align (message), showHeader, showVotes, showLetters (poll), showName, showIcon (message); the size is set with w and h: where the scene draws the poll or the chat message put on air; empty while nothing is on air, and a scene without such a slot uses a default corner. Geometry in fractions of the 16:9 frame (x, y, w, h between 0 and 1), visible, opacity
lockedPadlock: a locked scene refuses any edit and deletion (400), from the app as from the API, until a call sends { "locked": false }. It can still be put on air and duplicated
audiosSounds of the scene, several allowed (6 max), each { "mediaId": "…" } (audio file from the media library) or { "url": "https://…" }, with autoplay (starts when the scene goes on air, default true), once (with autoplay: only the first time, default false), loop (default false), volume (0 to 1, default 1), startSec and durationSec (part played, in seconds; null to remove). Played for all participants and captured in the broadcast and the recording. Replaces the list (a sound already there, same mediaId, keeps its other settings); [] removes them all. The older audio field (a single sound, same shape) is still accepted on write, "audio": null removes every sound; on read, sounds are always in audios. Play, pause and restart each sound from the beginning with PUT …/media/:mediaId.
background, backgroundImageUrl, showLogo, transitionCSS background colour, https background image (null to remove it), the space's logo in the corner, transition when the scene arrives (the previous one stays underneath during the animation): cut, fade (crossfade), black (fade to black), slide-left, slide-right, slide-up, slide-down, zoom, wipe, blur, with transitionMs (100 to 3000, default 500). Played in the room, the broadcast and the recording.
framedFramed: a margin all around the slots lets the background (colour or image) and your branding show. For every layout but free; default false, null to remove it

20 scenes per live session, 9 slots and 30 stickers or banners per scene. A scene is prepared before the live session (speakers are designated by order of arrival) and edited during it: editing a scene on air is visible immediately.

GETPOST/live/sessions/:id/scenes and GETPATCHDELETE…/scenes/:sceneId

GET returns the whole production desk:

{ "session": { … }, "activeSceneId": "intro",
  "autoAdvance": { "enabled": true, "loop": false, "nextSceneAt": "2026-09-12T14:05:20.000Z" },
  "scenes": [ { … } ],
  "media": [ { "mediaId": "m8…", "page": 3, "playing": false, "position": 0, "updatedAt": "…" } ],
  "bands": [ { "id": "bd_…", "text": "🎉 Marie just signed up", "bg": "#059669", "color": "#ffffff", "until": "…" } ] }

POST creates a scene from the object above (everything is optional except what gives it meaning), or from a template of the space: { "template": "Shared screen", "name": "Demo" }. "activate": true puts it on air right away; "afterId" places it after a given scene. Response 201 { "scene": { … } }.

POST /live/sessions/p97…/scenes
{ "id": "offer", "name": "Special offer", "layout": "pip",
  "slots": [ { "kind": "media", "mediaId": "m12…" }, { "kind": "host" } ],
  "overlays": [ { "id": "price", "type": "band", "text": "SEO course: €499 until tonight", "y": 0.86 } ],
  "activate": true }

PATCH edits the fields sent; slots and overlays, when present, replace the whole lists (to touch a single item, see below). DELETE removes the scene (if it was on air, back to the automatic layout) and returns the production desk. PUT …/scenes/order { "sceneIds": [ … ] } reorders.

On air, banners, media

CallEffect
POST/live/sessions/:id/scenes/:sceneId/activatePuts the scene on air (its videos restart from the beginning). Response: the full production desk
PUT/live/sessions/:id/scenes/active { "sceneId": "intro" }Same thing; { "sceneId": null } goes back to the automatic layout (cameras in a grid, screen share large)
PUT/live/sessions/:id/scenes/auto { "enabled": true, "loop": true }Auto-advance: during the live session, each scene with durationSec gives way to nextSceneId (or the next one) once its time is up; loop goes back to the first one at the end of the list. A manual switch (app or API) restarts the timer of the new scene. The returned production desk carries autoAdvance: { enabled, loop, nextSceneAt } (nextSceneAt: ISO date of the next change, or null)
POST/live/sessions/:id/bands { "text": "5-minute break", "bg": "#059669", "color": "#ffffff", "seconds": 8 }Banner shown at the top of the screen for seconds (2 to 60, 8 by default) on every screen, whatever the scene. Response 201 { "band": { "id", "text", "bg", "color", "until" } }. For a permanent or editable text, prefer a scene banner (overlays)
PUT/live/sessions/:id/media/:mediaId { "page": 4 }Page shown of a PDF present in a scene
PUT/live/sessions/:id/media/:mediaId { "playing": true, "position": 0 }Play, pause or position (seconds) of a video present in a scene, or of one of a scene's sounds (audios[].mediaId), synchronised for everyone

Each change of the scene on air sends the live.scene.changed webhook ({ session, scene }, scene set to null for the automatic layout).

Stickers, banners and slots of a scene

CallEffect
POST…/scenes/:sceneId/overlaysAdds a sticker or a banner (overlays[] object above). 201 { "overlay" }
PATCH…/scenes/:sceneId/overlays/:overlayIdEdits the fields sent: { "text": "Only 12 seats left" }, { "visible": false }, { "x": 0.1, "y": 0.1 }… Visible immediately if the scene is on air
PATCH/live/sessions/:id/overlays/:overlayIdSame thing in all the scenes that carry this id: a shared banner (counter, price, next step) placed in each scene with the same id is updated in a single call
DELETE…/scenes/:sceneId/overlays/:overlayIdRemoves the item, returns the scene
POST…/scenes/:sceneId/slotsAdds a slot (slots[] object; optional position, 0 = main). 201 { "slot" }
PATCH…/scenes/:sceneId/slots/:slotIdEdits the slot (other media via mediaId or url, guestIndex, fit, geometry in free, position to move it in the order)
DELETE…/scenes/:sceneId/slots/:slotIdRemoves the slot, returns the scene
# The seat counter, in every scene, from your CRM
PATCH /live/sessions/p97…/overlays/places
{ "text": "Only 7 seats left", "bg": "#f59e0b", "color": "#111111" }

GETPOST/scene-templates and DELETE/scene-templates/:id

Scene templates of the space, reusable in every live session (“New” menu of the production desk, or "template" when creating a scene). POST: { "name": "Shared screen", "scene": { … } } or, to save an existing scene, { "name": "…", "sessionId": "p97…", "sceneId": "intro" }. Same name = replaced. Response { "template": { "id", "name", "scene", "folderId", "createdAt", "updatedAt" } }. :id accepts the identifier or the name.

PUT/live/sessions/:id/participants/:userId/role

{ "role": "speaker" | "viewer" | "assistant" | "moderator" } for a participant already registered on the session (/participants): give or take back the floor, or appoint a production crew member. Applied immediately during the live session. Response { "participant": { … } }.

Polls and quizzes: principle

A live session can ask participants polls (opinion) and quizzes (with a correct answer), in the room as well as in the webinar audience. They are prepared in advance (status draft, invisible to participants), launched during the live session (open, one at a time) then closed (closed). Results are kept after the live session: GET …/polls always returns them, and they also appear in the statistics and on the replay page. In the app, the host, the staff and the moderator and assistant roles manage them from the live session's “Polls” tab and from “Prepare”.

{ "id": "k3…", "sessionId": "p97…", "kind": "quiz", "question": "What is the capital of Australia?",
  "allowMultiple": false, "status": "closed", "resultsShown": true, "position": 2,
  "options": [ { "id": "o1_…", "text": "Sydney",   "votes": 31, "percent": 62, "correct": false },
               { "id": "o2_…", "text": "Canberra", "votes": 19, "percent": 38, "correct": true } ],
  "voters": 50, "correctVoters": 19,
  "openedAt": "…", "closedAt": "…", "createdAt": "…", "updatedAt": "…" }

kind: poll (default) or quiz. For a quiz, correct at creation gives the indexes (from 0) of the correct answers in options; the correct answer and correctVoters (voters who picked exactly the right combination) are revealed to participants with the results. percent is the share of voters who chose the option (with allowMultiple, the total exceeds 100). A participant can change their vote while the poll is open; a voter counts once.

GETPOST/live/sessions/:id/polls and GETPATCHDELETE…/polls/:pollId

GET …/polls returns { "polls": [ … ] } in preparation order, with the counts; ?status=draft|open|closed filters. Response { "poll": { … } } for the others.

POST /live/sessions/p97…/polls
{ "question": "Which topic next?", "options": ["Pricing", "LMS integration", "Replays"], "allowMultiple": true }
→ 201 { "poll": { "status": "draft", … } }

POST /live/sessions/p97…/polls
{ "kind": "quiz", "question": "Capital of Australia?", "options": ["Sydney", "Canberra"], "correct": [1], "open": true }
→ 201 { "poll": { "status": "open", … } }   // "open": true launches it right away (live session in progress only)

phase (at creation or in PATCH): live (default, launched by hand during the live), waiting (pre-live poll or quiz: open in the waiting room as soon as it exists while the live is scheduled, closed automatically at launch, results reusable during the live through …/band and …/stage) or closing (opened automatically when the live ends, on the closing screen: feedback, satisfaction). An open waiting poll stays editable.

PATCH edits a draft poll (or an open waiting poll) only (same fields; if options changes for a quiz, send correct again). A launched poll is duplicated (new POST) rather than edited. DELETE deletes the poll and its votes, whatever its status. 2 to 6 answers, 100 polls per live session.

Launch, results, close

CallEffect
POST…/polls/:pollId/openOpens voting to participants (card on the video, system message in the chat unless { "announceInChat": false }). Refused if another poll is open or if the live session isn't in progress. Webhook live.poll.opened.
PUT…/polls/:pollId/results { "shown": true }Shows (or hides with false) the counts to participants, during the vote or after closing. For a quiz, also reveals the correct answer.
POST…/polls/:pollId/closeCloses voting; { "showResults": false } to close without showing them (default: shown). Webhook live.poll.closed with the results. A poll still open at the end of the live session is closed, results shown.
PUT…/polls/:pollId/stage { "shown": true }Shows the poll in the scene (the poll slot of the scene on air, otherwise the bottom-right corner): question, answers, number of votes, then the bars and the correct answer when results are shown. Visible in the room, the broadcast and the recording. false removes it; onStage in the poll object.
PUT/live/sessions/:id/viewers { "extra": 25 }Adds viewers to the counter shown by the scenes' viewers widget (actual number of people in the live session + extra). Never counted in attendance, statistics or billing. 0 removes the addition; extraViewers in the session object.
PUT/live/sessions/:id/chat/stage { "messageId": "…" }Puts a chat message on air (the scene's message slot, otherwise bottom left): name and text, image if any. { "messageId": null } removes it. Private messages are refused. Response { "message": { id, name, text, imageUrl, at } | null }.
POST…/polls/:pollId/band12-second banner on the scene (video, broadcast, recording) with the leading answers, or the correct answer and the success rate for a quiz. Live session in progress, at least one vote.

Support: principle

Your users open support tickets from your platform (for example the LearnFloo WordPress plugin); your team answers them in the LearnFloo space, Support tab, or through the API (team side). Same API key as for live sessions.

ticket object returned by every call:

{
  "id": "j57…", "number": 12, "subject": "Video stuck in module 3",
  "status": "open", "priority": "normal", "category": null,
  "messageCount": 3, "lastMessageAt": "2026-09-07T09:12:00.000Z", "lastMessageBy": "staff",
  "createdAt": "…", "updatedAt": "…", "resolvedAt": null, "closedAt": null,
  "assignee": { "name": "Julie", "image": null }
}
FieldDescription
statusopen (waiting for the team), pending (waiting for the user), resolved, closed
prioritylow, normal, high, urgent
lastMessageBymember or staff: who wrote last

GET/support/tickets

The user's tickets, most recent first. ?externalId=… required, &status=active|open|pending|resolved|closed|all optional (all by default, active = open + pending).

{ "tickets": [ { … }, … ] }

All the space's tickets (team dashboard): ?scope=space&status=…&limit=100 (500 max). Each ticket then carries its author ({ id, externalId, name, email, image }).

POST/support/tickets

{
  "user": { "externalId": "wp:42", "name": "Alice Martin" },
  "subject": "Video stuck in module 3",
  "content": "Hello,\n\nthe video stops at 2 min.",
  "priority": "normal",
  "category": "wordpress",
  "attachments": [ { "kind": "image", "url": "https://…", "name": "capture.png", "mimeType": "image/png", "sizeBytes": 1234 } ]
}
FieldDescription
user.externalId, user.nameRequired. 200 and 60 characters max
subjectRequired, 200 characters max
contentPlain text (default) or HTML with "format": "html". Required unless an attachment is provided
priorityDefault normal
categoryFree text, optional (shown to the team)
attachmentsOptional, 10 max, objects returned by /attachments

Response 201: { "ticket": { … } }. The space team is notified.

GET/support/tickets/:id

?externalId=… required (or ?scope=space for the team: any ticket of the space, with its author). The ticket and its messages visible to the user, oldest first. The team's internal notes never leave through the API.

{
  "ticket": {
    …,
    "messages": [
      { "id": "m3…", "content": "<p>Hello,</p>…", "attachments": [],
        "createdAt": "…", "fromStaff": false, "author": { "name": "Alice Martin", "image": null } },
      { "id": "m4…", "content": "<p>Could you clear the cache?</p>", "attachments": [],
        "createdAt": "…", "fromStaff": true, "author": { "name": "Julie", "image": "https://…" } }
    ]
  }
}
content is HTML produced by the LearnFloo editor: sanitise it before showing it in your page (text tags, images, links).

404 if the ticket doesn't exist or doesn't belong to this user.

POST/support/tickets/:id/messages

{ "user": { "externalId": "wp:42", "name": "Alice Martin" }, "content": "Thanks, it's fixed.", "attachments": [] }

Response 201: { "ticket": { … } } (without the messages). The ticket goes back to open and the team (or the assigned person) is notified.

POST/support/tickets/:id/status

{ "user": { "externalId": "wp:42" }, "status": "resolved" }

The user can mark their ticket resolved or reopen it (open). The other statuses are reserved for the team. Response 200: { "ticket": { … } }.

Support on the team side (staff)

To sync an external help desk, the two calls above accept a staff object instead of user: the action is done by a member of the space team (owner, admin, moderator, teacher), by default the key's creator, or staff.email for another LearnFloo account.

POST /support/tickets/:id/messages
{ "staff": { "email": "julie@client.com" }, "content": "Could you clear the cache?" }
→ the ticket goes to "pending", the user is notified

POST /support/tickets/:id/status
{ "staff": {}, "status": "closed", "priority": "high", "assigneeEmail": "julie@client.com" }
→ any status; priority and assigneeEmail (null to unassign) are optional

POST/attachments

Raw file body, Content-Type header = the file's type (images, PDF, audio, text, zip, Office), 25 MB max. ?externalId=… optional (attachment of an external user). Then attach it to a ticket, a reply, a post or a comment via attachments. /support/attachments is an alias.

{ "attachment": { "kind": "image", "url": "https://…/capture.png", "mimeType": "image/png", "sizeBytes": 1234 } }

Errors: 415 unsupported type, 413 file too large.

GET/space

The space behind the key, for a connection test and display:

{ "space": { "id": "…", "slug": "my-space", "name": "My space", "description": null, "logo": "https://…", "coverImage": null,
  "mode": "learning", "visibility": "private", "accessType": "paid", "priceCents": 2900, "priceCurrency": "eur", "priceInterval": "month", "category": "business",
  "aboutUrl": null, "rating": 4.8, "reviewCount": 12, "gamificationEnabled": true,
  "liveDomain": "live.client.com", "memberCount": 128, "courseCount": 4, "groupCount": 6,
  "groupLabels": { "singular": "Class", "plural": "Classes", "leader": "Teacher", "member": "Pupil", "gender": "f" },
  "createdAt": "…", "url": "https://app.learnfloo.com/my-space" } }

visibility: public (listed on www.learnfloo.com/en/communities, About page aboutUrl open to everyone) or private (access by invitation link only). accessType: free or paid; a paid space carries priceCents (amount in the currency's smallest unit), priceCurrency (eur, price including VAT, or usd, price excluding tax) and priceInterval (month, year, once = lifetime access). rating is the average of the members' reviews (null without reviews).

GET/space/usage 5 tokens

Usage of the space since the last invoice, valued at the pay-as-you-go rates of the single offer (webinar hours broadcast, recorded video call hours, interactive hours, viewer hours, plays, stored video minutes, simulcast hours multiplied by the number of destinations, subtitled hours), the state of the owner's account and the key's rate limit.

{ "usage": { "month": "2026-09", "currency": "eur", "billingActive": true, "liveAllowed": true,
  "units": { "liveHours": 6.5, "recordedVisioHours": 0, "interactiveHours": 12.5, "spectatorHours": 240.3, "simulcastHours": 3, "subtitleHours": 0, "plays": 1530, "storedMinutes": 412 },
  "allowance": { "storedMinutes": 60, "plays": 300 },
  "usageCents": 4812, "capCents": 10000, "capMode": "block" },
  "rateLimit": { "perMinute": 300 } }

usageCents is this space's cost before the included allowances (allowance), the welcome credit and the monthly cap (capCents, capMode = block or warn), which apply to the owner's account, all spaces combined. liveAllowed is false when the account can no longer pay (credit used up without active billing).

GETPOST/users

GET: members of the space, paginated (?limit=50&cursor=…, 200 max), filters ?role=member|contributor|teacher|moderator|admin|owner and ?status=active|suspended.

{ "users": [ { "id": "k97…", "externalId": "lms-user-42", "name": "Alice Martin", "email": null, "image": null,
               "member": { "role": "member", "status": "active", "totalXp": 320, "level": 3, "joinedAt": "…" } } ],
  "nextCursor": "…" }

POST: creates (or updates) a user of your platform and adds them as a member of the space, without waiting for them to join a live session. Idempotent: calling again with the same externalId updates the name.

{ "externalId": "lms-user-42", "name": "Alice Martin", "role": "member", "member": true }
→ 201 { "user": { …, "member": { … } }, "created": true }

role: member (default), contributor, teacher, moderator, admin. member: false creates the account without enrolling it in the space. Response 200 if the user already existed.

GETPATCHDELETE/users/:id

:id = LearnFloo identifier or ext:<externalId>.

GET → { "user": { "id": "k97…", "externalId": "lms-user-42", "name": "Alice Martin", "email": null, "image": null, "bio": null, "createdAt": "…",
  "member": { "role": "member", "status": "active", "totalXp": 320, "level": 3, "joinedAt": "…" },
  "stats": { "lessonsCompleted": 14, "liveSessionsJoined": 3, "posts": 2, "badges": 1 } } }

PATCH { "name": "Alice Martin-Dupont", "role": "contributor", "status": "suspended" }   → { "user": { … } }
DELETE → { "removed": true }   (removes the member from the space; their account and history are kept)

Only users created by your platform can be renamed. The space owner can be neither edited nor removed.

POST/users/:id/entry

Password-less sign-in (SSO) of a user of your platform into the LearnFloo space: feed, courses, calendar, leaderboard… Same principle as /live/entry: personal, single-use URL, valid for 15 minutes, to request at the click. The user is added as a member if they aren't one already. Reserved for ext:… users; with name, the user is created if they don't exist.

POST /users/ext%3Alms-user-42/entry
{ "name": "Alice Martin", "redirect": "courses" }
→ { "url": "https://app.learnfloo.com/enter/…", "expiresAt": "…", "userId": "k97…", "redirect": "/my-space/courses" }

redirect: home (default), posts, courses, calendar, live, leaderboard, tickets, challenges, or a path of the space (/my-space/courses/onboarding, /posts/<id>).

GET/users/:id/progress 3 tokens

A user's progress in each course of the space.

{ "user": { … }, "courses": [
  { "id": "c1…", "slug": "onboarding", "title": "Onboarding", "published": true, "lessonCount": 8,
    "completedLessons": 5, "progressPct": 63, "completed": false, "lastCompletedAt": "…" }
] }

GETPOST/users/:id/xp

GET: total, level and latest XP events of the user in the space (?limit=100, 1000 max).

{ "user": { … }, "totalXp": 320, "level": 3, "events": [ { "id": "…", "amount": 25, "source": "lesson_complete", "refId": "l3…", "createdAt": "…" } ] }

POST: awards XP from your platform (quiz passed, assignment handed in…). amount from -1000 to 1000, reason (60 characters, visible as the source api:<reason>) and refId optional. The level is recalculated. 400 if gamification is disabled in the space.

{ "amount": 50, "reason": "quiz-module-3", "refId": "quiz:874" }   → 201 { "user": { … }, "totalXp": 370, "level": 3, "awarded": 50 }

Leaderboard, badges, challenges

CallResponse
GET/leaderboard?limit=50&group={ "leaderboard": [ { "rank": 1, "id", "externalId", "name", "image", "totalXp", "level", "role" } ] } (500 max); group restricts to the members of a group
GET/badgesCatalogue: { "badges": [ { "id", "key", "name", "description", "icon", "criteriaType", "criteriaTarget", "xpReward" } ] }
GET/users/:id/badges{ "user": { … }, "badges": [ { "id", "key", "name", "description", "icon", "awardedAt" } ] }
GET/challenges{ "challenges": [ { "id", "title", "description", "objectiveType", "objectiveTarget", "xpReward", "startsAt", "endsAt", "status": "upcoming|active|ended", "participantCount", "completedCount" } ] }
GET/challenges/:idThe challenge with its participants ({ id, externalId, name, progress, completedAt, joinedAt })

Groups: principle and audience

A space can split its members into groups: a school's classes, a training organisation's cohorts, a company's teams or departments. The wording is set in the space settings (groupLabels of GET /space); the API always talks about groups. Each group has a slug usable instead of its identifier.

A course, a post, an event or a live session can be addressed to certain groups only: that's the groups field (array of identifiers or slugs) at creation and update, and groupIds when reading. Empty = the whole space. A member only sees content without an audience or addressed to one of their groups; the owner, admin, moderator and teacher roles see everything. Lists accept ?group=<id or slug> to return only the content addressed to that group.

POST /courses   { "title": "Maths — Year 12", "groups": ["terminale-a", "terminale-b"] }
POST /posts     { "title": "Friday's outing", "content": "…", "groups": ["terminale-a"] }
POST /live/sessions { "title": "Revision", "scheduledAt": "…", "groups": ["terminale-a"] }
GET  /courses?group=terminale-a

In a group, a member is member (pupil, learner…) or leader (teacher, manager…): a leader can manage the list of members of their group from the app, without any particular role in the space.

Automatic groups. A group can carry a rule: members who meet it are added as they go (and notified), current members as soon as the rule is created. Rules are one-way, nobody is removed automatically; you can always add or remove people by hand. Types: level_min (level 2 to 10), streak_days (days), challenge_joined and challenge_completed (challenge = id, see /challenges), badge (badge = id or key, see /badges), course_completed (course = id or slug). Then use the group as an audience: a course reserved for “Level 5 and above”, a live session for those who finished a challenge.

POST /groups  { "name": "Level 5 and above", "rule": { "kind": "level_min", "level": 5 } }
POST /groups  { "name": "Module 3 finished", "rule": { "kind": "course_completed", "course": "module-3" } }
PATCH /groups/module-3-finished  { "rule": null }        # becomes a manual group again
→ "group": { …, "rule": { "kind": "course_completed", "level": null, "days": null, "challengeId": null, "badgeId": null, "courseId": "c1…" } }

GETPOST/groups and GETPATCHDELETE/groups/:id

CallBody / response
GET/groups{ "groups": [ { "id", "slug", "name", "description", "color", "memberCount", "rule", "createdAt" } ] }, by name
POST/groups{ "name", "slug"?, "description"?, "color"?, "rule"? }201 { "group": { … } }. The slug is derived from the name if not given; color in the #rrggbb format
GET/groups/:idThe group (id or slug), a readable ruleLabel (in French), and its members: [ { "id", "externalId", "name", "email", "image", "groupRole": "leader|member", "auto", "addedAt" } ] (auto: added by the rule)
PATCH/groups/:id{ "name"?, "slug"?, "description"?: string | null, "color"?: string | null, "rule"?: object | null }
DELETE/groups/:id{ "deleted": true }. The group is removed from the audience of the content that targeted it (content that targeted only this group becomes visible to the whole space again)

Group members

CallBody / response
POST/groups/:id/members{ "users": ["k97…", "ext:lms-user-42"], "role"?: "member|leader" }{ "group", "added", "notFound": [] }. Users must already be members of the space (see POST /users); up to 500 per call, idempotent
DELETE/groups/:id/members/:userId{ "removed": true }. :userId = LearnFloo identifier or ext:…

GETPUT/users/:id/groups

GET: a user's groups in the space, with their groupRole. PUT replaces the whole list (handy to sync classes from your LMS):

PUT /users/ext%3Alms-user-42/groups
{ "groups": ["terminale-a", "club-robotique"], "role": "member" }
→ { "groups": [ { "id", "slug", "name", …, "groupRole": "member" } ] }

Group leaderboard, follow-up and documents

CallResponse
GET/groups/:id/leaderboard?limit={ "group", "leaderboard": [ { "rank", "id", "externalId", "name", "image", "totalXp", "level", "role", "groupRole" } ] }: the group's members ranked by XP
GET/groups/:id/stats 5 tokensFollow-up of each member: progress in the courses open to the group, lessons completed, posts, live sessions attended, XP over the last 30 days, last activity. See below
GET/groups/:id/documents{ "documents": [ { "id", "title", "url", "kind", "mimeType", "sizeBytes", "uploadedBy", "createdAt" } ] }, most recent first
POST/groups/:id/documents{ "title", "url", "kind"?, "mimeType"?, "sizeBytes"? }201 { "document" }. url: a file uploaded via POST /attachments, or any https link (kind: "link", never deleted from storage)
DELETE/groups/:id/documents/:docId{ "deleted": true }; a file uploaded through the API is erased from storage
GET /groups/terminale-a/stats
{ "group": { "id", "slug": "terminale-a", "name": "Year 12 A", … }, "generatedAt": "…",
  "summary": { "memberCount": 26, "learnerCount": 25, "activeLast7d": 19, "activeLast30d": 24, "inactive": 1,
               "avgProgressPct": 62, "avgVideoPct": 48, "videoLessonCount": 12, "lessonsCompleted": 410, "posts": 57, "livesAttended": 88, "xp30d": 5310 },
  "courses": [ { "id": "c1…", "title": "Maths — Year 12", "slug": "maths-terminale", "lessonCount": 12 } ],
  "members": [ { "id": "k97…", "externalId": "lms-user-42", "name": "Alice Martin", "email": null, "image": null, "groupRole": "member",
                 "totalXp": 320, "level": 3, "xp30d": 140, "lessonsCompleted": 9, "progressPct": 75,
                 "courses": [ { "id": "c1…", "slug": "maths-terminale", "completed": 9, "total": 12 } ],
                 "posts": 2, "livesAttended": 4, "videoPct": 55, "lastActivityAt": "…", "addedAt": "…" } ] }

summary covers the members with the member role (leaders are excluded from the averages); inactive = no activity for 30 days. The courses taken into account are the published courses addressed to the group or to the whole space.

GETPOST/courses

GET: published courses of the space (?includeUnpublished=1 to see everything, ?group= for those addressed to a group).

{ "courses": [ { "id": "c1…", "slug": "onboarding", "title": "Onboarding", "description": null, "coverImage": null,
                 "published": true, "lessonCount": 8, "groupIds": [], "createdAt": "…", "url": "/my-space/courses/onboarding" } ] }

POST: creates a course (draft by default). An optional author must be owner, admin or teacher. groups: audience (ids or slugs), empty = the whole space.

{ "title": "Onboarding", "slug": "onboarding", "description": "…", "coverImage": "https://…", "published": false, "groups": ["terminale-a"] }
→ 201 { "course": { … } }

GETPATCHDELETE/courses/:id

:id = course identifier or slug. GET returns the full structure; add ?withContent=1 for the lessons' HTML.

{ "course": { …, "modules": [ { "id": "m1…", "title": "Week 1", "description": null, "order": 0 } ],
  "lessons": [ { "id": "l1…", "courseId": "c1…", "moduleId": "m1…", "title": "Welcome", "order": 0,
                 "videoUrl": "https://…", "videoDurationSec": 312, "createdAt": "…", "url": "/my-space/courses/onboarding/lessons/l1…" } ] } }

PATCH: title, description, coverImage, published, groups ([] or null = the whole space). DELETE deletes the course, its modules, its lessons and the progress records.

Modules and lessons

CallBody / response
POST/courses/:id/modules{ "title", "description"? }201 { "module": { id, courseId, title, description, order } }
POST/courses/:id/lessons{ "title", "content", "format"?, "moduleId"?, "videoUrl"?, "videoDurationSec"? }201 { "lesson": { … , "content" } }. videoUrl: URL of a video (MP4, HLS, YouTube, Vimeo) or the playbackUrl of a studio video
GET/lessons/:id{ "lesson": { …, "content" } }
PATCH/lessons/:id{ "title"?, "content"?, "format"?, "moduleId"?: id | null, "videoUrl"?: url | null, "videoDurationSec"?: n | null, "order"? }
DELETE/lessons/:id{ "deleted": true }

GET/courses/:id/progress 5 tokens

Without parameters: all the learners who started the course, the most advanced first.

{ "course": { "id": "c1…", "title": "Onboarding", "lessonCount": 8 },
  "learners": [ { "id": "k97…", "externalId": "lms-user-42", "name": "Alice Martin", "email": null, "image": null,
                  "completedLessons": 8, "progressPct": 100, "completed": true, "lastCompletedAt": "…" } ] }

With ?externalId=lms-user-42 (or ?userId=k97…): the lesson-by-lesson detail for this learner.

{ "course": { … }, "user": { …, "completedLessons": 5, "progressPct": 63,
  "lessons": [ { "id": "l1…", "title": "Welcome", "moduleId": "m1…", "completedAt": "…" }, { "id": "l2…", "title": "…", "moduleId": "m1…", "completedAt": null } ] } }

POST/lessons/:id/complete

Marks a lesson as finished for a learner, as if they had completed it in LearnFloo (XP, quests, badges, lesson.completed webhook). Idempotent.

{ "user": { "externalId": "lms-user-42", "name": "Alice Martin" } }     or     { "userId": "k97…" }
→ 201 { "lessonId": "l1…", "user": { … }, "completedAt": "…", "created": true }    (200 if already finished)

Posts and comments

CallBody / response
GET/posts?category=&group=&limit=&cursor=The space's feed, most recent first: { "posts": [ … ], "nextCursor" }. Categories: general, announcements, question, wins, resource. group: posts addressed to that group
GET/posts/:idThe post with its comments (oldest first, parentId for replies)
POST/posts{ "title"?, "category"?, "content", "format"?, "attachments"?, "pinned"?, "groups"?, "author"? }201 { "post": { … } }. Mentions, XP and notifications as in the app. pinned reserved for moderators; an author without a staff role can only address (groups) their own groups
PATCH/posts/:id{ "title"?: string | null, "category"?, "content"?, "format"?, "pinned"?, "groups"? }
DELETE/posts/:idDeletes the post, its comments and reactions
POST/posts/:id/comments{ "content", "format"?, "parentCommentId"?, "attachments"?, "author"? }201 { "comment": { … } }
DELETE/comments/:idDeletes the comment and its replies
{ "post": { "id": "p1…", "title": "Welcome, cohort 12", "category": "announcements", "content": "<p>…</p>", "text": "…",
  "attachments": [], "pinned": true, "groupIds": ["g1…"], "commentCount": 3, "reactionCount": 12, "createdAt": "…",
  "author": { "id": "k12…", "externalId": null, "name": "Julie", "email": "julie@client.com", "image": "https://…", "role": "admin", "level": 5 },
  "url": "/my-space/posts/p1…" } }

Calendar events

CallBody / response
GET/events?from=&to=&type=&group=Events by ascending start date; from / to in ISO or milliseconds. Scheduled live sessions appear with liveSessionId
GET/events/:id{ "event": { "id", "type", "title", "description", "startsAt", "endsAt", "location", "liveSessionId", "groupIds", "createdAt" } }
POST/events{ "type"?, "title", "description"?, "startsAt", "endsAt"?, "location"?, "groups"?, "author"? }. Types: event (default), workshop, masterclass, deadline, qa. Live sessions are created via /live/sessions
PATCH/events/:idSame fields; endsAt: null to clear it, groups: [] to open it to the whole space. An event linked to a live session is edited via PATCH /live/sessions/:id
DELETE/events/:id{ "deleted": true }

Studio videos

Recordings from the LearnFloo studio (screen, camera, imports) and imported live replays, hosted on Bunny Stream.

CallResponse
GET/videos?status=ready&limit=&cursor={ "videos": [ … ], "nextCursor" }, most recent first. Statuses: uploading, processing, ready, failed
GET/videos/:id{ "video": { … } }
PATCH/videos/:id{ "title"?, "description"? }
{ "video": { "id": "v1…", "title": "Module 3 demo", "description": null, "mode": "screen_camera", "status": "ready",
  "durationSec": 612, "width": 1920, "height": 1080, "encodeProgress": null, "thumbnailUrl": "https://…",
  "playbackUrl": "https://…/play_1080p.mp4", "hlsUrl": "https://…/playlist.m3u8", "embedUrl": "https://iframe.mediadelivery.net/embed/…",
  "lessonId": null, "liveSessionId": null, "createdAt": "…", "updatedAt": "…", "author": { … } } }

Playback URLs are only returned for ready videos. As for replays, the MP4 requires a Referer header: embed it in a page. Videos are uploaded from the app (studio); the API doesn't take video files.

GETPOST/media and GETPATCHDELETE/media/:id

The space's media library: the images, PDFs, videos, embeds and stickers used in live scenes and in the studio. GET: ?kind=image|pdf|video|embed|sticker&limit=&cursor=.

{ "media": [ { "id": "…", "kind": "pdf", "title": "Module 3 slides", "url": "https://…", "thumbnailUrl": null, "videoId": null, "sizeBytes": 812000,
              "folder": { "id": "…", "name": "Live 12/09" }, "createdAt": "…" } ], "nextCursor": null }

POST adds an item by URL (https, hosted by you or on a CDN):

{ "kind": "image", "url": "https://cdn.your-site.com/offer.png", "title": "Back-to-school offer", "folder": "Live 12/09" }
→ 201 { "media": { "id": "m12…", "kind": "image", … } }

kind: image, pdf, video (MP4 file playable directly), embed (YouTube, Vimeo or Loom page, converted into a player), sticker (transparent PNG) or audio (MP3, WAV, OGG, M4A: a scene's sound). folder: identifier or name of a folder (created on the fly if it doesn't exist; list with GET /media/folders, explicit creation with POST /media/folders { "name" }). An external PDF must allow requests from app.learnfloo.com (CORS header); images and videos don't have this constraint. To upload a file rather than a URL, go through POST /attachments then give the URL obtained.

PATCH /media/:id: title, folder (null for the root). DELETE /media/:id deletes the item (and the file if LearnFloo hosts it); scenes that used it keep the URL.

E-mails: principle

The whole e-mail layer of a space is driven by the API, under /emails/…: the campaigns to the members (writing, preview, test, immediate or scheduled send, statistics), the segments that define who receives them, the reusable texts, the wording of the automatic e-mails (live invitations), the automatic digest of what is new, the client's sending domain and each member's preferences. The same actions exist as MCP tools ("E-mails" family).

Common rules: the author of every write is the creator of the API key (they receive the test sends); bodies are plain text (blank line = paragraph) or HTML with "format": "html"; {{prenom}} (first name) and {{espace}} (space name) are replaced for each reader; every bulk e-mail carries the person's unsubscribe link, and an unsubscribed person receives neither campaign nor digest from that space any more. A read-only key can use the GET routes and POST /emails/segments/preview.

GETPOST/emails/campaigns and GETPATCHDELETE/emails/campaigns/:id

POST { "subject": "What's new at {{espace}}", "previewText": "Three novelties this week", "body": "Hello {{prenom}},

Here is…",
       "segmentId": "…", "templateId": "…", "send": "now" }
→ 201 { "campaign": { "id": "…", "kind": "campaign", "subject": "…", "previewText": "…", "body": "<p>…</p>", "status": "draft",
  "segmentId": null, "audienceLabel": null, "scheduledAt": null, "sentAt": null, "recipientCount": null,
  "stats": { "sent": 0, "delivered": 0, "opened": 0, "clicked": 0, "bounced": 0, "spam": 0, "unsubscribed": 0 },
  "error": null, "createdAt": "…", "updatedAt": "…" } }

GET /emails/campaigns?status=draft|scheduled|sending|sent|cancelled|failed&kind=campaign|digest lists the last 200, newest first (kind: "digest": the automatic digests sent, with their statistics). POST creates a draft; templateId takes a reusable text for what is not given; send is "now" or an ISO date to send in the same call (3 tokens). PATCH changes a draft only (subject, previewText, body + format, segmentId, null to remove). DELETE deletes (a scheduled campaign is unscheduled; while sending, 403).

stats come from the provider's receipts: delivered, opened, clicked, bounced, spam and unsubscribed fill in during the hours after the send.

Send, schedule, cancel, test, preview

POST /emails/campaigns/:id/send { "at": "2026-09-22T09:00:00+02:00" }   → { "campaign": { "status": "scheduled", … } }   (5 tokens; without "at": immediate send, status "sending")
POST /emails/campaigns/:id/cancel                                        → { "campaign": { "status": "draft", … } }
POST /emails/campaigns/:id/test                                          → { "sent": true, "to": "you@…", "error": null }   (to the key's creator, subject prefixed [Test]; 3 tokens)
GET  /emails/campaigns/:id/preview                                       → { "subject": "…", "html": "…", "text": "…" }   (full rendering, frame and unsubscribe footer included)

A send is irreversible as soon as the campaign turns sending: an assistant must show the preview and get the user's agreement before calling /send. The email.campaign.sent and email.campaign.failed webhooks report the end of the send.

GETPOST/emails/segments and GETPATCHDELETE/emails/segments/:id

A segment is a saved filter on the active members of the space, resolved at send time. Every filter given must match; users are added to the result.

POST { "name": "Silent newcomers", "description": "…",
       "filters": { "roles": ["member"], "groups": ["cohort-2026"], "minLevel": 1, "maxLevel": 2, "joinedWithinDays": 30, "activeWithinDays": 14, "users": ["ext:u-42"] } }
→ 201 { "segment": { "id": "…", "name": "…", "description": "…", "memberCount": 12,
                     "filters": { "roles": ["member"], "groups": [ { "id", "slug", "name" } ], "minLevel": 1, "maxLevel": 2, "joinedWithinDays": 30, "activeWithinDays": 14, "users": [ { "id", "name" } ] },
                     "createdAt": "…", "updatedAt": "…" } }
POST /emails/segments/preview { "filters": { … } }   → { "memberCount": 12, "sample": [ { "id", "name" } ], "capped": false, "filters": { … } }   (nothing saved; 3 tokens)
GET  /emails/segments/:id                            → { "segment": { …, "memberCount": recomputed, "sample": […], "capped": false } }   (3 tokens)

roles: owner, admin, moderator, teacher, contributor, member; groups accepts ids or slugs; activeWithinDays relies on the last time the app was opened. Fifty segments per space; beyond 5,000 members, capped turns true.

GETPOST/emails/templates and PATCHDELETE/emails/templates/:id

Reusable campaign texts (subject, preview text, body), passed as templateId when creating a campaign. POST { "name", "subject", "previewText", "body", "format" }201 { "template": { "id", "name", "subject", "previewText", "body", "updatedAt" } }. Fifty per space.

GET/emails/notifications and PUTDELETE/emails/notifications/:kind/:lang

The wording of the automatic e-mails the space sends, per kind and language (kind: live_invitation for now; lang: fr, en, es). The frame (colour, button, calendar links, footer) stays the platform's.

GET → { "templates": [ { "kind": "live_invitation", "lang": "en", "subject": "…", "body": "…", "custom": false, "updatedAt": null }, … ],
        "variables": { "live_invitation": [ { "key": "prenom", "label": { "fr", "en", "es" }, "sample": { … } }, … ] },
        "defaults": { "live_invitation": { "en": { "subject", "body" }, … } } }
PUT    /emails/notifications/live_invitation/en { "subject": "…", "body": "…", "format": "html" }   → { "template": { …, "custom": true } }
DELETE /emails/notifications/live_invitation/en                                                     → default text restored
GET    /emails/notifications/live_invitation/en/preview?subject=…&body=…                          → { "subject", "html" }   (sample values; without parameters: the saved text)

GETPATCH/emails/digest and GET…/digest/preview, POST…/digest/send

Automatic digest of what is new (posts, courses, lessons, upcoming live sessions and events, challenges) sent to the members at the chosen cadence.

GET → { "digest": { "enabled": false, "cadence": "weekly", "weekday": 1, "dayOfMonth": 1, "hour": 9, "timeZone": "Europe/Paris",
                    "sections": { "posts": true, "courses": true, "lessons": true, "lives": true, "events": true, "challenges": true },
                    "maxItems": 5, "subject": "", "intro": "", "onlyIfNews": true, "segmentId": null, "lastSentAt": null, "nextAt": null, "configured": false } }
PATCH { "enabled": true, "cadence": "weekly", "weekday": 2, "hour": 8, "sections": { "challenges": false }, "segmentId": null }   → { "digest": { …, "nextAt": "…" } }   (partial update)
GET  /emails/digest/preview          → { "subject", "html", "empty": false }   (as it would go out now; 3 tokens)
POST /emails/digest/send { "test": true }   → { "sent": 1, "skipped": false, "error": null }   (test: to the key's creator; without "test": to the whole audience, 5 tokens)

cadence: daily, weekly (weekday 0 = Sunday … 6) or monthly (dayOfMonth 1 to 28); hour in the local time of timeZone; subject and intro accept {{prenom}} and {{espace}}, null restores the platform text.

GETPOSTPATCHDELETE/emails/sender and POST…/sender/check, …/sender/test

Where the space's e-mails leave from. By default "via LearnFloo"; with the paid "sending domain" option, from the client's domain once its DNS records are verified. Enabling the option stays in the app (it is billed to the owner); everything else is driven here.

GET → { "option": true, "active": false, "configured": true, "defaultFrom": "invitations@learnfloo.com", "priceCents": 1000, "currency": "eur",
        "sender": { "id": "…", "domain": "out.myschool.com", "fromEmail": "hello@out.myschool.com", "fromName": "My school", "replyTo": "hello@myschool.com",
                    "dns": [ { "type": "TXT", "host": "…_domainkey.out.myschool.com", "value": "k=rsa;…", "verified": false, "purpose": "dkim" },
                             { "type": "CNAME", "host": "pm-bounces.out.myschool.com", "value": "pm.mtasv.net", "verified": false, "purpose": "return-path" } ],
                    "verifiedAt": null, "lastCheckedAt": null, "lastError": null, "testedAt": null, "sentCount": 0, "bounceCount": 0, "complaintCount": 0, "suspendedAt": null, "suspendReason": null } }
POST   /emails/sender { "domain": "out.myschool.com", "fromEmail": "hello@out.myschool.com", "fromName": "My school", "replyTo": "hello@myschool.com" }   → 201, same answer as GET
POST   /emails/sender/check   → { "dkimVerified": true, "returnPathVerified": true, …GET }   (asks for the DNS check)
PATCH  /emails/sender { "fromEmail", "fromName", "replyTo" }
POST   /emails/sender/test    → { "sent": true, "from": "…", "error": null }   (to the key's creator; 3 tokens)
DELETE /emails/sender         → { "removed": true }   (back to "via LearnFloo")

active is true when e-mails really leave from the domain (option on, both records verified, no suspension). suspendedAt: sending from the domain stopped by the bounce and complaint rule, to be seen with support.

GETPUT/users/:id/email-preferences

GET → { "user": { "id", "externalId", "name", "email", "image" }, "hasEmail": true, "subscribed": true, "unsubscribedAt": null, "unsubscribedFromCampaignId": null }
PUT { "subscribed": false }   → same object, updated

A member's subscription to the space's bulk e-mails (campaigns and digest), to read or to mirror from your platform: setting subscribed to false equals a click on "unsubscribe" (email.unsubscribed webhook). Individual e-mails (live invitations) are not affected.

Webhooks: principle and events

Rather than polling the API, get notified: LearnFloo sends a JSON POST to the URL of your choice for each subscribed event. Up to 10 webhooks per space.

POST https://your-backend.com/learnfloo
Content-Type: application/json
X-LearnFloo-Event: live.session.ended
X-LearnFloo-Delivery: d7…
X-LearnFloo-Signature: t=1757404800,v1=5f1a…

{ "id": "d7…", "event": "live.session.ended", "createdAt": "2026-09-12T15:01:40.000Z",
  "space": { "id": "…", "slug": "my-space" },
  "data": { "session": { … session object … } } }
Eventdata
live.session.created, live.session.updated, live.session.started, live.session.ended, live.session.cancelled{ session } (see GET /live/sessions/:id). After ended, call /attendance for attendance
live.replay.ready{ session } with replayUrl set (raw MP4, as soon as the live session ends)
live.conversion{ session, conversion } — sign-up reported via /conversions
live.poll.opened, live.poll.closed{ session, poll } — a poll or quiz launched, then closed with its results
live.scene.changed{ session, scene } — a scene put on air (scene set to null: automatic layout)
support.ticket.created{ ticket, author, message: { content, text, attachments } }
support.ticket.message{ ticket, message: { id, content, text, attachments, fromStaff, author, createdAt } } — a reply from the user or the team (never internal notes)
support.ticket.status{ ticket, previousStatus, byStaff }
lesson.completed{ lesson: { id, title, courseId, moduleId }, user, completedAt }
video.lead{ lead: { id, email, name, userId, source, lessonId, interactionId, createdAt } } — email left in an email capture of a video (source: lesson, or about for the presentation video)
post.created{ post }
comment.created{ comment, post: { id, title } }
member.joined{ user, member } — new member (joined from LearnFloo or created by the API)
email.campaign.sent, email.campaign.failed{ campaign } — end of send of a campaign or a digest (kind), with recipientCount, stats.sent and error
email.unsubscribed{ user, campaignId, unsubscribedAt } — a member unsubscribed from the space's bulk e-mails (unsubscribe link, page, or API)
pingTest delivery (POST /webhooks/:id/test)

The user / author objects contain { id, externalId, name, email, image }: externalId lets you find the person on your side.

Signature and retries

Reply 2xx within 10 seconds (process afterwards). Check the signature: HMAC-SHA256 of the text <t>.<raw body> with the webhook's secret, compared with v1; reject if t is more than 5 minutes old.

import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyLearnFloo(rawBody, signatureHeader, secret) {
  const { t, v1 } = Object.fromEntries(signatureHeader.split(',').map((kv) => kv.split('=')))
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}

On failure (non-2xx response, timeout, network error), delivery is retried after 1 min, 5 min, 30 min then 2 h (5 attempts). The same event may therefore arrive twice: use id (or X-LearnFloo-Delivery) to deduplicate. After 20 consecutive failures, the webhook is paused (active: false); reactivate it with PATCH once your endpoint is fixed.

Managing webhooks

CallBody / response
GET/webhooks{ "webhooks": [ { "id", "url", "events", "description", "active", "createdAt", "updatedAt", "lastDeliveryAt", "lastStatus", "failureCount" } ] }
POST/webhooks{ "url": "https://…", "events": ["live.session.ended", "live.replay.ready"], "secret"?, "description"? }201 { "webhook": { …, "secret" } }. events: ["*"] to receive everything. The secret (16 to 128 characters, generated if absent) is only returned at creation. https URL required
GET/webhooks/:id{ "webhook": { … } }
PATCH/webhooks/:id{ "url"?, "events"?, "description"?, "active"? }. active: true resets the failure counter
DELETE/webhooks/:id{ "deleted": true }
POST/webhooks/:id/testSends a ping event → 202 { "sent": true }
GET/webhooks/:id/deliveries?limit=50Delivery log (30 days): { "deliveries": [ { "id", "event", "attempt", "status": "pending|success|failed", "responseStatus", "error", "createdAt", "deliveredAt" } ] }

OpenAPI

The OpenAPI 3.1 description of the API is generated from the MCP tool catalogue (one operation per route, operationId = MCP tool name), for client generators, API tools and the GPT Actions of ChatGPT:

GET https://api.learnfloo.com/openapi.json                 the whole API (159 operations), API key or OAuth security
GET https://api.learnfloo.com/openapi.json?preset=gpt      the 30 operations of the LearnFloo GPT (ChatGPT's limit per action), OAuth only
GET https://api.learnfloo.com/openapi.json?tools=get_space,list_posts,create_post    a subset of tools
GET https://api.learnfloo.com/openapi.json?groups=live,courses                       whole families

A custom GPT is built from this file and a confidential OAuth client (see MCP server); the steps and the instructions of the GPT are in the LearnFloo Agent Kit.

MCP server (AI assistants)

The same API is exposed as an MCP server (Model Context Protocol) for AI assistants and agents: Claude (app, claude.ai, Claude Code), ChatGPT, Cursor, or any MCP client. The assistant sees the space through named tools (list_live_sessions, create_post, get_group_stats…), each equivalent to a route of this reference: same fields, same responses, same errors.

URL: https://api.learnfloo.com/mcp        (“Streamable HTTP” transport, JSON-RPC 2.0, stateless)

Two ways to connect, chosen by the user according to the level of security they want:

OAuth (recommended)API key
PrincipleThe assistant opens a LearnFloo page: the user signs in with their account, chooses the space, the access level and the tools, and authorises. One-hour access tokens renewed automatically, revocable.A key of the space (Authentication), in the Authorization: Bearer lf_live_… header or, for clients without headers, in the URL https://api.learnfloo.com/mcp?key=lf_live_….
Who canOwner or administrator of the space, with their own accountAnyone who holds the key
Actions attributed toThe user who authorisedThe key's creator
RiskNo secret in the URL or in the client configurationA key in the URL can end up in logs; if it leaks, revoke it
Where it's managedSpace settings → API and MCP: keys and OAuth authorisations side by side, each with its access level and its enabled tools

Client configuration

ClientOAuthAPI key
claude.ai, Claude appSettings → Connectors → Add a custom connector, URL https://api.learnfloo.com/mcp, then “Connect”: the LearnFloo authorisation page opensSame screen, URL https://api.learnfloo.com/mcp?key=lf_live_…, without authentication
Claude Codeclaude mcp add --transport http learnfloo https://api.learnfloo.com/mcp then /mcp to authenticateclaude mcp add --transport http learnfloo https://api.learnfloo.com/mcp --header "Authorization: Bearer lf_live_…"
Cursor, Windsurf, others{ "mcpServers": { "learnfloo": { "url": "https://api.learnfloo.com/mcp" } } } (the client starts the authorisation){ "mcpServers": { "learnfloo": { "url": "https://api.learnfloo.com/mcp", "headers": { "Authorization": "Bearer lf_live_…" } } } }

OAuth 2.1 server: discovery /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server, dynamic client registration POST /oauth/register (public clients), GET /oauth/authorize (PKCE S256 required), POST /oauth/token (authorization_code, refresh_token with rotation), POST /oauth/revoke. Scopes read and full; the level actually granted is the one chosen on the authorisation page, returned in scope. A 401 response from the MCP server carries WWW-Authenticate: Bearer resource_metadata=… so that the client discovers the OAuth server.

Confidential clients. For a ChatGPT GPT or a server-side integration, the LearnFloo team registers a client with a client_secret (client_secret_post or client_secret_basic, on top of PKCE) and wildcard redirect URLs (https://chatgpt.com/aip/*/oauth/callback). The access tokens obtained (lfo_…) are accepted by the MCP server and by the REST API (Authorization: Bearer lfo_…): on the REST API a grant opens only the routes of the tools chosen on the consent page (one tool = one route), 403 otherwise.

The key in the URL (?key=) is visible in the client's history and logs: keep it for clients without headers when OAuth isn't wanted, create a dedicated key (named after the assistant) and revoke it at the slightest doubt. A read-only MCP key is enough to analyse, summarise and answer questions.

Enabled tools. For each key (in the settings) and each OAuth authorisation (on the authorisation page, editable later in the settings), an access level (read only or full) and the list of enabled MCP tools: all the tools of the level (default), or a selection (for example, a production assistant that only sees a live session's scenes and polls; a support assistant that only sees tickets). tools/list only returns the enabled tools; a disabled tool is refused as unknown.

Rate and costs. Each JSON-RPC message uses the tokens of the underlying route (rate limit); initialize, ping and tools/list use one. An API error (400, 403, 404…) comes back as an isError result with its message; a refused key or a reached limit as a JSON-RPC error (HTTP 401 or 429, Retry-After). Returned objects are in structuredContent and, as text, in content.

What MCP doesn't do: file uploads (POST /attachments remains a REST route) and incoming webhooks. Write calls made by an assistant are attributed to the key's creator, unless an explicit author or user is given, as in REST.

Recipes (prompts) and assistant kit. The server also serves MCP prompts (prompts/list, prompts/get, optional request argument): nine job recipes for the owner of a space (space report, live preparation, control room, debrief, course building, feed animation, member follow-up, support tickets, integration), offered by the clients that list prompts (Claude, Cursor…) with nothing to install. The same recipes exist as skills (open Agent Skills format) and as a Claude Code plugin in the LearnFloo Agent Kit: claude plugin marketplace add learnfloo/agent-kit then claude plugin install learnfloo@learnfloo; install script for Codex, Cursor and Gemini CLI. Every recipe shows the content before any write and sticks to reading with a read-only key.

Tools

159 tools, by family. “Access” column: read (open to read-only keys) or write.

Space

ToolDescriptionAccessRoute
get_spaceThe space behind the API key: name, slug, visibility, price, member and course counts, group vocabulary, URL. Call it first to know where you are.readGET /space
get_space_usageUsage of the space since the last invoice (webinar hours, interactive and spectator hours, plays, stored minutes), account state (billing active, live allowed, cap) and the rate limit of the key.readGET /space/usage

Members and gamification

ToolDescriptionAccessRoute
list_usersMembers of the space, paginated (200 max per page), with role, status, XP and level.readGET /users
get_userOne user with membership (role, status, XP, level) and stats (lessons completed, lives joined, posts, badges).readGET /users/:id
create_userCreates (or updates, idempotent on externalId) a user of your platform and adds them as member of the space.writePOST /users
update_userChanges the name (users created by your platform only), the role or the status of a member. The owner cannot be changed.writePATCH /users/:id
remove_userRemoves the member from the space (account and history kept).writeDELETE /users/:id
create_user_entry_urlSingle-use, 15-minute sign-in URL that opens the space (feed, courses, calendar…) for a user of your platform (ext:… only; created when name is given). Ask for it at click time.writePOST /users/:id/entry
get_user_progressProgress of a user in every course of the space (lessons completed, percentage).readGET /users/:id/progress
get_user_xpTotal XP, level and latest XP events of a user in the space.readGET /users/:id/xp
award_xpGives (or takes, negative amount) XP to a user from your platform: quiz passed, homework handed in… Level recalculated.writePOST /users/:id/xp
get_user_badgesBadges awarded to a user.readGET /users/:id/badges
get_leaderboardMembers ranked by XP (500 max), optionally within one group.readGET /leaderboard
list_badgesBadges of the space with their criteria and XP reward.readGET /badges
list_challengesChallenges of the space (objective, dates, status, participants).readGET /challenges
get_challengeOne challenge with its participants and their progress.readGET /challenges/:id

Live sessions

ToolDescriptionAccessRoute
list_live_sessionsLive sessions of the space, newest first (200 max), with status, dates, replay and counters.readGET /live/sessions
get_live_sessionOne live session: status, schedule, participants now, recording and replay, chat mode, conversions, active scene.readGET /live/sessions/:id
create_live_sessionSchedules a live (webinar or conference) in the space. Returns the session and hostUrl, the page where the host starts it.writePOST /live/sessions
update_live_sessionChanges a live. Before it starts: everything (date, duration, format, capacity, recording, chat, audience, replay rule). Once started: title, description, audience, capacity, chat, replay rule. Once ended: title, description, audience, replay rule.writePATCH /live/sessions/:id
cancel_live_sessionCancels a scheduled live (calendar event removed). A running or ended live cannot be cancelled.writeDELETE /live/sessions/:id
create_live_entry_urlPersonal, single-use, 15-minute URL that signs a user of your platform into a live (viewer or speaker). Ask for it at click time.writePOST /live/entry
get_live_attendanceAttendance report: summary (attended, peak, average watch time, spectator and interactive hours) and per participant watch time and percentage. Costs 5 rate-limit tokens.readGET /live/sessions/:id/attendance
get_live_participantsInstant state of the people registered on a live: connected, spectator, hand raised, role.readGET /live/sessions/:id/participants
get_live_chatChat messages of a live, oldest first (2000 max), kept after the live.readGET /live/sessions/:id/chat
get_live_replayThe replay of a live: MP4, HLS and embed URLs, thumbnail, duration, linked lesson, views.readGET /live/sessions/:id/replay
report_live_conversionReports a sign-up or purchase made during a live: announced in the chat and on the scene, counted in the report, webhook live.conversion.writePOST /live/sessions/:id/conversions
list_live_invitesShareable invitation links of a live (speaker, viewer, assistant, moderator).readGET /live/sessions/:id/invites
create_live_inviteCreates a reusable invitation link for people without an account on your platform (external speaker, control room assistant, chat moderator).writePOST /live/sessions/:id/invites
revoke_live_inviteRevokes an invitation link.writeDELETE /live/sessions/:id/invites/:inviteId
set_participant_roleGives or takes the floor, or names a control-room assistant or a moderator, for a participant already registered on the live. Applied immediately.writePUT /live/sessions/:id/participants/:userId/role
set_extra_viewersAdds viewers to the counter shown by the viewers widget of the scenes (never counted in attendance or billing). 0 removes it.writePUT /live/sessions/:id/viewers
stage_chat_messageShows a chat message on the scene (message slot, else bottom left); null removes it.writePUT /live/sessions/:id/chat/stage

Scenes and production

ToolDescriptionAccessRoute
get_live_scenesThe whole control room of a live: scenes with slots and overlays, active scene, auto-advance state, media state (PDF page, video playing), temporary bands.readGET /live/sessions/:id/scenes
get_live_sceneOne scene of a live.readGET /live/sessions/:id/scenes/:sceneId
create_sceneCreates a scene in a live, from its fields or from a template of the space ({ template: "name" }). activate: true puts it on air right away. Works before and during the live.writePOST /live/sessions/:id/scenes
update_sceneChanges the given fields of a scene; slots and overlays, when present, replace the whole lists (use the overlay and slot tools to touch one element). Visible immediately if on air.writePATCH /live/sessions/:id/scenes/:sceneId
delete_sceneRemoves a scene (automatic layout if it was on air). Returns the control room.writeDELETE /live/sessions/:id/scenes/:sceneId
activate_scenePuts a scene on air for everyone (room, audience stream, recording, external broadcasts). Its videos restart from the beginning.writePOST /live/sessions/:id/scenes/:sceneId/activate
set_active_sceneSets the scene on air by id; null returns to the automatic layout (cameras in a grid, screen share large).writePUT /live/sessions/:id/scenes/active
reorder_scenesSets the order of the scenes of a live.writePUT /live/sessions/:id/scenes/order
set_auto_advanceTurns the automatic scene sequence on or off (each scene with durationSec gives way to nextSceneId or the next one), with or without loop.writePUT /live/sessions/:id/scenes/auto
show_bandShows a band at the top of every screen for a few seconds, whatever the scene (break, announcement).writePOST /live/sessions/:id/bands
control_scene_mediaTurns the page of a PDF, or plays, pauses and seeks a video or the scene sound present in a scene, synchronised for everyone.writePUT /live/sessions/:id/media/:mediaId
add_overlayAdds a sticker, a band or a widget (poll, message, viewers) to a scene. Give it a stable id to update it later.writePOST /live/sessions/:id/scenes/:sceneId/overlays
update_overlayChanges the given fields of one overlay of a scene ({ text }, { visible: false }, { x, y }…). Visible immediately if on air.writePATCH /live/sessions/:id/scenes/:sceneId/overlays/:overlayId
update_overlay_everywhereChanges the overlay carrying this id in every scene of the live: a common band (seats left, price, next step) updated in one call.writePATCH /live/sessions/:id/overlays/:overlayId
delete_overlayRemoves an overlay from a scene.writeDELETE /live/sessions/:id/scenes/:sceneId/overlays/:overlayId
add_slotAdds a slot (host, guest, screen, media, embed) to a scene; position 0 = main.writePOST /live/sessions/:id/scenes/:sceneId/slots
update_slotChanges a slot of a scene (other media, guest, fit, geometry in free layout, position).writePATCH /live/sessions/:id/scenes/:sceneId/slots/:slotId
delete_slotRemoves a slot from a scene.writeDELETE /live/sessions/:id/scenes/:sceneId/slots/:slotId
list_scene_templatesScene templates of the space, reusable in every live.readGET /scene-templates
create_scene_templateSaves a scene template from fields ({ name, scene }) or from an existing scene ({ name, sessionId, sceneId }). Same name = replaced.writePOST /scene-templates
delete_scene_templateDeletes a scene template (id or name).writeDELETE /scene-templates/:id

Polls and quizzes

ToolDescriptionAccessRoute
list_live_pollsPolls and quizzes of a live with their counts, kept after the live.readGET /live/sessions/:id/polls
get_live_pollOne poll or quiz with its results.readGET /live/sessions/:id/polls/:pollId
create_pollCreates a poll (opinion) or a quiz (with correct answers) in a live, as a draft, or opened right away with open: true during the live. 2 to 6 options.writePOST /live/sessions/:id/polls
update_pollChanges a draft poll (same fields as creation).writePATCH /live/sessions/:id/polls/:pollId
delete_pollDeletes a poll and its votes, whatever its status.writeDELETE /live/sessions/:id/polls/:pollId
open_pollOpens the vote to the participants (one poll open at a time, running live only).writePOST /live/sessions/:id/polls/:pollId/open
close_pollCloses the vote; results shown unless showResults is false.writePOST /live/sessions/:id/polls/:pollId/close
show_poll_resultsShows or hides the counts to the participants (and the correct answer of a quiz).writePUT /live/sessions/:id/polls/:pollId/results
stage_pollShows the poll in the scene (poll slot, else bottom right) in the room, the broadcast and the recording; false removes it.writePUT /live/sessions/:id/polls/:pollId/stage
show_poll_bandShows a 12-second band on the scene with the leading answers (or the right answer and the success rate of a quiz).writePOST /live/sessions/:id/polls/:pollId/band

Support

ToolDescriptionAccessRoute
list_support_ticketsTickets of one external user (externalId) or of the whole space (scope: "space", team dashboard), newest first.readGET /support/tickets
get_support_ticketA ticket with its messages (never the internal notes). Give externalId of the user, or scope: "space" for the team.readGET /support/tickets/:id
create_support_ticketOpens a support ticket on behalf of a user of your platform. The team of the space is notified.writePOST /support/tickets
reply_support_ticketAdds a message to a ticket, as the user ({ user }) or as the team ({ staff: {} } or { staff: { email } }): a staff reply puts the ticket in pending.writePOST /support/tickets/:id/messages
set_support_ticket_statusChanges the status of a ticket: as the user (resolved or open), or as the team (any status, plus priority and assigneeEmail).writePOST /support/tickets/:id/status

Groups

ToolDescriptionAccessRoute
list_groupsGroups of the space (classes, teams, cohorts… see groupLabels of get_space) with member counts and automatic rules.readGET /groups
get_groupOne group (id or slug) with its members and their group role (leader or member).readGET /groups/:id
create_groupCreates a group, manual or automatic (rule: level_min, streak_days, challenge_joined, challenge_completed, badge, course_completed).writePOST /groups
update_groupChanges name, slug, description, color or rule (null = manual group) of a group.writePATCH /groups/:id
delete_groupDeletes a group; the content addressed only to it becomes visible to the whole space.writeDELETE /groups/:id
add_group_membersAdds members of the space to a group (500 per call, idempotent), as member or leader.writePOST /groups/:id/members
remove_group_memberRemoves one member from a group.writeDELETE /groups/:id/members/:userId
get_user_groupsGroups of a user in the space, with their group role.readGET /users/:id/groups
set_user_groupsReplaces the whole list of groups of a user (sync of classes from your platform).writePUT /users/:id/groups
get_group_leaderboardMembers of a group ranked by XP.readGET /groups/:id/leaderboard
get_group_statsFollow-up of every member of a group: course progress, lessons completed, posts, lives attended, XP over 30 days, last activity, plus a summary. Costs 5 rate-limit tokens.readGET /groups/:id/stats
list_group_documentsShared documents of a group, newest first.readGET /groups/:id/documents
add_group_documentAdds a document (file stored via POST /api/v1/attachments, or any https link) to a group.writePOST /groups/:id/documents
delete_group_documentDeletes a document of a group.writeDELETE /groups/:id/documents/:docId

Courses

ToolDescriptionAccessRoute
list_coursesPublished courses of the space (includeUnpublished for drafts too), with lesson counts and audience.readGET /courses
get_courseA course (id or slug) with its modules and lessons; withContent adds the HTML of the lessons.readGET /courses/:id
create_courseCreates a course (draft by default). The author, when given, must be owner, admin or teacher.writePOST /courses
update_courseChanges title, description, cover, published state or audience of a course.writePATCH /courses/:id
delete_courseDeletes a course with its modules, lessons and progress.writeDELETE /courses/:id
create_moduleAdds a module (section) to a course.writePOST /courses/:id/modules
create_lessonAdds a lesson to a course, with rich content and an optional video (MP4, HLS, YouTube, Vimeo, or playbackUrl of a studio video).writePOST /courses/:id/lessons
get_lessonOne lesson with its content.readGET /lessons/:id
update_lessonChanges title, content, module, video or order of a lesson.writePATCH /lessons/:id
delete_lessonDeletes a lesson.writeDELETE /lessons/:id
get_course_progressEvery learner who started the course (most advanced first), or the lesson-by-lesson detail of one learner (externalId or userId). Costs 5 rate-limit tokens.readGET /courses/:id/progress
complete_lessonMarks a lesson as completed for a learner, as if validated in LearnFloo (XP, quests, badges, webhook). Idempotent.writePOST /lessons/:id/complete

Community feed

ToolDescriptionAccessRoute
list_postsFeed of the space, newest first, paginated; categories general, announcements, question, wins, resource.readGET /posts
get_postA post with its comments (oldest first, parentId for replies).readGET /posts/:id
create_postCreates a post in the feed (mentions, XP and notifications as in the app). pinned is for moderators.writePOST /posts
update_postChanges title, category, content, pinned state or audience of a post.writePATCH /posts/:id
delete_postDeletes a post with its comments and reactions.writeDELETE /posts/:id
create_commentAdds a comment (or a reply with parentCommentId) to a post.writePOST /posts/:id/comments
delete_commentDeletes a comment and its replies.writeDELETE /comments/:id

Calendar

ToolDescriptionAccessRoute
list_eventsCalendar events by start date (scheduled lives included with liveSessionId).readGET /events
get_eventOne calendar event.readGET /events/:id
create_eventCreates a calendar event (lives are created with create_live_session).writePOST /events
update_eventChanges an event (an event linked to a live is changed with update_live_session).writePATCH /events/:id
delete_eventDeletes a calendar event.writeDELETE /events/:id

Studio videos

ToolDescriptionAccessRoute
list_videosStudio recordings and imported live replays, newest first, paginated.readGET /videos
get_videoOne video with its playback URLs (ready videos only), duration, thumbnail, linked lesson or live.readGET /videos/:id
update_videoChanges the title or description of a video.writePATCH /videos/:id

Media library

ToolDescriptionAccessRoute
list_mediaImages, PDF, videos, embeds, stickers and sounds of the space used in scenes and the studio.readGET /media
list_media_foldersFolders of the media library.readGET /media/folders
get_mediaOne item of the media library.readGET /media/:id
add_mediaAdds an item to the media library from an https URL (image, pdf, video, embed of YouTube/Vimeo/Loom, sticker, audio), optionally in a folder (created when needed).writePOST /media
create_media_folderCreates a folder in the media library.writePOST /media/folders
update_mediaChanges the title or the folder (null for the root) of a media item.writePATCH /media/:id
delete_mediaDeletes a media item (and its file when hosted by LearnFloo).writeDELETE /media/:id

Webhooks

ToolDescriptionAccessRoute
list_webhooksOutgoing webhooks of the space with their events and delivery state.readGET /webhooks
get_webhookOne webhook.readGET /webhooks/:id
create_webhookSubscribes an https URL to events (["*"] for all). The signing secret is returned once.writePOST /webhooks
update_webhookChanges url, events, description or active state (active: true resets the failure count).writePATCH /webhooks/:id
delete_webhookDeletes a webhook.writeDELETE /webhooks/:id
test_webhookSends a ping event to a webhook.writePOST /webhooks/:id/test
list_webhook_deliveriesDelivery log of a webhook over 30 days (attempts, status, errors).readGET /webhooks/:id/deliveries

emails

ToolDescriptionAccessRoute
list_email_campaignsCampaigns of the space (drafts, scheduled, sent, failed) with their statistics: sent, delivered, opened, clicked, bounced, spam, unsubscribed. kind "digest" lists the automatic digests sent.readGET /emails/campaigns
get_email_campaignOne campaign with its content, audience and statistics.readGET /emails/campaigns/:id
preview_email_campaignThe campaign as a reader will receive it (subject, HTML and text, frame and unsubscribe footer included), rendered with the key creator’s first name.readGET /emails/campaigns/:id/preview
create_email_campaignCreates a campaign as a draft (or from a reusable text). Show the content to the user and get their agreement before sending: pass send "now" or an ISO date to send it in the same call, or call send_email_campaign later.writePOST /emails/campaigns
update_email_campaignChanges a draft (subject, preview text, body, audience). Scheduled or sent campaigns cannot be modified.writePATCH /emails/campaigns/:id
send_email_campaignSends a draft now, or schedules it (at: ISO date at least one minute ahead). Irreversible once sending: confirm with the user first. Costs 5 rate-limit tokens.writePOST /emails/campaigns/:id/send
cancel_email_campaignA scheduled campaign goes back to draft.writePOST /emails/campaigns/:id/cancel
test_email_campaignSends the campaign to the creator of the API key only, subject prefixed with [Test].writePOST /emails/campaigns/:id/test
delete_email_campaignDeletes a campaign (a scheduled one is unscheduled; a campaign being sent cannot be deleted).writeDELETE /emails/campaigns/:id
list_email_segmentsSaved audiences of the space (filters on roles, groups, level, seniority, activity, explicit members) with their last member count.readGET /emails/segments
get_email_segmentA segment with its member count computed now and a sample of names. Costs 3 tokens.readGET /emails/segments/:id
preview_email_segmentHow many members a set of filters reaches now, without saving anything. Use it before creating a segment or sending. Costs 3 tokens.readPOST /emails/segments/preview
create_email_segmentSaves an audience for campaigns and the digest.writePOST /emails/segments
update_email_segmentChanges the name, description or filters of a segment.writePATCH /emails/segments/:id
delete_email_segmentDeletes a segment (campaigns keep their history).writeDELETE /emails/segments/:id
list_email_templatesReusable campaign texts of the space (subject, preview text, body).readGET /emails/templates
create_email_templateSaves a campaign text to start future campaigns from (templateId of create_email_campaign).writePOST /emails/templates
update_email_templateChanges a reusable campaign text.writePATCH /emails/templates/:id
delete_email_templateDeletes a reusable campaign text.writeDELETE /emails/templates/:id
list_email_notificationsThe wording of the automatic e-mails the space sends (kind × language, e.g. live_invitation × fr), the saved text or the platform default, with the variables each kind accepts.readGET /emails/notifications
preview_email_notificationRenders an automatic e-mail with sample values and the space branding: the saved text, or the subject and body given.readGET /emails/notifications/:kind/:lang/preview
set_email_notificationSaves the space’s wording of an automatic e-mail for a kind and a language ({{variables}} listed by list_email_notifications).writePUT /emails/notifications/:kind/:lang
reset_email_notificationBack to the platform’s default wording for that kind and language.writeDELETE /emails/notifications/:kind/:lang
get_email_digestSettings of the automatic digest (cadence, day, hour, time zone, sections, audience) with the last and next send.readGET /emails/digest
preview_email_digestThe digest as it would go out now (subject, HTML, and whether it would be empty). Costs 3 tokens.readGET /emails/digest/preview
update_email_digestPartial update of the digest settings: enable it, change the cadence (daily, weekly, monthly), day, hour, time zone, sections, number of items, subject, intro, audience.writePATCH /emails/digest
send_email_digestSends the digest now: to the key creator only (test true), or to the whole audience. Costs 5 tokens.writePOST /emails/digest/send
get_email_senderWhat the space’s e-mails leave from: the paid option, the domain, its DNS records (DKIM, Return-Path) and their verification, deliverability counters, suspension.readGET /emails/sender
add_email_sender_domainRegisters the space’s sending domain (the paid option must be enabled by the owner in the app) and returns the DNS records to add.writePOST /emails/sender
update_email_senderChanges the display name, sending address (on the domain) or reply address.writePATCH /emails/sender
check_email_sender_domainAsks the mail provider to look the DKIM and Return-Path records up and records the result.writePOST /emails/sender/check
test_email_senderSends the key creator a test e-mail with the space’s current sender.writePOST /emails/sender/test
remove_email_sender_domainRemoves the sending domain: e-mails go back to leaving via LearnFloo.writeDELETE /emails/sender
get_email_preferencesWhether a member still receives the space’s bulk e-mails (campaigns, digest).readGET /users/:id/email-preferences
set_email_preferencesSubscribes or unsubscribes a member from the space’s bulk e-mails (for example to mirror an opt-out recorded in your platform).writePUT /users/:id/email-preferences

GET/public/spaces

Directory of public spaces, without a key or rate limit, CORS open (usable from a browser). Full URL: https://api.learnfloo.com/public/spaces. Response cached for 60 s.

{ "spaces": [ { "slug": "prince-ecom", "name": "Prince Ecom Academy", "description": "…", "logo": "https://…", "coverImage": "https://…",
    "category": "business", "mode": "learning", "accessType": "paid", "priceCents": 5900, "priceCurrency": "eur", "priceInterval": "month", "price": "59 €/mois",
    "memberCount": 163, "courseCount": 22, "rating": 5, "reviewCount": 15,
    "aboutUrl": "https://www.learnfloo.com/c/prince-ecom/", "appUrl": "https://app.learnfloo.com/c/prince-ecom" } ] }

GET/public/spaces/:slug

About page of a public space: the card above plus space.about (HTML), space.video (introduction video: embedUrl, playbackUrl, thumbnailUrl), owner (name, image), adminCount, the last 30 reviews (rating, text, createdAt, author) and courses, the space's open-access courses (slug, title, description, coverImage, lessonCount, url of the public page). 404 if the space doesn't exist or is private.

Open-access courses. In a public space, a published course open to every member can be marked “readable on the web” by its author (course page → Edit), or every course of the space at once (Settings → Access and About page): it is then rendered, with its lessons and videos, on www.learnfloo.com/en/c/<slug>/courses/<course>/ (and /c/…/cours/…, /es/c/…/cursos/…), read only, with a button to join the space; these pages are listed in www.learnfloo.com/c/sitemap.xml.

These two endpoints feed the pages www.learnfloo.com/en/communities/ and www.learnfloo.com/en/c/<slug>/. Joining a space always happens in the app (appUrl, ?join=1 parameter); for an LMS, use the /users/:id/entry SSO instead.

Errors

{ "error": "message" }
CodeCase
400Invalid body, missing field, session over (Session is over), unknown session, host not allowed, business rule not met
401Key missing, malformed or revoked
403Action forbidden to the chosen author (insufficient role), space owner
404Unknown path, unknown resource or one belonging to another space, ticket belonging to another user
405Method not supported on this path
413, 415Attachment too large or of an unsupported type
429Rate limit reached: respect Retry-After

On the learner's side, an expired or already used entry URL shows an explicit LearnFloo page; a new click in your platform (new /live/entry or /users/:id/entry call) is enough.

Display (iframe, tab)

New tab (recommended, especially for Safari):

const { url } = await fetch('/my-backend/live-entry', { method: 'POST' }).then((r) => r.json())
window.open(url, '_blank')

Iframe:

<iframe src="URL RETURNED BY /live/entry"
  allow="camera; microphone; display-capture; autoplay; fullscreen" allowfullscreen
  style="width:100%;height:80vh;border:0"></iframe>
The allow attribute is essential: without it, the browser refuses mic, camera and screen sharing in an iframe from another domain. Safari and Firefox in strict mode may block storage in a third-party iframe; switch to a new tab in that case.

Custom domain

On request, the live room is served under a subdomain of yours (e.g. live.your-domain.com). Only one action on your side: a DNS CNAME record from the subdomain to learnfloo-v2.b-cdn.net, then let LearnFloo know. After that, the /live/entry URLs are on your domain, with no change in your code. Only the live pages are served there; the /users/:id/entry URLs and invitation links stay on app.learnfloo.com.

Node example

const API = 'https://api.learnfloo.com/api/v1'
const headers = { Authorization: `Bearer ${process.env.LEARNFLOO_API_KEY}`, 'Content-Type': 'application/json' }

async function call(method, path, body) {
  const res = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined })
  if (res.status === 429) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get('Retry-After') ?? 1) * 1000))
    return call(method, path, body)
  }
  const data = await res.json()
  if (!res.ok) throw new Error(data.error)
  return data
}

// On the “Join the live session” click
export const liveEntry = (sessionId, learner) =>
  call('POST', '/live/entry', { sessionId, user: { externalId: String(learner.id), name: learner.fullName }, role: 'viewer' }).then((d) => d.url)

// On the “Open the community” click
export const spaceEntry = (learner) =>
  call('POST', `/users/ext%3A${encodeURIComponent(learner.id)}/entry`, { name: learner.fullName, redirect: 'posts' }).then((d) => d.url)

// After the live session: replay and validated attendance
export async function liveReport(sessionId) {
  const [s, a] = await Promise.all([call('GET', `/live/sessions/${sessionId}`), call('GET', `/live/sessions/${sessionId}/attendance`)])
  return { replayUrl: s.session.replayUrl, validated: a.participants.filter((p) => p.externalId && p.watchPct >= 80) }
}

// Once: get notified when live sessions end and replays are ready
export const subscribe = () =>
  call('POST', '/webhooks', { url: 'https://your-backend.com/learnfloo', events: ['live.session.ended', 'live.replay.ready', 'lesson.completed'] })

Changelog

DateChange
2026-09-16Open-access courses (SEO): a course of a public space marked “readable on the web” is rendered on www.learnfloo.com/en/c/<slug>/courses/<course>/ with its lessons, in three languages, and listed in /c/sitemap.xml; GET /public/spaces/:slug returns these courses in courses.
2026-09-15E-mails: the whole e-mail layer is available through the API and MCP ("E-mails" family, 35 tools): campaigns (creation, preview, test, immediate or scheduled send, cancellation, statistics), segments and audience count, reusable texts, automatic e-mails per kind and language, automatic digest (settings, preview, send), sending domain (add, DNS, check, test, remove), a member's e-mail preferences; email.campaign.sent, email.campaign.failed, email.unsubscribed webhooks.
2026-09-15Waiting room and closing screen: in the app (“Prepare” page, “Waiting & closing” tab), countdown, welcome message, image or teaser video, pre-live quiz; offer with a tracked button (link carrying lf_live, lf_user, lf_ext like the chat CTAs, sign-ups reported through /conversions), shown to the audience during the live and on the closing screen, offer countdown; closing poll. API: phase (live, waiting, closing) on polls, at creation, update and read.
2026-09-15OpenAPI 3.1 generated from the MCP catalogue (GET /openapi.json, ?preset=gpt, ?tools=, ?groups=); confidential OAuth clients (secret + PKCE, wildcard redirect URLs); OAuth tokens accepted by the REST API, limited to the tools chosen at consent; “LearnFloo” GPT for ChatGPT (instructions and steps in the kit).
2026-09-15MCP: prompts (job recipes, prompts/list and prompts/get, request argument) and LearnFloo Agent Kit (Claude Code plugin, skills for Codex, Cursor and Gemini CLI). Server version 1.1.0.
2026-09-13Scenes: jingle field (plays its duration, then the interrupted scene resumes where it was).
2026-09-13Scenes: auto-advance waits for the end of the videos and sounds started with the scene (not looped); mediaSec field (file length in seconds) on video slots and in audios.
2026-09-13Scenes: several sounds per scene, audios field (list, 6 max); the older audio field is still accepted on write, scenes now return audios.
2026-09-13Scenes: camera look, shape, filter, bgMode, bgBlur, bgImage fields (background applied on the person's device); volume and muted also apply to cameras (sound on the air).
2026-09-13Scenes: framed field (margin around the slots, the background stays visible), on creation and update.
2026-09-13Video player: video.lead webhook (email left in a video); GET /groups/:id/stats returns videoPct per member (share of the group's lesson videos actually watched) and avgVideoPct, videoLessonCount in summary.
2026-09-13Live sessions: replay rule. replay (all, attendees, groups, level, none), replayGroups and replayMinLevel fields on POST / PATCH /live/sessions, returned as replay, replayGroupIds, replayMinLevel. PATCH also takes mode and works during and after the live session for the title, description, groups and replay.
2026-09-12Single pay-as-you-go rate aligned with the public price list (www.learnfloo.com): GET /space/usage also returns simulcastHours (simulcast hours × destinations) and subtitleHours (subtitled hours, counted once generation moves to the new engine).
2026-09-12MCP: OAuth 2.1 connection (/.well-known/oauth-authorization-server, /oauth/register, /oauth/authorize, /oauth/token, /oauth/revoke): the user authorises the assistant on a LearnFloo page, choosing the space, the access level and the tools; the authorisation is shown and revoked in the settings next to the keys. An alternative to the key in the URL, at the user's choice.
2026-09-12MCP server https://api.learnfloo.com/mcp for AI assistants (Claude, ChatGPT, Cursor…), with the same keys: one tool per API route. Keys with an access level (read only or full: a write with a read-only key returns 403) and a list of enabled MCP tools set per key in the space settings. Existing keys stay on full access, all tools.
2026-09-12Scenes: auto-advance. durationSec and nextSceneId fields on scenes, PUT /live/sessions/:id/scenes/auto { enabled, loop }, autoAdvance object in the production desk. Each automatic change sends live.scene.changed like a manual switch.
2026-09-11Scenes: viewers widget (viewer counter) and PUT /live/sessions/:id/viewers to add viewers to it; extraViewers in the session object.
2026-09-11Scenes: style of the poll and message slots (colours, background opacity, text size, rounding, border, shadow, font, items shown).
2026-09-11Scenes: video options autoplay, once, startSec, durationSec, volume; startSec and durationSec also on the scene sound (audio).
2026-09-11Scenes: transitions fade, black, slide-*, zoom, wipe, blur and duration transitionMs.
2026-09-11Scenes: audio field (scene sound: audio file from the media library or URL, autoplay, once, loop, volume), controlled with PUT …/media/:mediaId. Media library: kind audio.
2026-09-11Polls and quizzes: /live/sessions/:id/polls (prepare, edit, delete), …/open, …/close, PUT …/results, …/band; kind: "quiz" with correct. Results kept after the live session. Webhooks live.poll.opened and live.poll.closed. In the app: “Polls” tab of the live session and of “Prepare”, for the host, the staff and the moderator and assistant roles. On air: poll and message slots in scenes (prepared in advance), PUT …/polls/:pollId/stage and PUT …/chat/stage to show a poll or a chat message in the video.
2026-09-11Live sessions: moderator role (chat moderation, prepared messages, private messages) accepted by /invites and PUT …/participants/:userId/role. Messages deleted by a moderator and private messages don't appear in GET …/chat.
2026-09-11Scenes: locked field (padlock); a locked scene or template refuses edits and deletion until { "locked": false }.
2026-09-11Scenes and production via the API: a live session is run like a studio. /live/sessions/:id/scenes (list, create, edit, delete, order), putting on air (…/activate, PUT …/scenes/active), stickers, banners and slots editable one by one (…/overlays/:overlayId, …/slots/:slotId, and PATCH /live/sessions/:id/overlays/:overlayId for the same banner in every scene), temporary banners POST …/bands, control of PDFs and videos PUT …/media/:mediaId, templates /scene-templates, a participant's role PUT …/participants/:userId/role (including assistant, the production crew, also accepted by /invites). Media library: POST /media by URL, GET, PATCH, DELETE /media/:id, folders /media/folders. Webhook live.scene.changed; session gains activeSceneId and sceneCount.
2026-09-10Single offer: the Free / Starter / Growth / Scale / Event grid goes away. GET /space/usage now returns the six pay-as-you-go units (units), the allowances, usageCents, the account cap and liveAllowed; plan, planLabel, eventCredits and the quotas are no longer returned, nor is plan in GET /space. The rate limit depends on the owner's account (60 or 300 requests per minute).
2026-09-10Currencies: paid spaces carry priceCurrency (eur or usd) in GET /space, GET /public/spaces and GET /public/spaces/:slug; price is formatted in the currency. POST /live/sessions/:id/conversions accepts currency next to amountCents (returned on the conversion and in the live.conversion webhook).
2026-09-10Sign-ups during the live session: POST /live/sessions/:id/conversions to report a purchase made from the chat (announcement in the chat, banner on the scene, counter), lf_live, lf_user, lf_ext parameters added to button links, live.conversion webhook, conversionCount and ctaClickCount fields on the session, kind on chat messages.
2026-09-10Live chat: the host's messages can carry imageUrl and link (call-to-action button) in GET /live/sessions/:id/chat.
2026-09-10Live sessions: chat field (open, closed, off) at creation, update and read, to open the chat on arrival or disable it. Can be changed during the live session by the host (live.session.updated webhook).
2026-09-10Groups (classes, teams, cohorts… wording per space): /groups (list, create, edit, delete), /groups/:id/members, GET and PUT /users/:id/groups to sync classes from an LMS. Per group: /groups/:id/leaderboard, member follow-up /groups/:id/stats, shared documents /groups/:id/documents; /leaderboard?group=. Automatic groups: rule field (level reached, day streak, challenge joined or finished, badge, course finished). Audience: groups field when creating and editing courses, posts, events and live sessions (groupIds when reading), ?group= filter on their lists. GET /space gains groupCount and groupLabels.
2026-09-10Public or private, free or paid spaces: GET /space gains priceCents, priceInterval, category, aboutUrl, rating, reviewCount; accessType is now free or paid. New endpoints without a key: GET /public/spaces (directory) and GET /public/spaces/:slug (About page, reviews). Members of a paid space who cancel move to status: "expired" in /users.
2026-09-09Rate limit per key according to the plan (X-RateLimit-* headers, 429 + Retry-After). Signed outgoing webhooks (/webhooks, 13 events, retries). Live sessions: PATCH and DELETE /live/sessions/:id, /participants, /chat, /replay, invitation links /invites; session gains maxParticipants, replayViews, hlsStatus, hostId, createdAt. Support: scope=space (all tickets), replies and statuses on the team side (staff), generic /attachments. Space: richer GET /space, /space/usage. Members: /users (list, create, edit, remove), SSO /users/:id/entry, /users/:id/progress, XP (GET and POST /users/:id/xp), badges, /leaderboard, /challenges. Courses: /courses, modules, lessons, /courses/:id/progress, /lessons/:id/complete. Feed: /posts and comments. Calendar: /events. Studio videos: /videos. Media library: /media. Cursor pagination; 405 on unknown method. The /entry and /sessions paths are now documented under /live/… (unchanged).
2026-09-07Webinars: the audience watches an HLS stream (10 to 20 s latency) and no longer connects to the room; a viewer can ask to speak and joins the room when the host accepts. summary of /sessions/:id/attendance: spectatorHours and interactiveHours added.
2026-09-07Support: /support/tickets (GET, POST), /support/tickets/:id, …/messages, …/status, /support/attachments, to open and follow tickets on behalf of an external user (WordPress plugin). GET /space to test a key.
2026-09-07GET /sessions/:id/attendance: summary added and, per participant, source, leftAt, watchSec, watchPct, connections (replaces lastSeenAt). Replay available as soon as the live session ends, re-encoded afterwards. This page goes online.
2026-09-06Errors returned as clean JSON { "error" }. Custom domain per space for the live pages. API served on api.learnfloo.com.
2026-09-05First version: /entry, /sessions (POST, GET), /sessions/:id, /sessions/:id/attendance.

Questions: the LearnFloo team.