Get started
IntroductionQuickstartAuthentication
REST API
Geniallys
Search by nameGet by IDUser-tracking analyticsGlobal analytics
Spaces
List spacesList folders of a spaceList geniallys of a space
Folders
List geniallys of a folder
Webhooks
Events
slide.viewedinteractive_question.answeredlti_session.startedlti_session.ended
Examples
Collect form responses
Get started

Introduction

The Genially API lets your organization search and browse team content — spaces, folders and geniallys — read the detail of any genially it can access, read the tracking analytics of published geniallys, and receive webhook events as activity happens. It is designed for the development and data teams building on top of Genially.

Credentials are managed from your team settings (app.genially.com/teams/{teamId}/settings): OAuth applications, personal access tokens and team access tokens.

Base URL

All API requests are made to the following base URL over HTTPS. The API is versioned in the path.

https://developer-api.genially.com/v1

What you can do

  • List the spaces of your team, the folders inside a space, and the geniallys of a space or a folder — with cursor pagination on the spaces and geniallys listings.
  • Search the geniallys you can access by name.
  • Get the detail of a genially by ID: owner, space and folder, privacy state, collaborators and their access level, and links to the view and the editor.
  • Read the tracking analytics of a published genially: per-user sessions and per-question answers, or aggregated visits per day, per slide and per interaction.
  • Receive webhook events on your endpoint as they happen — slide views, interactive question answers and LTI sessions.

Pagination

Every listing returns a plain JSON array. The spaces and geniallys listings are always newest first (creation order) and paged with a cursor: ask for a page size with limit (1 to 50, 48 by default) and, to get the next page, send last with the id of the last item you received, omitting it on the first call; the cursor continues downwards from that item. The folders listing is not paginated: it filters with parentId and defaults to the same newest-first order unless you sort it with sortBy/sortDirection.

Walking a listing
# First page
curl "https://developer-api.genially.com/v1/spaces?limit=50" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Next page: last = id of the last item of the previous page
curl "https://developer-api.genially.com/v1/spaces?limit=50&last=64f1c2a0d2b4a90012f3e001" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
The response carries no cursor and no "has more" flag: you have reached the end when a page comes back with fewer items than limit. Note that an id sent as last that no longer exists — a deleted genially, for instance — also returns an empty array, so keep your own cursor from an item you have just read.

Next steps

Get started

Quickstart

Go from zero to your first authenticated API call in three steps.

1. Create a personal access token

In your team settings — app.genially.com/teams/{teamId}/settings/personal-access-tokens — create a personal access token. Copy the gy_... value right away: it is shown once. The token acts as you within that team — no OAuth flow, no client registration. See the Authentication guide for team tokens and OAuth applications.

2. Make your first call

Send the token as a bearer credential. Your team is derived from the token itself — no extra parameters needed. List the spaces of your team:

GET /v1/spaces
curl https://developer-api.genially.com/v1/spaces \
  -H "Authorization: Bearer gy_4f0f..."

# → 200 OK
[
  {
    "id": "64f1c2a0d2b4a90012f3e001",
    "name": "Marketing",
    "type": "Common",
    "owner": {
      "id": "64f1c2a0d2b4a90012f3e999",
      "name": "Jane Doe",
      "email": "jane@acme.com"
    }
  }
]

3. List the geniallys of a space

Take a space id from the response and list the geniallys inside, with the same header:

GET /v1/spaces/{spaceId}/geniallys
curl https://developer-api.genially.com/v1/spaces/64f1c2a0d2b4a90012f3e001/geniallys \
  -H "Authorization: Bearer gy_4f0f..."

# → 200 OK
[
  {
    "id": "64f1c2a0d2b4a90012f3e777",
    "name": "Onboarding · Module 1",
    "lastModified": "2026-06-21T14:02:11.000Z",
    "folderId": "64f1c2a0d2b4a90012f3e555",
    "folderName": "Onboarding"
  }
]
Every endpoint takes the same Authorization header. Looking for something by name? GET /v1/geniallys/search?term=onboarding returns the geniallys you can access that match the term. Once you have an id, GET /v1/geniallys/{geniallyId} returns the detail of that genially: owner, location, privacy, collaborators and links.
Get started

Authentication

Every request to the API carries a bearer credential in the Authorization header. The fastest way to get one is an access token — personal or team — created from your team settings. For integrations that obtain tokens programmatically, the API is also an OAuth 2.1 resource server backed by auth.genially.com.

Access tokens

Access tokens are opaque strings prefixed with gy_ — no OAuth flow involved. Create one in your team settings and start calling the API immediately:

Token
Acts as
Create it at
Personal
You, inside the team where it was created. Sees what you can access.
app.genially.com/teams/{teamId}
/settings/personal-access-tokens
Team
The whole team, with a role you pick at creation — your own role or lower. Not tied to any user.
app.genially.com/teams/{teamId}
/settings/team-access-tokens
GET /v1/spaces
curl https://developer-api.genially.com/v1/spaces \
  -H "Authorization: Bearer gy_4f0f..."

# → 200 OK
[
  { "id": "64f1c2a0d2b4a90012f3e001", "name": "Marketing", "type": "Common" }
]

The token value is shown once, at creation — only a 4-character preview is kept for identification. Pick an expiration of 30 to 365 days (90 by default) or no expiration, and rename, deactivate or delete a token at any time from the same page. Each scope holds up to 20 active tokens with unique names.

Some endpoints distinguish the two: a team token reaches every space of its team, while a personal token sees the spaces its user can access — and the global tracking analytics endpoint requires a user-bound credential that also carries a team.

OAuth: register a client

When you are building an application — a partner backend, a CLI, an app acting on behalf of Genially users — register an OAuth client from your team settings (app.genially.com/teams/{teamId}/settings/team-oauth-app), or directly against the auth server (POST /oauth/register) from an authenticated Genially session. The client type is set by its token_endpoint_auth_method:

Client type
Token auth
Grants
Confidential — partner backends
client_secret_basic
client_secret_post
All grants. Bound to a team at registration; its tokens carry that team.
Public — CLIs, SPAs, desktop apps
none
Authorization code + refresh only. PKCE instead of a secret; redirect URIs required. Its tokens carry no team, so the team-scoped v1 endpoints — today, all of them — reject them with 400.
The client_secret is returned once, at registration. Store it securely — only a 4-character preview is kept for identification.

Server to server: client credentials

Confidential clients exchange their credentials directly for a token. No user interaction; the token acts on the team the client is bound to.

POST /oauth/token
curl https://auth.genially.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=client_credentials \
  -d client_id=$CLIENT_ID \
  -d client_secret=$CLIENT_SECRET \
  -d resource=https://developer-api.genially.com

# → 200 OK
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

On behalf of a user: authorization code + PKCE

The user approves access in the browser. PKCE with S256 is mandatory for every client, and only response_type=code is supported. This flow also returns a refresh token.

Authorization code flow
# 1. Send the user to Genially to sign in and approve access
GET https://auth.genially.com/oauth/authorize
    ?response_type=code
    &client_id=$CLIENT_ID
    &redirect_uri=https://yourapp.example/callback
    &code_challenge=$PKCE_CHALLENGE
    &code_challenge_method=S256
    &state=$STATE
    &resource=https://developer-api.genially.com

# 2. Exchange the code delivered to your redirect_uri
POST https://auth.genially.com/oauth/token
    grant_type=authorization_code
    code=$CODE
    client_id=$CLIENT_ID
    redirect_uri=https://yourapp.example/callback
    code_verifier=$PKCE_VERIFIER

# → 200 OK
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "refresh_token_expires_in": 2592000
}

Before exchanging, verify that the state delivered to your callback matches the one you generated in step 1 — discard the code if it does not. The exchange always carries the client_id the code was issued to. A confidential client also authenticates with its client_secret (body or Basic header); a public client sends the client_id alone — the server rejects a public client that sends a secret.

Refreshing: post grant_type=refresh_token with the refresh_token and your client_id to the same token endpoint, authenticating exactly as in the code exchange. Refresh tokens are single-use and rotated on every refresh; reusing a spent one revokes the whole session. Access tokens last 1 hour, refresh tokens 30 days.

Call the API

Whichever credential you use — a personal or team access token, or an OAuth access token from a confidential client — send it as a bearer credential on every request.

cURL
curl https://developer-api.genially.com/v1/geniallys/search?term=onboarding \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Team plan

Every request is checked against the plan of the team the credential acts on — the team of an access token, or the team_id of an OAuth token. When that plan does not include the Developer API, for example after a downgrade, the request is rejected with 403 before reaching any endpoint:

GET /v1/spaces
curl https://developer-api.genially.com/v1/spaces \
  -H "Authorization: Bearer gy_4f0f..."

# → 403 Forbidden
{ "error": "Your team plan does not include the Developer API" }
Your tokens and OAuth clients are left untouched: once the team is back on a plan that includes the Developer API, the same credentials work again. This API caches each team's answer for up to 30 seconds, so a plan change takes that long to apply in either direction.

OAuth token claims

OAuth-issued tokens are signed JWTs (access tokens created in settings are opaque gy_ strings and carry no claims). There are no OAuth scopes — what a token can do is derived from its user, team and grant type.

Claim
Meaning
iss
Always the Genially auth server, https://auth.genially.com.
sub
The user id (authorization code) or the client id (client credentials).
aud
The resource the token was minted for — https://developer-api.genially.com for this API.
client_id
The OAuth client that obtained the token.
grant_type
authorization_code or client_credentials.
team_id
Confidential clients only: the team the client was registered for. Public-client tokens are user-scoped and carry no team — and since every v1 endpoint acts on the single team resolved from the token, a public-client token is rejected with 400 on all of them. Use a confidential client, or an access token, to call the API.
exp
One hour after issuance (expires_in: 3600).
OAuth tokens are audience-scoped: always request resource=https://developer-api.genially.com. A token minted for another Genially resource is rejected with 401, and an unknown resource fails at the token endpoint with invalid_target.

Disabling and deleting a client

The team that owns a client manages it from app.genially.com/teams/{teamId}/settings/team-oauth-app. Three actions change what the client can do, and they differ in what happens to the tokens it has already issued.

