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 pagecurl"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 pagecurl"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:
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.
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.
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 accessGET 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_uriPOST https://auth.genially.com/oauth/tokengrant_type=authorization_codecode=$CODEclient_id=$CLIENT_IDredirect_uri=https://yourapp.example/callbackcode_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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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 timeconst 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 retrylet 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()
ifnot 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 retrytry:
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 webusing 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 parsingusing 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 returningreturn 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 retryvar 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 .
EXPOSE3000CMD ["node", "receiver.mjs"]
FROM python:3.12-alpine
WORKDIR /app
COPY receiver.py .
EXPOSE3000CMD ["python", "receiver.py"]
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
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
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
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 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.
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.
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.mjsimport { 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 timeconst 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 retrylet 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.pyimport 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()
ifnot 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 retrytry:
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.javaimport 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 webusing 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 parsingusing 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 returningreturn 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.gopackage 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 retryvar 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 .
EXPOSE7171CMD ["node", "collector.mjs"]
FROM python:3.12-alpine
WORKDIR /app
COPY collector.py .
EXPOSE7171CMD ["python", "collector.py"]
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:
A view is made Restricted from the editor’s Share menu, under “Who can see this creation?”:
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.