Action
New authorizations and token requests
Tokens already issued
Disabled
/oauth/authorize answers 400 and /oauth/token answers 401 — both invalid_client, described as OAuth client is disabled, on every grant including a refresh.
Rejected with a bare 401. Nothing is invalidated: re-enable the client and they are accepted again, with no user having to authorize a second time.
Deleted
The same two answers, described as OAuth client has been deleted. Permanent — the client id never comes back.
Invalidated at the moment of deletion. A new client and a fresh authorization are the only way back.
Secret rotated
The client keeps working — authenticate with the new secret from then on.
Invalidated: every token minted before the rotation stops being accepted.
New authorizations and token requests are blocked the moment a client is disabled, because the auth server checks its state on every call. Tokens already in flight take a little longer: this API caches client state for up to 30 seconds, so one can still be accepted for that long after the switch — and re-enabling takes just as long to take effect.
REST API

Geniallys

Every endpoint of this resource — parameters, response shape, examples and errors.

REST API · Geniallys

Search geniallys by name

Search geniallys by name. Works with both credential kinds: a team token searches every genially of the team, while a user-bound credential — a personal access token, or an authorization-code OAuth token from a confidential client — searches the geniallys that user can access.

GET/v1/geniallys/searchauth: Bearer token

Query parameters

term: string · optional
Text to match against the genially name. Omit it to match every name.
limit: integer · optional
How many geniallys to return in one page. · 1–50 · Defaults to 48
last: string · optional
Pagination cursor: the id of the last genially of the previous page. Omit it to get the first page.

Example request

cURL
curl https://developer-api.genially.com/v1/geniallys/search \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

A plain JSON array — each item carries:

id: string
Id of the genially. Use it as the cursor for the next page.
name: string
Title of the genially.

Example response

Matches for onboarding
[
  {
    "id": "64f1c2a0d2b4a90012f3e777",
    "name": "Onboarding · Module 1"
  },
  {
    "id": "64f1c2a0d2b4a90012f3e778",
    "name": "Onboarding · Module 2"
  }
]

Errors

Status
When
400
limit is out of the 1–50 range, or last is empty. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
REST API · Geniallys

Get a genially by ID

Returns the detail of a genially. Works with both a personal and a team token: the team is taken from the token, so no team parameter is sent. The response includes identity and dates, the owner (id, email, nickname), its space and folder, the privacy state (draft, anyone with the link — with password, search-engine visibility and reusability flags — or restricted), the collaborators it is shared with (people, team groups or the whole team, each with edit or view access), a thumbnail, a view link (only when published) and an editor link. Returns 401 without a token, 403 when the genially exists but falls outside the token scope, and 404 when it does not exist, is in the trash or is disabled (indistinguishable).

GET/v1/geniallys/{geniallyId}auth: Bearer token

Path parameters

geniallyId: string
Id of the genially, as returned by the listing endpoints.

Example request

cURL
curl https://developer-api.genially.com/v1/geniallys/$GENIALLY_ID \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

id: string
Id of the genially.
name: string
Title of the genially.
description: string · optional
Description, when it has one.
type: string
Kind of genially, such as Presentation or Infographic.
createdAt: string
When it was created, as an ISO 8601 timestamp.
lastModifiedAt: string
Last edit, as an ISO 8601 timestamp.
publishedAt: string · optional
When it was first published. Omitted while it is a draft.
▸owner: object
Who owns the genially.
id: string
User id of the owner.
email: string · optional
Email of the owner.
nickname: string · optional
Nickname of the owner.
teamId: string · optional
Team the genially belongs to.
spaceId: string · optional
Space the genially lives in.
▸folder: object · optional
Folder it lives in. Omitted when it sits at the root of its space.
id: string
Folder id.
name: string · optional
Folder name.
▸collaborators: array of object
Who the genially is shared with. Empty when it is not shared.
type: string
One of: user, group, team
access: string
What this collaborator can do. · One of: edit, view · Only when collaborators[].type is user
id: string · optional
User id. Omitted for someone invited by email who has no account yet. · Only when collaborators[].type is user
email: string · optional
Email the genially was shared with. · Only when collaborators[].type is user
name: string · optional
Nickname of the user. · Only when collaborators[].type is user
access: string
What this collaborator can do. · One of: edit, view · Only when collaborators[].type is group
id: string
Team group id. · Only when collaborators[].type is group
name: string · optional
Name of the team group. · Only when collaborators[].type is group
access: string
What this collaborator can do. · One of: edit, view · Only when collaborators[].type is team
id: string
Team id: the genially is shared with the whole team. · Only when collaborators[].type is team
▸privacy: object
Who can reach the genially.
visibility: string
draft: Not published yet: only collaborators reach it. · anyoneWithLink: Published and reachable by anyone holding the link. · restricted: Published, but only the listed collaborators reach it.
hasPassword: boolean
Whether opening it requires a password. · Only when privacy.visibility is anyoneWithLink
hiddenInSearchEngines: boolean
Whether search engines are asked not to index it. · Only when privacy.visibility is anyoneWithLink
reusable: boolean
Whether others may reuse it as a template. · Only when privacy.visibility is anyoneWithLink
thumbnailUrl: string · optional
Preview image, when it has one.
viewUrl: string · optional
Public link to the published genially. Omitted while it is a draft.
editorUrl: string
Link to open the genially in the editor.

Example responses

Draft
{
  "id": "64f1c2a0d2b4a90012f3e777",
  "name": "Onboarding · Module 1",
  "type": "Presentation",
  "createdAt": "2026-06-18T09:12:44.000Z",
  "lastModifiedAt": "2026-06-21T14:02:11.000Z",
  "owner": {
    "id": "64f1c2a0d2b4a90012f3e999",
    "email": "jane@acme.com",
    "nickname": "Jane Doe"
  },
  "teamId": "64f1c2a0d2b4a90012f3e123",
  "spaceId": "64f1c2a0d2b4a90012f3e001",
  "collaborators": [],
  "privacy": {
    "visibility": "draft"
  },
  "editorUrl": "https://app.genially.com/editor/64f1c2a0d2b4a90012f3e777"
}
Anyone with the link
{
  "id": "64f1c2a0d2b4a90012f3e777",
  "name": "Onboarding · Module 1",
  "type": "Presentation",
  "createdAt": "2026-06-18T09:12:44.000Z",
  "lastModifiedAt": "2026-06-21T14:02:11.000Z",
  "publishedAt": "2026-06-22T08:30:00.000Z",
  "owner": {
    "id": "64f1c2a0d2b4a90012f3e999",
    "email": "jane@acme.com",
    "nickname": "Jane Doe"
  },
  "teamId": "64f1c2a0d2b4a90012f3e123",
  "spaceId": "64f1c2a0d2b4a90012f3e001",
  "folder": {
    "id": "64f1c2a0d2b4a90012f3e555",
    "name": "Onboarding"
  },
  "collaborators": [],
  "privacy": {
    "visibility": "anyoneWithLink",
    "hasPassword": false,
    "hiddenInSearchEngines": true,
    "reusable": false
  },
  "thumbnailUrl": "https://view.genially.com/thumbnails/64f1c2a0d2b4a90012f3e777.png",
  "viewUrl": "https://view.genially.com/64f1c2a0d2b4a90012f3e777",
  "editorUrl": "https://app.genially.com/editor/64f1c2a0d2b4a90012f3e777"
}
Restricted
{
  "id": "64f1c2a0d2b4a90012f3e777",
  "name": "Onboarding · Module 1",
  "type": "Presentation",
  "createdAt": "2026-06-18T09:12:44.000Z",
  "lastModifiedAt": "2026-06-21T14:02:11.000Z",
  "publishedAt": "2026-06-22T08:30:00.000Z",
  "owner": {
    "id": "64f1c2a0d2b4a90012f3e999",
    "email": "jane@acme.com",
    "nickname": "Jane Doe"
  },
  "teamId": "64f1c2a0d2b4a90012f3e123",
  "spaceId": "64f1c2a0d2b4a90012f3e001",
  "folder": {
    "id": "64f1c2a0d2b4a90012f3e555",
    "name": "Onboarding"
  },
  "collaborators": [
    {
      "type": "user",
      "access": "edit",
      "id": "64f1c2a0d2b4a90012f3e888",
      "email": "sam@acme.com",
      "name": "Sam Rivers"
    },
    {
      "type": "user",
      "access": "view",
      "email": "newcomer@acme.com"
    },
    {
      "type": "group",
      "access": "view",
      "id": "64f1c2a0d2b4a90012f3e444",
      "name": "Marketing"
    }
  ],
  "privacy": {
    "visibility": "restricted"
  },
  "thumbnailUrl": "https://view.genially.com/thumbnails/64f1c2a0d2b4a90012f3e777.png",
  "viewUrl": "https://view.genially.com/64f1c2a0d2b4a90012f3e777",
  "editorUrl": "https://app.genially.com/editor/64f1c2a0d2b4a90012f3e777"
}

Errors

Status
When
400
geniallyId is not a valid id. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
403
The genially exists but falls outside the scope of your token.
404
No genially with that id, or it is in the trash or disabled. The three cases are indistinguishable on purpose.
REST API · Geniallys

Get user-tracking analytics for a genially

Returns per-user session metrics and per-question answers for a genially within the requested date range. Range is required and capped at 60 days. Every viewer with activity in the range is returned; narrow the dates to keep responses small on busy geniallys. A genially in your scope that was never published is not an error: it has no activity, so the response is an empty array.

GET/v1/geniallys/{geniallyId}/tracking/individualauth: Bearer token

Path parameters

geniallyId: string
Id of the genially to read analytics from. A never-published genially is not an error: it simply has no data.

Query parameters

startDate: string
First day of the range, included, as YYYY-MM-DD. Calendar days are evaluated in UTC.
endDate: string
Last day of the range, included, as YYYY-MM-DD. It cannot be earlier than startDate, and the range cannot exceed 60 days.

Example request

cURL
curl https://developer-api.genially.com/v1/geniallys/$GENIALLY_ID/tracking/individual \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -G -d "startDate=$START_DATE" \
  -d "endDate=$END_DATE"

Response

A plain JSON array — each item carries:

geniallyId: string
Id of the genially the session belongs to.
userAlias: string
Alias the viewer used, or the one assigned to them.
firstSessionCreatedAt: number
First session of this viewer, as a Unix timestamp in milliseconds.
numberOfSessions: number
Sessions this viewer opened in the range.
totalDuration: number
Time spent across those sessions, in milliseconds.
slidesVisited: number
How many slides this viewer visited.
▸answers: array of object
One entry per answered question. The object stays open: widget and fill-in-the-blanks answers add their own userAnswer detail.
interactiveQuestionId: string
Id of the answered question.
type: number
Numeric question type: 1 quiz, 2 open answer, 3 true/false, 4 image, 5 sort, 6 survey, 7 fill in the blanks, 8 short answer, 9 image survey, 10 branching scenario, 11 widget.
questionText: string
The question as the viewer saw it.
timestamp: number
When it was answered, as a Unix timestamp in ms.
answerIds: array of string · optional
Ids of the selected options — absent for free-text, fill-in-the-blanks and widget answers.
answerTexts: array of string · optional
Labels of the selected options — absent for open answer, short answer and widget answers.
answerText: string · optional
The free text typed — open answer and short answer only.
isCorrect: boolean · optional
Whether the answer was correct — absent for survey-like questions; an unscoreable answer is reported as false.
answerImageUrls: array of string · optional
Image of each selected option — image and image survey questions only.

Example response

Two viewers with activity
[
  {
    "geniallyId": "64f1c2a0d2b4a90012f3e777",
    "userAlias": "sam",
    "firstSessionCreatedAt": 1781784000000,
    "numberOfSessions": 2,
    "totalDuration": 412000,
    "slidesVisited": 7,
    "answers": [
      {
        "interactiveQuestionId": "ques-01",
        "type": 1,
        "questionText": "Which planet is closest to the sun?",
        "timestamp": 1781784120000,
        "answerIds": [
          "opt-2"
        ],
        "answerTexts": [
          "Mercury"
        ],
        "isCorrect": true
      }
    ]
  },
  {
    "geniallyId": "64f1c2a0d2b4a90012f3e777",
    "userAlias": "anonymous-4b21",
    "firstSessionCreatedAt": 1781870400000,
    "numberOfSessions": 1,
    "totalDuration": 96000,
    "slidesVisited": 3,
    "answers": []
  }
]

Errors

Status
When
400
Dates are not YYYY-MM-DD, endDate is earlier than startDate, or the range exceeds 60 days. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
403
No genially with that id inside the token scope — unknown ids and ids of another team are indistinguishable.
REST API · Geniallys

Get global analytics for a genially

Returns aggregated visit analytics for a genially within the requested date range: total visits, visits per day, visits per slide, and interactions per item. Range is required and capped at 90 days. Requires a user-bound credential that also carries a team: a personal access token, or an authorization-code OAuth token from a confidential client. A genially in your scope that was never published is not an error: it has no visits, so the counters are zero and the breakdowns empty.

GET/v1/geniallys/{geniallyId}/tracking/globalauth: Bearer token

Path parameters

geniallyId: string
Id of the genially to read analytics from. A never-published genially is not an error: it simply has no data.

Query parameters

startDate: string
First day of the range, included, as YYYY-MM-DD. Calendar days are evaluated in UTC.
endDate: string
Last day of the range, included, as YYYY-MM-DD. It cannot be earlier than startDate, and the range cannot exceed 90 days.

Example request

cURL
curl https://developer-api.genially.com/v1/geniallys/$GENIALLY_ID/tracking/global \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -G -d "startDate=$START_DATE" \
  -d "endDate=$END_DATE"

Response

geniallyId: string
Id of the genially the analytics belong to.
visits: number
Total visits in the requested range.
▸visitsPerDay: array of object
Visits broken down by day.
date: string
Day, as YYYY-MM-DD, evaluated in UTC.
visits: number
Visits on that day.
▸visitsPerSlide: array of object
Visits broken down by slide.
slideId: string
Id of the slide.
slideName: string
Name of the slide.
visits: number
Visits the slide received.
▸interactionsPerItem: array of object
Interactions broken down by item.
itemId: string
Id of the interactive item.
itemType: string
Kind of item the interaction happened on.
itemName: string
Name of the item.
interactions: number
Times the item was interacted with.

Example response

Analytics for a three-day range
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "visits": 128,
  "visitsPerDay": [
    {
      "date": "2026-06-19",
      "visits": 41
    },
    {
      "date": "2026-06-20",
      "visits": 52
    },
    {
      "date": "2026-06-21",
      "visits": 35
    }
  ],
  "visitsPerSlide": [
    {
      "slideId": "slide-01",
      "slideName": "Welcome",
      "visits": 128
    },
    {
      "slideId": "slide-02",
      "slideName": "How it works",
      "visits": 96
    }
  ],
  "interactionsPerItem": [
    {
      "itemId": "item-07",
      "itemType": "button",
      "itemName": "Start the tour",
      "interactions": 63
    }
  ]
}

Errors

Status
When
400
Dates are not YYYY-MM-DD, endDate is earlier than startDate, or the range exceeds 90 days. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
403
The token is not user-bound (this endpoint requires one), or no genially with that id exists inside the token scope — unknown ids and ids of another team are indistinguishable.
REST API

Spaces

Every endpoint of this resource — parameters, response shape, examples and errors.

REST API · Spaces

List the spaces of a team

Lists the spaces of a team with cursor pagination. The team is always the one carried by the token — it is never guessed from the user. Team tokens list every space of their team; a user-bound credential (a personal access token, or an authorization-code OAuth token from a confidential client) lists the spaces that user can access within that team.

GET/v1/spacesauth: Bearer token

Query parameters

limit: integer · optional
How many spaces to return in one page. · 1–50 · Defaults to 48
last: string · optional
Pagination cursor: the id of the last space of the previous page. Omit it to get the first page.

Example request

cURL
curl https://developer-api.genially.com/v1/spaces \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

A plain JSON array — each item carries:

id: string
Id of the space. Use it as the cursor for the next page.
name: string
Name of the space.
type: string
Kind of space, such as Common or Individual.
▸owner: object · optional
Owner of the space, when the space has one.
id: string · optional
User id of the owner.
name: string · optional
Name of the owner.
email: string · optional
Email of the owner.

Example response

One page of spaces
[
  {
    "id": "64f1c2a0d2b4a90012f3e001",
    "name": "Marketing",
    "type": "Common",
    "owner": {
      "id": "64f1c2a0d2b4a90012f3e999",
      "name": "Jane Doe",
      "email": "jane@acme.com"
    }
  },
  {
    "id": "64f1c2a0d2b4a90012f3e002",
    "name": "Design",
    "type": "Common"
  }
]

Errors

Status
When
400
limit is out of the 1–50 range, or last is empty. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
REST API · Spaces

List the folders of a team space

Lists the root folders of a team space of the team the token is bound to, or the children of a folder when parentId is given. Supports sortBy/sortDirection. Team tokens list any space of their team; a user-bound credential (a personal access token, or an authorization-code OAuth token from a confidential client) browses as that user.

GET/v1/spaces/{spaceId}/foldersauth: Bearer token

Path parameters

spaceId: string
Id of the team space whose folders you want to list.

Query parameters

parentId: string · optional
Id of a folder: returns its subfolders instead of the root folders of the space.
sortBy: string · optional
Field to sort by. · One of: UpdatedAt, Name
sortDirection: string · optional
Sort direction. · One of: ASC, DESC

Example request

cURL
curl https://developer-api.genially.com/v1/spaces/$SPACE_ID/folders \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

A plain JSON array — each item carries:

id: string
Id of the folder.
name: string
Name of the folder.
parentId: string · optional
Parent folder. Omitted for a root folder of the space.
spaceId: string · optional
Space the folder belongs to.
emoji: string · optional
Emoji chosen for the folder, if any.

Example response

Root folders of a space
[
  {
    "id": "64f1c2a0d2b4a90012f3e555",
    "name": "Onboarding",
    "spaceId": "64f1c2a0d2b4a90012f3e001",
    "emoji": "🚀"
  },
  {
    "id": "64f1c2a0d2b4a90012f3e556",
    "name": "Campaigns",
    "spaceId": "64f1c2a0d2b4a90012f3e001"
  }
]

Errors

Status
When
400
The spaceId is not a 24-character hex id, sortBy or sortDirection is not one of the accepted values, or parentId is empty. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
403
No space with that id inside the token scope — unknown ids and ids of another team are indistinguishable.
REST API · Spaces

List the geniallys of a team space

Lists the geniallys of a space with cursor pagination. Team tokens list any space of their team; a user-bound credential (a personal access token, or an authorization-code OAuth token from a confidential client) lists the spaces that user can access.

GET/v1/spaces/{spaceId}/geniallysauth: Bearer token

Path parameters

spaceId: string
Id of the team space whose geniallys you want to list.

Query parameters

limit: integer · optional
How many geniallys to return in one page. · 1–50 · Defaults to 48
last: string · optional
Pagination cursor: the id of the last genially of the previous page. Omit it to get the first page.

Example request

cURL
curl https://developer-api.genially.com/v1/spaces/$SPACE_ID/geniallys \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

A plain JSON array — each item carries:

id: string
Id of the genially. Use it as the cursor for the next page.
name: string
Title of the genially.
lastModified: string · optional
Last edit, as an ISO 8601 timestamp.
folderId: string · optional
Folder it lives in. Omitted when it sits at the root of the space.
folderName: string · optional
Name of that folder.

Example response

One page of geniallys
[
  {
    "id": "64f1c2a0d2b4a90012f3e777",
    "name": "Onboarding · Module 1",
    "lastModified": "2026-06-21T14:02:11.000Z"
  },
  {
    "id": "64f1c2a0d2b4a90012f3e778",
    "name": "Onboarding · Module 2",
    "lastModified": "2026-06-20T10:41:03.000Z",
    "folderId": "64f1c2a0d2b4a90012f3e555",
    "folderName": "Onboarding"
  }
]

Errors

Status
When
400
The spaceId is not a 24-character hex id, limit is out of the 1–50 range, or last is empty. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
403
No space with that id inside the token scope — unknown ids and ids of another team are indistinguishable.
REST API

Folders

Every endpoint of this resource — parameters, response shape, examples and errors.

REST API · Folders

List the geniallys of a folder

Lists the geniallys inside a folder of the team the token is bound to. Supports cursor pagination (limit/last). Team tokens list any folder of their team; a user-bound credential (a personal access token, or an authorization-code OAuth token from a confidential client) browses as that user.

GET/v1/folders/{folderId}/geniallysauth: Bearer token

Path parameters

folderId: string
Id of the folder whose geniallys you want to list.

Query parameters

limit: integer · optional
How many geniallys to return in one page. · 1–50 · Defaults to 48
last: string · optional
Pagination cursor: the id of the last genially of the previous page. Omit it to get the first page.

Example request

cURL
curl https://developer-api.genially.com/v1/folders/$FOLDER_ID/geniallys \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Response

A plain JSON array — each item carries:

id: string
Id of the genially. Use it as the cursor for the next page.
name: string
Title of the genially.
lastModified: string · optional
Last edit, as an ISO 8601 timestamp.
folderId: string · optional
Id of the folder it lives in.
folderName: string · optional
Name of that folder.

Example response

One page of geniallys
[
  {
    "id": "64f1c2a0d2b4a90012f3e778",
    "name": "Onboarding · Module 2",
    "lastModified": "2026-06-20T10:41:03.000Z",
    "folderId": "64f1c2a0d2b4a90012f3e555",
    "folderName": "Onboarding"
  }
]

Errors

Status
When
400
The folderId is not a 24-character hex id, limit is out of the 1–50 range, or last is empty. Also returned when the token carries no team (a public-client OAuth token).
401
The token is missing, malformed or expired.
403
No folder with that id inside the token scope — unknown ids and ids of another team are indistinguishable.
Webhooks

Events

Webhooks push events to your endpoint as HTTPS POST requests the moment they happen, so you can react without polling. Every delivery is signed with your webhook secret so you can verify it came from Genially.

Configure a webhook

In your team settings — app.genially.com/teams/{teamId}/settings/webhooks, managed by team owners and admins — create a webhook with a name, your endpoint URL and the events you want to subscribe to. The name is up to 100 characters and only labels the webhook in that screen, so you can tell several apart; you can rename it later, and it never travels in a delivery — neither in the body nor in the headers. The name and the subscribed events stay editable, but the endpoint URL does not: to point a webhook somewhere else, delete it and create a new one. You get a unique signing secret at creation; copy it then, it is not shown again. Each webhook keeps a browsable delivery history for 7 days.

Event types

Event
Triggered when
interactive_question.answered
A viewer answers an interactive question of a published genially.
slide.viewed
A viewer views a slide of a published genially; the payload carries the time spent on it.
lti_session.started
A learner opens a genially through an LTI launch from an LMS.
lti_session.ended
That LTI session ends; the payload carries the total duration.

The delivery request

Each event reaches your endpoint as a JSON POST. The body is an envelope: id (the delivery id), eventId, event, createdAt and the event-specific data.

Webhook delivery
POST https://yourapp.example/webhooks
content-type: application/json
user-agent: Genially-Webhooks/1
x-genially-event: interactive_question.answered
x-genially-event-id: c9c1d4a7f34b0e12
x-genially-delivery: 3d5f0b3a-4b6e-4f9a-9c2d-6f1f2a7b8c90
x-genially-signature: sha256=8b1c33e0...

{
  "id": "3d5f0b3a-4b6e-4f9a-9c2d-6f1f2a7b8c90",
  "eventId": "c9c1d4a7f34b0e12",
  "event": "interactive_question.answered",
  "createdAt": "2026-07-09T10:15:00.000Z",
  "data": {
    "geniallyId": "64f1c2a0d2b4a90012f3e777",
    "interactiveQuestionId": "ques-01",
    "interactiveQuestionAnswerId": "answ-01",
    "isCorrect": true,
    "questionText": "Which planet is closest to the sun?",
    "answer": {
      "answerIds": ["opt-2"],
      "answerTexts": ["Mercury"]
    },
    "userSessionId": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
    "userAlias": "sam"
  }
}
Header
Meaning
x-genially-event
The event type.
x-genially-event-id
Identifies the event. Stable across retries and shared by every webhook receiving it — use it as your idempotency key.
x-genially-delivery
Identifies each individual attempt; changes on every retry.
x-genially-signature
HMAC signature of the body: sha256=<hex>.
Fields without a value are omitted from data, never sent as null — parse defensively. This applies to the keys of an object, not to the items of an array: a null inside an array survives, so answerImages can arrive as [null, "https://…"] while an answerImages that has no value at all is absent from answer altogether.
Each event's field list is the whole contract: data is matched against the published shape for its event before the delivery is signed, and anything not declared there is removed rather than forwarded. A field you see in a delivery is a field documented on that event's page. When something upstream goes wrong you may receive fewer fields than documented, never different ones — so guard for a field being absent, but never for a shape you have not seen documented.

Each event carries its own data shape, and slide.viewed and interactive_question.answered each travel in two different shapes depending on where the view happened. The lms block is what tells them apart: it is present when the genially was opened through an LTI launch from an LMS — carrying the user, course and resource-link context the LTI platform supplied — and absent otherwise. Branch on it before reading anything else: the two shapes carry different fields, and they do not share a session identifier.

Every field of every shape, with an example of each, is documented per event:

interactive_question.answered  ·  slide.viewed  ·  lti_session.started  ·  lti_session.ended

Receive and verify deliveries

Expose an HTTPS POST endpoint for Genially to call. The signature is the HMAC-SHA256 of the raw request body — exactly as received, before any JSON parsing — keyed with your webhook secret and hex-encoded; if your framework parses the body first, make the route hand you the raw bytes (in Express, express.raw({ type: 'application/json' })). Compute it, compare with the x-genially-signature header, and acknowledge before doing any heavy work:

import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const secret = process.env.GENIALLY_WEBHOOK_SECRET;

createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhooks') {
    return res.writeHead(404).end();
  }

  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    // The signature covers the raw bytes — verify before parsing, in constant time
    const expected =
      'sha256=' + createHmac('sha256', secret).update(raw).digest('hex');
    const received = req.headers['x-genially-signature'] ?? '';
    if (
      received.length !== expected.length ||
      !timingSafeEqual(Buffer.from(expected), Buffer.from(received))
    ) {
      console.log('⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET');
      return res.writeHead(401).end();
    }

    // Reject malformed bodies before acknowledging — a 4xx makes Genially retry
    let body;
    try {
      body = JSON.parse(raw);
    } catch {
      return res.writeHead(400).end();
    }

    // Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
    res.writeHead(204).end();

    // Process after responding; dedupe on eventId (retries share it)
    console.log(body.event, body.eventId, body.data);
  });
}).listen(3000);
import hashlib
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ['GENIALLY_WEBHOOK_SECRET'].encode()


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != '/webhooks':
            self.send_response(404)
            self.end_headers()
            return
        raw = self.rfile.read(int(self.headers['content-length']))
        # The signature covers the raw bytes — verify before parsing
        expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected, self.headers.get('x-genially-signature', '')):
            print('⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET', flush=True)
            self.send_response(401)
            self.end_headers()
            return
        # Reject malformed bodies before acknowledging — a 4xx makes Genially retry
        try:
            body = json.loads(raw)
        except ValueError:
            self.send_response(400)
            self.end_headers()
            return
        # Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
        self.send_response(204)
        self.end_headers()
        # Process after responding; dedupe on eventId (retries share it)
        print(body['event'], body['eventId'], body['data'], flush=True)


HTTPServer(('', 3000), Receiver).serve_forever()
<?php
$secret = getenv('GENIALLY_WEBHOOK_SECRET');

if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $_SERVER['REQUEST_URI'] !== '/webhooks') {
    http_response_code(404);
    exit;
}

$raw = file_get_contents('php://input');
// The signature covers the raw bytes — verify before parsing
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_GENIALLY_SIGNATURE'] ?? '')) {
    error_log('⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET');
    http_response_code(401);
    exit;
}

// Reject malformed bodies before acknowledging — a 4xx makes Genially retry
$body = json_decode($raw, true);
if (!is_array($body)) {
    http_response_code(400);
    exit;
}

// Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
http_response_code(204);

// Process after responding; dedupe on eventId (retries share it)
error_log("{$body['event']} {$body['eventId']}");
import com.sun.net.httpserver.HttpServer;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

public class Receiver {
  public static void main(String[] args) throws Exception {
    var secret = System.getenv("GENIALLY_WEBHOOK_SECRET");
    var server = HttpServer.create(new InetSocketAddress(3000), 0);
    server.createContext("/webhooks", exchange -> {
      if (!"POST".equals(exchange.getRequestMethod())) {
        exchange.sendResponseHeaders(404, -1);
        exchange.close();
        return;
      }
      var raw = exchange.getRequestBody().readAllBytes();
      // The signature covers the raw bytes — verify before parsing
      String expected;
      try {
        var mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(raw));
      } catch (Exception error) {
        throw new IllegalStateException(error);
      }
      var signature = exchange.getRequestHeaders().getFirst("x-genially-signature");
      if (
        signature == null ||
        !MessageDigest.isEqual(
          expected.getBytes(StandardCharsets.UTF_8),
          signature.getBytes(StandardCharsets.UTF_8))
      ) {
        System.out.println("⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET");
        exchange.sendResponseHeaders(401, -1);
        exchange.close();
        return;
      }
      // Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
      exchange.sendResponseHeaders(204, -1);
      exchange.close();
      // Process after responding; dedupe on eventId (retries share it)
      System.out.println(new String(raw, StandardCharsets.UTF_8));
    });
    server.start();
  }
}
// Program.cs — inside a project created with: dotnet new web
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var secret = Environment.GetEnvironmentVariable("GENIALLY_WEBHOOK_SECRET")!;
var app = WebApplication.CreateBuilder(args).Build();

app.MapPost("/webhooks", async (HttpRequest request) =>
{
    using var memory = new MemoryStream();
    await request.Body.CopyToAsync(memory);
    var raw = memory.ToArray();

    // The signature covers the raw bytes — verify before parsing
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(raw)).ToLowerInvariant();
    var signature = request.Headers["x-genially-signature"].ToString();
    if (!CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)))
    {
        Console.WriteLine("⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET");
        return Results.Unauthorized();
    }

    // Reject malformed bodies before acknowledging — a 4xx makes Genially retry
    JsonElement body;
    try
    {
        body = JsonDocument.Parse(raw).RootElement;
    }
    catch (JsonException)
    {
        return Results.BadRequest();
    }
    // Process after acknowledging; dedupe on eventId (retries share it)
    Console.WriteLine(body.GetProperty("event") + " " + body.GetProperty("eventId"));
    // Any status below 400 within 5 seconds counts as delivered — in production, persist or enqueue before returning
    return Results.NoContent();
});

app.Run("http://*:3000");
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

var secret = os.Getenv("GENIALLY_WEBHOOK_SECRET")

type envelope struct {
	Event   string          `json:"event"`
	EventID string          `json:"eventId"`
	Data    json.RawMessage `json:"data"`
}

func main() {
	http.HandleFunc("POST /webhooks", func(w http.ResponseWriter, r *http.Request) {
		raw, _ := io.ReadAll(r.Body)
		// The signature covers the raw bytes — verify before parsing
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(raw)
		expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
		if !hmac.Equal([]byte(expected), []byte(r.Header.Get("x-genially-signature"))) {
			fmt.Println("⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET")
			w.WriteHeader(401)
			return
		}
		// Reject malformed bodies before acknowledging — a 4xx makes Genially retry
		var body envelope
		if err := json.Unmarshal(raw, &body); err != nil {
			w.WriteHeader(400)
			return
		}
		// Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
		w.WriteHeader(204)

		// Process after responding; dedupe on eventId (retries share it)
		fmt.Println(body.Event, body.EventID)
	})
	http.ListenAndServe(":3000", nil)
}

Each receiver is a single file on the platform’s standard library — the six of them ship together with their Dockerfiles in webhook-receivers-samples.zip. Prefer a container? The build and run commands are the same whichever language you pick:

Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY receiver.mjs .
EXPOSE 3000
CMD ["node", "receiver.mjs"]
FROM python:3.12-alpine
WORKDIR /app
COPY receiver.py .
EXPOSE 3000
CMD ["python", "receiver.py"]
FROM php:8.3-cli-alpine
WORKDIR /app
COPY receiver.php .
EXPOSE 3000
CMD ["php", "-S", "0.0.0.0:3000", "receiver.php"]
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY Receiver.java .
EXPOSE 3000
CMD ["java", "Receiver.java"]
# build context = the dotnet new web project folder
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
EXPOSE 3000
CMD ["dotnet", "run"]
FROM golang:1.24-alpine
WORKDIR /app
COPY receiver.go .
EXPOSE 3000
CMD ["go", "run", "receiver.go"]
Terminal
$ docker build -t webhook-receiver .
$ docker run --rm -p 3000:3000 -e GENIALLY_WEBHOOK_SECRET='<your signing secret>' webhook-receiver

Responses and retries

Respond within 5 seconds; any status below 400 counts as delivered. On failure — 4xx, 5xx, timeout or connection error — the delivery is retried up to 3 more times, roughly 5 minutes, 30 minutes and 3 hours after the previous attempt, resending the identical body and signature. After the final failed attempt the event is dropped for that webhook.

Webhooks · Event payload

slide.viewed

A viewer views a slide of a published genially; the payload carries the time spent on it.

From a published genially

The default shape, sent for any regular view of the published genially.

geniallyId: string
Id of the genially being viewed
slideId: string
Id of the slide the reported time belongs to
correlationId: string
Identifies one continuous visit to a slide — its deliveries share it, so sum their timeSpentMs for the visit total
slideIndex: number · optional
1-based position of the slide in the published genially · Omitted when there is no value
totalSlides: number · optional
Number of slides in the published genially · Omitted when there is no value
enteredAt: string
When the reported viewing segment started (ISO 8601, viewer clock)
timeSpentMs: number
Milliseconds viewed since the previous delivery of this visit — a delta, not a running total
userSessionId: string
Identifies the viewing session — the same value across every event of one viewer on this genially
userAlias: string · optional
Who viewed — an anonymous code, a self-reported name, or the Genially account name, depending on the audience setup · Omitted when there is no value
geniallyUserId: string · optional
Genially account id of the viewer — present when the genially identifies its audience with a Genially account · Omitted when there is no value
Example data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "slideId": "slide-03",
  "correlationId": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
  "slideIndex": 3,
  "totalSlides": 12,
  "enteredAt": "2026-07-09T10:15:00.000Z",
  "timeSpentMs": 4200,
  "userSessionId": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
  "userAlias": "sam"
}

From an LMS (LTI)

Sent instead when the genially is opened from an LMS through an LTI launch — the payload adds the lms block with the context the platform supplied.

geniallyId: string
Id of the genially the slide belongs to
geniallyTitle: string · optional
Title of the genially · Omitted when there is no value
slideId: string
Id of the slide the learner viewed
correlationId: string
Identifies one continuous visit to a slide
slideIndex: number · optional
1-based position of the slide among the genially's visible slides · Omitted when there is no value
totalSlides: number · optional
Number of visible slides in the genially · Omitted when there is no value
enteredAt: string
When the learner entered the slide (ISO 8601, browser clock)
exitedAt: string
When the slide view was recorded (ISO 8601, server clock)
timeSpentMs: number
Time spent on the slide in milliseconds, measured in the browser
▸lms: object
The LMS context the platform supplied at the launch — the same shape on every LTI event
▸user: object
The learner as the LMS identifies them
id: string
The learner's opaque user id on the LMS — the LTI sub claim
email: string · optional
The learner's email, when the LMS releases it · Omitted when there is no value
name: string · optional
The learner's full name, when the LMS releases it · Omitted when there is no value
givenName: string · optional
The learner's given name, when the LMS releases it · Omitted when there is no value
familyName: string · optional
The learner's family name, when the LMS releases it · Omitted when there is no value
▸context: object
The course, placement and session context of the launch
platformIssuer: string
The LMS platform issuer URL — the iss claim of the launch
clientId: string · optional
The OAuth client id of the tool registration the launch came from · Omitted when there is no value
deploymentId: string · optional
The LTI deployment_id claim of the launch · Omitted when there is no value
courseId: string · optional
Id of the LMS course (LTI context claim) · Omitted when there is no value
courseLabel: string · optional
Short label of the LMS course (LTI context claim) · Omitted when there is no value
courseTitle: string · optional
Title of the LMS course (LTI context claim) · Omitted when there is no value
resourceLinkId: string · optional
Id of the LMS placement the learner opened (LTI resource_link claim) · Omitted when there is no value
resourceLinkTitle: string · optional
Title of that LMS placement (LTI resource_link claim) · Omitted when there is no value
roles: array of string
The learner's LTI roles, as the IMS role URIs the platform sent
attempt: number · optional
How many times this learner has launched this genially from the same placement · Omitted when there is no value
sessionId: string
Identifies the LTI session — the same value across every event of one launch
sessionCreatedAt: string
When the LTI session started (ISO 8601) — the moment of the launch
▸endpoint: object · optional
The LTI AGS endpoints the platform granted at the launch
lineitem: string · optional
URL of the placement's grade line item (LTI AGS endpoint claim) · Omitted when there is no value
lineitems: string · optional
URL of the course's line-items collection (LTI AGS endpoint claim) · Omitted when there is no value
scope: array of string · optional
The AGS scopes the platform granted to the tool
Example data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "geniallyTitle": "Photosynthesis",
  "slideId": "slide-03",
  "correlationId": "k2h1c3d4e5f60a9b0c1d2",
  "slideIndex": 3,
  "totalSlides": 12,
  "enteredAt": "2026-07-09T10:15:00.000Z",
  "exitedAt": "2026-07-09T10:15:04.200Z",
  "timeSpentMs": 4200,
  "lms": {
    "user": {
      "id": "lms-user-1",
      "email": "learner@school.edu",
      "name": "Sam Learner",
      "givenName": "Sam",
      "familyName": "Learner"
    },
    "context": {
      "platformIssuer": "https://lms.example",
      "clientId": "client-1",
      "deploymentId": "dep-1",
      "courseId": "course-1",
      "courseLabel": "BIO-101",
      "courseTitle": "Biology 101",
      "resourceLinkId": "rl-1",
      "resourceLinkTitle": "Unit 3",
      "roles": [
        "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
      ],
      "attempt": 1,
      "sessionId": "lti-sess-1",
      "sessionCreatedAt": "2026-07-09T10:00:00.000Z"
    }
  }
}
Webhooks · Event payload

interactive_question.answered

A viewer answers an interactive question of a published genially.

From a published genially

The default shape, sent for any regular view of the published genially.

geniallyId: string
Id of the genially the question belongs to
interactiveQuestionId: string
Id of the answered question element (the widget element id for widget answers)
interactiveQuestionType: string · optional
One of: quiz, openAnswer, trueFalse, image, sort, survey, fillInTheBlanks, shortAnswer, imageSurvey, branchingScenario, widget · Omitted when there is no value
interactiveQuestionAnswerId: string
Id of this answer attempt — re-answering the same question produces a new one
isCorrect: boolean · optional
Whether the answer is correct — only quiz, trueFalse, image, sort and widget answers carry it · Omitted when there is no value
questionText: string · optional
The question's text, with its formatting markup stripped · Omitted when there is no value
▸answer: object
The viewer's answer — which fields it carries depends on the question type
answerIds: array of string · optional
Ids of the selected options, in submission order · Omitted when there is no value
answerTexts: array of string · optional
Display text of each selected option, aligned with answerIds, markup stripped · Omitted when there is no value
answerImages: array of string · optional
Image URL per selected option, aligned with answerIds — present only when the question has image options · Omitted when there is no value
answerText: string · optional
The free text the viewer typed (open answer and short answer) · Omitted when there is no value
blanks: object · optional
Fill-in-the-blanks map: blank id → selected option id · Omitted when there is no value
blankTexts: object · optional
The same map with the selected option text as value · Omitted when there is no value
▸widget: object · optional
The widget's own answer — only widget answers carry it
isCorrect: boolean · optional
Whether the widget reported the answer as correct
answerText: string · optional
The widget's own answer text
▸score: object · optional
value: number · optional
Score the widget assigned to the answer
max: number · optional
Maximum score the widget could assign
▸detail: object · optional
type: string · optional
string: a single text value · stringList: a list of text values
value: string · optional
Extra detail the widget attached to the answer · Only when answer.widget.detail.type is string
value: array of string · optional
Extra detail the widget attached to the answer · Only when answer.widget.detail.type is stringList
userSessionId: string
Identifies the viewing session — the same value across every answer of one respondent
userAlias: string · optional
Who answered — an anonymous code, a self-reported name, or the Genially account name, depending on the audience setup · Omitted when there is no value
geniallyUserId: string · optional
Genially account id of the viewer — present when the genially identifies its audience with a Genially account · Omitted when there is no value
Example data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "interactiveQuestionId": "ques-01",
  "interactiveQuestionType": "quiz",
  "interactiveQuestionAnswerId": "answ-01",
  "isCorrect": true,
  "questionText": "Which planet is closest to the sun?",
  "answer": {
    "answerIds": [
      "opt-2"
    ],
    "answerTexts": [
      "Mercury"
    ]
  },
  "userSessionId": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
  "userAlias": "sam"
}

From an LMS (LTI)

Sent instead when the genially is opened from an LMS through an LTI launch — the payload adds the lms block with the context the platform supplied.

geniallyId: string
Id of the genially the question belongs to
geniallyTitle: string · optional
Title of the genially · Omitted when there is no value
interactiveQuestionId: string
Id of the answered question
questionIndex: number · optional
1-based position of the question among the genially's scoring questions · Omitted when there is no value
totalQuestions: number · optional
Number of scoring questions in the genially · Omitted when there is no value
slideIndex: number · optional
1-based position of the slide the learner was on when answering · Omitted when there is no value
totalSlides: number · optional
Number of visible slides in the genially · Omitted when there is no value
type: number
Numeric question type: 1 quiz, 2 open answer, 3 true/false, 4 image, 5 sort, 6 survey, 7 fill in the blanks, 8 short answer, 9 image survey, 10 branching scenario, 11 widget. A question drawn from a random bank carries the drawn question type
isCorrect: boolean · optional
Whether the answer was correct — only graded question types carry it · Omitted when there is no value
questionText: string · optional
The question's text, with its formatting markup stripped · Omitted when there is no value
▸answer: object
The viewer's answer — which fields it carries depends on the question type
answerIds: array of string · optional
Ids of the selected options, in submission order · Omitted when there is no value
answerTexts: array of string · optional
Display text of each selected option, aligned with answerIds, markup stripped · Omitted when there is no value
answerImages: array of string · optional
Image URL per selected option, aligned with answerIds — present only when the question has image options · Omitted when there is no value
answerText: string · optional
The free text the viewer typed (open answer and short answer) · Omitted when there is no value
blanks: object · optional
Fill-in-the-blanks map: blank id → selected option id · Omitted when there is no value
blankTexts: object · optional
The same map with the selected option text as value · Omitted when there is no value
▸widget: object · optional
The widget's own answer — only widget answers carry it
isCorrect: boolean · optional
Whether the widget reported the answer as correct
answerText: string · optional
The widget's own answer text
▸score: object · optional
value: number · optional
Score the widget assigned to the answer
max: number · optional
Maximum score the widget could assign
▸detail: object · optional
type: string · optional
string: a single text value · stringList: a list of text values
value: string · optional
Extra detail the widget attached to the answer · Only when answer.widget.detail.type is string
value: array of string · optional
Extra detail the widget attached to the answer · Only when answer.widget.detail.type is stringList
▸lms: object
The LMS context the platform supplied at the launch — the same shape on every LTI event
▸user: object
The learner as the LMS identifies them
id: string
The learner's opaque user id on the LMS — the LTI sub claim
email: string · optional
The learner's email, when the LMS releases it · Omitted when there is no value
name: string · optional
The learner's full name, when the LMS releases it · Omitted when there is no value
givenName: string · optional
The learner's given name, when the LMS releases it · Omitted when there is no value
familyName: string · optional
The learner's family name, when the LMS releases it · Omitted when there is no value
▸context: object
The course, placement and session context of the launch
platformIssuer: string
The LMS platform issuer URL — the iss claim of the launch
clientId: string · optional
The OAuth client id of the tool registration the launch came from · Omitted when there is no value
deploymentId: string · optional
The LTI deployment_id claim of the launch · Omitted when there is no value
courseId: string · optional
Id of the LMS course (LTI context claim) · Omitted when there is no value
courseLabel: string · optional
Short label of the LMS course (LTI context claim) · Omitted when there is no value
courseTitle: string · optional
Title of the LMS course (LTI context claim) · Omitted when there is no value
resourceLinkId: string · optional
Id of the LMS placement the learner opened (LTI resource_link claim) · Omitted when there is no value
resourceLinkTitle: string · optional
Title of that LMS placement (LTI resource_link claim) · Omitted when there is no value
roles: array of string
The learner's LTI roles, as the IMS role URIs the platform sent
attempt: number · optional
How many times this learner has launched this genially from the same placement · Omitted when there is no value
sessionId: string
Identifies the LTI session — the same value across every event of one launch
sessionCreatedAt: string
When the LTI session started (ISO 8601) — the moment of the launch
▸endpoint: object · optional
The LTI AGS endpoints the platform granted at the launch
lineitem: string · optional
URL of the placement's grade line item (LTI AGS endpoint claim) · Omitted when there is no value
lineitems: string · optional
URL of the course's line-items collection (LTI AGS endpoint claim) · Omitted when there is no value
scope: array of string · optional
The AGS scopes the platform granted to the tool
Example data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "geniallyTitle": "Photosynthesis",
  "interactiveQuestionId": "ques-01",
  "questionIndex": 1,
  "totalQuestions": 4,
  "slideIndex": 3,
  "totalSlides": 12,
  "type": 1,
  "isCorrect": true,
  "questionText": "Which planet is closest to the sun?",
  "answer": {
    "answerIds": [
      "opt-2"
    ],
    "answerTexts": [
      "Mercury"
    ]
  },
  "lms": {
    "user": {
      "id": "lms-user-1",
      "email": "learner@school.edu",
      "name": "Sam Learner",
      "givenName": "Sam",
      "familyName": "Learner"
    },
    "context": {
      "platformIssuer": "https://lms.example",
      "clientId": "client-1",
      "deploymentId": "dep-1",
      "courseId": "course-1",
      "courseLabel": "BIO-101",
      "courseTitle": "Biology 101",
      "resourceLinkId": "rl-1",
      "resourceLinkTitle": "Unit 3",
      "roles": [
        "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
      ],
      "attempt": 1,
      "sessionId": "lti-sess-1",
      "sessionCreatedAt": "2026-07-09T10:00:00.000Z"
    }
  }
}
Webhooks · Event payload

lti_session.started

A learner opens a genially through an LTI launch from an LMS.

Only exists for geniallys opened from an LMS through an LTI launch.

geniallyId: string
Id of the genially the learner opened
startedAt: string
When the session started (ISO 8601) — the moment the LTI launch was processed
▸lms: object
The LMS context the platform supplied at the launch — the same shape on every LTI event
▸user: object
The learner as the LMS identifies them
id: string
The learner's opaque user id on the LMS — the LTI sub claim
email: string · optional
The learner's email, when the LMS releases it · Omitted when there is no value
name: string · optional
The learner's full name, when the LMS releases it · Omitted when there is no value
givenName: string · optional
The learner's given name, when the LMS releases it · Omitted when there is no value
familyName: string · optional
The learner's family name, when the LMS releases it · Omitted when there is no value
▸context: object
The course, placement and session context of the launch
platformIssuer: string
The LMS platform issuer URL — the iss claim of the launch
clientId: string · optional
The OAuth client id of the tool registration the launch came from · Omitted when there is no value
deploymentId: string · optional
The LTI deployment_id claim of the launch · Omitted when there is no value
courseId: string · optional
Id of the LMS course (LTI context claim) · Omitted when there is no value
courseLabel: string · optional
Short label of the LMS course (LTI context claim) · Omitted when there is no value
courseTitle: string · optional
Title of the LMS course (LTI context claim) · Omitted when there is no value
resourceLinkId: string · optional
Id of the LMS placement the learner opened (LTI resource_link claim) · Omitted when there is no value
resourceLinkTitle: string · optional
Title of that LMS placement (LTI resource_link claim) · Omitted when there is no value
roles: array of string
The learner's LTI roles, as the IMS role URIs the platform sent
attempt: number · optional
How many times this learner has launched this genially from the same placement · Omitted when there is no value
sessionId: string
Identifies the LTI session — the same value across every event of one launch
sessionCreatedAt: string
When the LTI session started (ISO 8601) — the moment of the launch
▸endpoint: object · optional
The LTI AGS endpoints the platform granted at the launch
lineitem: string · optional
URL of the placement's grade line item (LTI AGS endpoint claim) · Omitted when there is no value
lineitems: string · optional
URL of the course's line-items collection (LTI AGS endpoint claim) · Omitted when there is no value
scope: array of string · optional
The AGS scopes the platform granted to the tool
Example data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "startedAt": "2026-07-09T10:00:00.000Z",
  "lms": {
    "user": {
      "id": "lms-user-1",
      "email": "learner@school.edu",
      "name": "Sam Learner",
      "givenName": "Sam",
      "familyName": "Learner"
    },
    "context": {
      "platformIssuer": "https://lms.example",
      "clientId": "client-1",
      "deploymentId": "dep-1",
      "courseId": "course-1",
      "courseLabel": "BIO-101",
      "courseTitle": "Biology 101",
      "resourceLinkId": "rl-1",
      "resourceLinkTitle": "Unit 3",
      "roles": [
        "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
      ],
      "attempt": 1,
      "sessionId": "lti-sess-1",
      "sessionCreatedAt": "2026-07-09T10:00:00.000Z"
    }
  }
}
Webhooks · Event payload

lti_session.ended

That LTI session ends; the payload carries the total duration.

Only exists for geniallys opened from an LMS through an LTI launch.

geniallyId: string
Id of the genially the session belongs to
geniallyTitle: string · optional
Title of the genially · Omitted when there is no value
endedAt: string
When the session ended (ISO 8601) — the learner leaving the page
durationMs: number
Total session length in milliseconds, from the launch to leaving the page
reason: string
Why the session ended — navigation means the learner left the page
▸lms: object
The LMS context the platform supplied at the launch — the same shape on every LTI event
▸user: object
The learner as the LMS identifies them
id: string
The learner's opaque user id on the LMS — the LTI sub claim
email: string · optional
The learner's email, when the LMS releases it · Omitted when there is no value
name: string · optional
The learner's full name, when the LMS releases it · Omitted when there is no value
givenName: string · optional
The learner's given name, when the LMS releases it · Omitted when there is no value
familyName: string · optional
The learner's family name, when the LMS releases it · Omitted when there is no value
▸context: object
The course, placement and session context of the launch
platformIssuer: string
The LMS platform issuer URL — the iss claim of the launch
clientId: string · optional
The OAuth client id of the tool registration the launch came from · Omitted when there is no value
deploymentId: string · optional
The LTI deployment_id claim of the launch · Omitted when there is no value
courseId: string · optional
Id of the LMS course (LTI context claim) · Omitted when there is no value
courseLabel: string · optional
Short label of the LMS course (LTI context claim) · Omitted when there is no value
courseTitle: string · optional
Title of the LMS course (LTI context claim) · Omitted when there is no value
resourceLinkId: string · optional
Id of the LMS placement the learner opened (LTI resource_link claim) · Omitted when there is no value
resourceLinkTitle: string · optional
Title of that LMS placement (LTI resource_link claim) · Omitted when there is no value
roles: array of string
The learner's LTI roles, as the IMS role URIs the platform sent
attempt: number · optional
How many times this learner has launched this genially from the same placement · Omitted when there is no value
sessionId: string
Identifies the LTI session — the same value across every event of one launch
sessionCreatedAt: string
When the LTI session started (ISO 8601) — the moment of the launch
▸endpoint: object · optional
The LTI AGS endpoints the platform granted at the launch
lineitem: string · optional
URL of the placement's grade line item (LTI AGS endpoint claim) · Omitted when there is no value
lineitems: string · optional
URL of the course's line-items collection (LTI AGS endpoint claim) · Omitted when there is no value
scope: array of string · optional
The AGS scopes the platform granted to the tool
Example data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "geniallyTitle": "Photosynthesis",
  "endedAt": "2026-07-09T10:30:00.000Z",
  "durationMs": 1800000,
  "reason": "navigation",
  "lms": {
    "user": {
      "id": "lms-user-1",
      "email": "learner@school.edu",
      "name": "Sam Learner",
      "givenName": "Sam",
      "familyName": "Learner"
    },
    "context": {
      "platformIssuer": "https://lms.example",
      "clientId": "client-1",
      "deploymentId": "dep-1",
      "courseId": "course-1",
      "courseLabel": "BIO-101",
      "courseTitle": "Biology 101",
      "resourceLinkId": "rl-1",
      "resourceLinkTitle": "Unit 3",
      "roles": [
        "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
      ],
      "attempt": 1,
      "sessionId": "lti-sess-1",
      "sessionCreatedAt": "2026-07-09T10:00:00.000Z"
    }
  }
}
Webhooks

Examples

End-to-end walkthroughs that put webhooks to work: each one goes from a genially in the editor to a running backend that reacts to deliveries.

Example

Collect form responses

Interactive questions turn a genially into a native form: viewers answer inside the genially itself, and webhooks push each answer to your backend the moment it is sent — no embedded form tool, no polling. This tutorial builds a two-question feedback form and a tiny Node app that prints every response.

What you will build

  • A one-slide genially with a survey (pick one of three options) and an open response (free text).
  • A team webhook subscribed to interactive_question.answered.
  • A dependency-free Node collector that verifies each delivery and prints the answer to the console.

1. Build the form in Genially

In the editor, open the Interactive questions panel. The Information gathering group holds the form-style questions: add a Survey, then write the question and its options in the Content tab. This tutorial uses a workshop feedback form:

Question
Type
Which format do you prefer for the next session? — Interactive video / Gamified quiz / Guided presentation
Survey
What topic would you like us to cover?
Open response

Add an Open response question to the same slide for the free-text field. The selected survey options travel as an array (answer.answerTexts), so the same delivery shape also covers the Allow multiple selection toggle.

The Genially editor with the Interactive questions panel and the finished form: a survey with three options and an open response on the same slide

The first time, the Content tab offers to activate the activity (“Want to see the responses?”) — press Activate so answers are collected. Then publish your genially: answers flow from the published view.

The Content tab of a survey question, with the Activate button that enables collecting responses

2. Create the webhook

In your team settings — app.genially.com/teams/{teamId}/settings/webhooks, managed by team owners and admins — press Create webhook: give it a name, set the URL your app will listen on — a public HTTPS URL ending in /webhooks/genially; while you develop, an HTTPS tunnel forwarding to your local port works — and subscribe it to interactive_question.answered only. Copy the signing secret shown at creation — it is not shown again: the collector you build next reads it from GENIALLY_WEBHOOK_SECRET.

The Create webhook modal in team settings, subscribing the endpoint URL to the interactive_question.answered event

3. Run the collector

Pick your language — each collector is a single file on the platform’s standard library, nothing else to install, and all six ship together with their Dockerfiles in form-collectors-samples.zip. It verifies the x-genially-signature header against the raw body, acknowledges, and prints who answered what:

// collector.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const secret = process.env.GENIALLY_WEBHOOK_SECRET;

const printAnswer = data => {
  const name = data.userAlias ?? data.userSessionId;
  const who = data.geniallyUserId
    ? name + ' (' + data.geniallyUserId + ')'
    : name;
  const answer =
    data.answer.answerTexts?.join(', ') ?? data.answer.answerText ?? '(empty)';
  console.log('📩 ' + who + ' · ' + data.questionText);
  console.log('   → ' + answer);
};

createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhooks/genially') {
    return res.writeHead(404).end();
  }

  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    // The signature covers the raw bytes — verify before parsing, in constant time
    const expected =
      'sha256=' + createHmac('sha256', secret).update(raw).digest('hex');
    const received = req.headers['x-genially-signature'] ?? '';
    if (
      received.length !== expected.length ||
      !timingSafeEqual(Buffer.from(expected), Buffer.from(received))
    ) {
      console.log('⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET');
      return res.writeHead(401).end();
    }

    // Reject malformed bodies before acknowledging — a 4xx makes Genially retry
    let body;
    try {
      body = JSON.parse(raw);
    } catch {
      return res.writeHead(400).end();
    }

    // Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
    res.writeHead(204).end();

    if (body.event === 'interactive_question.answered') {
      printAnswer(body.data);
    }
  });
}).listen(7171, () => console.log('Waiting for form responses on :7171'));
# collector.py
import hashlib
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ['GENIALLY_WEBHOOK_SECRET'].encode()


def print_answer(data):
    name = data.get('userAlias') or data.get('userSessionId')
    user_id = data.get('geniallyUserId')
    who = f'{name} ({user_id})' if user_id else name
    answer = data.get('answer', {})
    text = ', '.join(answer.get('answerTexts') or []) or answer.get('answerText') or '(empty)'
    print(f'📩 {who} · {data.get("questionText")}', flush=True)
    print(f'   → {text}', flush=True)


class Collector(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != '/webhooks/genially':
            self.send_response(404)
            self.end_headers()
            return
        raw = self.rfile.read(int(self.headers['content-length']))
        # The signature covers the raw bytes — verify before parsing
        expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected, self.headers.get('x-genially-signature', '')):
            print('⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET', flush=True)
            self.send_response(401)
            self.end_headers()
            return
        # Reject malformed bodies before acknowledging — a 4xx makes Genially retry
        try:
            body = json.loads(raw)
        except ValueError:
            self.send_response(400)
            self.end_headers()
            return
        # Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
        self.send_response(204)
        self.end_headers()
        if body['event'] == 'interactive_question.answered':
            print_answer(body['data'])

    def log_message(self, format, *args):
        pass


print('Waiting for form responses on :7171', flush=True)
HTTPServer(('', 7171), Collector).serve_forever()
<?php
// collector.php
$secret = getenv('GENIALLY_WEBHOOK_SECRET');

if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $_SERVER['REQUEST_URI'] !== '/webhooks/genially') {
    http_response_code(404);
    exit;
}

$raw = file_get_contents('php://input');
// The signature covers the raw bytes — verify before parsing
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_GENIALLY_SIGNATURE'] ?? '')) {
    error_log('⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET');
    http_response_code(401);
    exit;
}

// Reject malformed bodies before acknowledging — a 4xx makes Genially retry
$body = json_decode($raw, true);
if (!is_array($body)) {
    http_response_code(400);
    exit;
}

// Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
http_response_code(204);

if ($body['event'] !== 'interactive_question.answered') {
    exit;
}

$data = $body['data'];
$name = $data['userAlias'] ?? $data['userSessionId'];
$who = isset($data['geniallyUserId']) ? "$name ({$data['geniallyUserId']})" : $name;
$texts = $data['answer']['answerTexts'] ?? null;
$text = $texts ? implode(', ', $texts) : ($data['answer']['answerText'] ?? '(empty)');
error_log("📩 $who · {$data['questionText']}");
error_log("   → $text");
// Collector.java
import com.sun.net.httpserver.HttpServer;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

public class Collector {
  public static void main(String[] args) throws Exception {
    var secret = System.getenv("GENIALLY_WEBHOOK_SECRET");
    var server = HttpServer.create(new InetSocketAddress(7171), 0);
    server.createContext("/webhooks/genially", exchange -> {
      if (!"POST".equals(exchange.getRequestMethod())) {
        exchange.sendResponseHeaders(404, -1);
        exchange.close();
        return;
      }
      var raw = exchange.getRequestBody().readAllBytes();
      // The signature covers the raw bytes — verify before parsing
      String expected;
      try {
        var mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(raw));
      } catch (Exception error) {
        throw new IllegalStateException(error);
      }
      var signature = exchange.getRequestHeaders().getFirst("x-genially-signature");
      if (
        signature == null ||
        !MessageDigest.isEqual(
          expected.getBytes(StandardCharsets.UTF_8),
          signature.getBytes(StandardCharsets.UTF_8))
      ) {
        System.out.println("⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET");
        exchange.sendResponseHeaders(401, -1);
        exchange.close();
        return;
      }
      // Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
      exchange.sendResponseHeaders(204, -1);
      exchange.close();
      // The JDK ships no JSON parser — swap the println for your JSON library of choice
      System.out.println("📩 " + new String(raw, StandardCharsets.UTF_8));
    });
    server.start();
    System.out.println("Waiting for form responses on :7171");
  }
}
// Program.cs — inside a project created with: dotnet new web
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var secret = Environment.GetEnvironmentVariable("GENIALLY_WEBHOOK_SECRET")!;
var app = WebApplication.CreateBuilder(args).Build();

app.MapPost("/webhooks/genially", async (HttpRequest request) =>
{
    using var memory = new MemoryStream();
    await request.Body.CopyToAsync(memory);
    var raw = memory.ToArray();

    // The signature covers the raw bytes — verify before parsing
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(raw)).ToLowerInvariant();
    var signature = request.Headers["x-genially-signature"].ToString();
    if (!CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)))
    {
        Console.WriteLine("⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET");
        return Results.Unauthorized();
    }

    // Reject malformed bodies before acknowledging — a 4xx makes Genially retry
    JsonElement body;
    try
    {
        body = JsonDocument.Parse(raw).RootElement;
    }
    catch (JsonException)
    {
        return Results.BadRequest();
    }
    if (body.GetProperty("event").GetString() == "interactive_question.answered")
    {
        PrintAnswer(body.GetProperty("data"));
    }
    // Any status below 400 within 5 seconds counts as delivered — in production, persist or enqueue before returning
    return Results.NoContent();
});

app.Run("http://*:7171");

static void PrintAnswer(JsonElement data)
{
    var name = data.TryGetProperty("userAlias", out var alias)
        ? alias.GetString()
        : data.GetProperty("userSessionId").GetString();
    var who = data.TryGetProperty("geniallyUserId", out var userId)
        ? name + " (" + userId.GetString() + ")"
        : name;
    var question = data.TryGetProperty("questionText", out var q) ? q.GetString() : null;
    var answer = data.GetProperty("answer");
    string? text = null;
    if (answer.TryGetProperty("answerTexts", out var texts))
    {
        text = string.Join(", ", texts.EnumerateArray().Select(t => t.GetString()));
    }
    else if (answer.TryGetProperty("answerText", out var free))
    {
        text = free.GetString();
    }
    Console.WriteLine("📩 " + who + " · " + question);
    Console.WriteLine("   → " + (text ?? "(empty)"));
}
// collector.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

var secret = os.Getenv("GENIALLY_WEBHOOK_SECRET")

type delivery struct {
	Event string          `json:"event"`
	Data  json.RawMessage `json:"data"`
}

type answerData struct {
	QuestionText   string `json:"questionText"`
	UserAlias      string `json:"userAlias"`
	UserSessionID  string `json:"userSessionId"`
	GeniallyUserID string `json:"geniallyUserId"`
	Answer         struct {
		AnswerTexts []string `json:"answerTexts"`
		AnswerText  string   `json:"answerText"`
	} `json:"answer"`
}

func printAnswer(rawData json.RawMessage) {
	var data answerData
	json.Unmarshal(rawData, &data)
	name := data.UserAlias
	if name == "" {
		name = data.UserSessionID
	}
	who := name
	if data.GeniallyUserID != "" {
		who = name + " (" + data.GeniallyUserID + ")"
	}
	text := strings.Join(data.Answer.AnswerTexts, ", ")
	if text == "" {
		text = data.Answer.AnswerText
	}
	if text == "" {
		text = "(empty)"
	}
	fmt.Println("📩 " + who + " · " + data.QuestionText)
	fmt.Println("   → " + text)
}

func main() {
	http.HandleFunc("POST /webhooks/genially", func(w http.ResponseWriter, r *http.Request) {
		raw, _ := io.ReadAll(r.Body)
		// The signature covers the raw bytes — verify before parsing
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(raw)
		expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
		if !hmac.Equal([]byte(expected), []byte(r.Header.Get("x-genially-signature"))) {
			fmt.Println("⚠️ Signature mismatch — check GENIALLY_WEBHOOK_SECRET")
			w.WriteHeader(401)
			return
		}
		// Reject malformed bodies before acknowledging — a 4xx makes Genially retry
		var body delivery
		if err := json.Unmarshal(raw, &body); err != nil {
			w.WriteHeader(400)
			return
		}
		// Acknowledge fast — in production, persist or enqueue the event before this line; any status below 400 within 5 seconds counts as delivered
		w.WriteHeader(204)

		if body.Event != "interactive_question.answered" {
			return
		}
		printAnswer(body.Data)
	})
	fmt.Println("Waiting for form responses on :7171")
	http.ListenAndServe(":7171", nil)
}

The collector listens at http://localhost:7171/webhooks/genially — the path the webhook URL from step 2 forwards to. A delivery that fails verification is rejected with a 401 and logged as a signature mismatch — if you see it, the GENIALLY_WEBHOOK_SECRET the collector was started with is not this webhook’s signing secret.

Prefer a container? Each collector ships as a minimal Dockerfile, and the build and run commands are the same whichever language you pick:

Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY collector.mjs .
EXPOSE 7171
CMD ["node", "collector.mjs"]
FROM python:3.12-alpine
WORKDIR /app
COPY collector.py .
EXPOSE 7171
CMD ["python", "collector.py"]
FROM php:8.3-cli-alpine
WORKDIR /app
COPY collector.php .
EXPOSE 7171
CMD ["php", "-S", "0.0.0.0:7171", "collector.php"]
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY Collector.java .
EXPOSE 7171
CMD ["java", "Collector.java"]
# build context = the dotnet new web project folder
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
EXPOSE 7171
CMD ["dotnet", "run"]
FROM golang:1.24-alpine
WORKDIR /app
COPY collector.go .
EXPOSE 7171
CMD ["go", "run", "collector.go"]
Terminal
$ docker build -t form-collector .
$ docker run --rm -p 7171:7171 -e GENIALLY_WEBHOOK_SECRET='<your signing secret>' form-collector

4. Answer the form

Open the published genially as a viewer, pick an option and send the free-text answer. Each answered question produces its own delivery, and the collector prints them as they arrive:

Terminal
$ GENIALLY_WEBHOOK_SECRET='<your signing secret>' node collector.mjs
Waiting for form responses on :7171

📩 maria · Which format do you prefer for the next session?
   → Gamified quiz
📩 maria · What topic would you like us to cover?
   → How to embed third-party content
$ GENIALLY_WEBHOOK_SECRET='<your signing secret>' python3 collector.py
Waiting for form responses on :7171

📩 maria · Which format do you prefer for the next session?
   → Gamified quiz
📩 maria · What topic would you like us to cover?
   → How to embed third-party content
$ GENIALLY_WEBHOOK_SECRET='<your signing secret>' php -S 0.0.0.0:7171 collector.php
[Tue Aug 25 10:15:00 2026] PHP 8.3.33 Development Server (http://0.0.0.0:7171) started

[Tue Aug 25 10:15:12 2026] 📩 maria · Which format do you prefer for the next session?
[Tue Aug 25 10:15:12 2026]    → Gamified quiz
[Tue Aug 25 10:15:14 2026] 📩 maria · What topic would you like us to cover?
[Tue Aug 25 10:15:14 2026]    → How to embed third-party content
$ GENIALLY_WEBHOOK_SECRET='<your signing secret>' java Collector.java
Waiting for form responses on :7171

📩 {"id":"3d5f0b3a-…","event":"interactive_question.answered","data":{"questionText":"Which format do you prefer for the next session?","answer":{"answerTexts":["Gamified quiz"]},"userAlias":"maria",…}}
📩 {"id":"7e2a1c9b-…","event":"interactive_question.answered","data":{"questionText":"What topic would you like us to cover?","answer":{"answerText":"How to embed third-party content"},"userAlias":"maria",…}}
$ dotnet new web
$ GENIALLY_WEBHOOK_SECRET='<your signing secret>' dotnet run
Building...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:7171

📩 maria · Which format do you prefer for the next session?
   → Gamified quiz
📩 maria · What topic would you like us to cover?
   → How to embed third-party content
$ GENIALLY_WEBHOOK_SECRET='<your signing secret>' go run collector.go
Waiting for form responses on :7171

📩 maria · Which format do you prefer for the next session?
   → Gamified quiz
📩 maria · What topic would you like us to cover?
   → How to embed third-party content

What arrives

The survey answer is delivered with the selected option ids and their labels already resolved:

Survey answer — delivery body
{
  "id": "3d5f0b3a-4b6e-4f9a-9c2d-6f1f2a7b8c90",
  "eventId": "c9c1d4a7f34b0e12",
  "event": "interactive_question.answered",
  "createdAt": "2026-08-25T10:15:00.000Z",
  "data": {
    "geniallyId": "64f1c2a0d2b4a90012f3e777",
    "interactiveQuestionId": "ques-01",
    "interactiveQuestionType": "survey",
    "interactiveQuestionAnswerId": "answ-01",
    "questionText": "Which format do you prefer for the next session?",
    "answer": {
      "answerIds": ["opt-2"],
      "answerTexts": ["Gamified quiz"]
    },
    "userSessionId": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
    "userAlias": "maria"
  }
}

The open response carries the free text in answer.answerText instead (only the data block shown):

Open response answer — data
{
  "geniallyId": "64f1c2a0d2b4a90012f3e777",
  "interactiveQuestionId": "ques-02",
  "interactiveQuestionType": "openAnswer",
  "interactiveQuestionAnswerId": "answ-02",
  "questionText": "What topic would you like us to cover?",
  "answer": {
    "answerText": "How to embed third-party content"
  },
  "userSessionId": "f0a1b2c3-d4e5-4f60-8a9b-0c1d2e3f4a5b",
  "userAlias": "maria"
}

Surveys deliver the selected labels resolved in answer.answerTexts — one entry per selection — while open responses carry the free text in answer.answerText. userSessionId is the same across every answer of one respondent, so it stitches both answers into one submission. The full field list — types, and when each field is omitted — lives on the interactive_question.answered reference page.

These survey and open-response deliveries never carry isCorrect — the field only appears on question types that report correctness. Like every field without a value, it is omitted from the body, never sent as null. Parse defensively.

Who is answering

What userAlias carries depends on how the genially identifies its audience:

Audience setup
What identifies the respondent
Nothing configured
userAlias is an auto-generated anonymous code: U_ plus five characters, like U_F7V2W.
Track who is accessing
When the genially loads, a modal asks viewers to identify themselves — whatever name or email they type arrives in userAlias. Self-reported: a viewer already logged into Genially skips the modal and is identified with their account name.
Restricted view
Only people with access can open it, logged in with their Genially account — nothing is self-reported: userAlias carries the account name and geniallyUserId the account id.

Track who is accessing is switched on in the genially’s Activity page, Individual tab:

The genially Activity page, Individual tab, with the Track who is accessing checkbox

A view is made Restricted from the editor’s Share menu, under “Who can see this creation?”:

The editor Share menu with Who can see this creation? set to Restricted

From console to storage

To turn the collector into a real backend, replace console.log with your database write and keep three habits: deduplicate on eventId (retries resend the identical body), group rows by userSessionId to reassemble each respondent’s submission, and filter on data.geniallyId — the webhook is team-wide, so answers from every genially of the team reach it. Signature, headers and the retry schedule are covered in Events.