Feedback
Show a demo feedback form inside your game and send the player’s answers
with your logger token — no browser, and every answer is linked to the
player’s session. You build the form once in the dashboard (Feedback); the
same form is also a public web page at forgelogger.dev/f/<link token>, and
answers from the game and the web land in the same overview (the game’s are
marked in game).
Change the questions in the dashboard and the game picks them up on the next fetch — no new build.
Using Godot? The Godot plugin does all of this for you:
ForgeLogger.show_feedback("<link token>")renders the form in the game (mouse, keyboard, gamepad), caches it for offline play, queues answers given offline and follows the retry rules below. This page is for other engines and custom clients.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET | /v1/ingest/feedback/:linkToken | The form to render (questions, branding, privacy settings) with an ETag |
POST | /v1/ingest/feedback | Submit a player’s answers (idempotent per clientRequestId) |
linkToken is the token of a feedback link — dashboard → Feedback → form →
Links, the 16 characters after /f/. Create a separate link for the game (e.g.
“In game”) so the overview can tell its answers apart from the web ones. The
link must belong to a form of the same project as your logger token;
anything else is a 404.
Get a form
GET /v1/ingest/feedback/:linkToken
If-None-Match: "<ETag of your cached copy>" (optional)
Response
| Field | Type | Description |
|---|---|---|
linkToken | string | The link token |
availability | string | open — accepts answers. draft (not published yet), closed (closed by the developer), not_started / ended (outside the link’s active window) — don’t show the form |
schemaVersion | integer or null | Revision of the questions — send it back with the answers. null unless open |
schema | object or null | The questions (below). null unless open |
branding | object | gameName, imageUrl, wishlistUrl, wishlistLabel, thankYouMessage — all optional |
privacyPolicyUrl | string or null | Your privacy policy — link it next to the contact consent |
defaultLocale | string or null | Preset language of the link, if any |
forChildren | boolean | Game for children: address parents; the form never asks for a contact or a raffle entry |
contactRetentionDays | integer | Days after the form closes until shared contacts are erased — part of the consent wording |
poweredBy | boolean | Show “Powered by Forge Logger” |
raffle | object or null | The raffle’s terms (below). null when the form runs no raffle — then the schema has no raffle block either. Older servers leave the field out |
The raffle (raffle)
| Field | Type | Description |
|---|---|---|
prize | string | What the winners get |
winnerCount | integer | How many winners are drawn |
entryDeadline | string (ISO 8601) | After this instant an entry is no longer accepted |
drawDate | string or null | When the developer plans to draw |
announcementMethod | string | direct_contact, social_post or other (announcementDetails says how) |
announcementDetails | string or null | Free text for other |
entryOpen | boolean | false once the deadline has passed — hide the raffle checkbox and say entries have closed |
Link the full rules at <form host>/f/<linkToken>/rules (e.g.
https://forgelogger.dev/f/<linkToken>/rules?lang=de).
Caching (ETag)
Every 200 carries an ETag and Cache-Control: private, no-cache. Keep the
last form you received and revalidate it before showing it: send its ETag as
If-None-Match and the API answers 304 Not Modified (no body) while
nothing changed. Offline, rate limited (429) or on a server error, show the
cached copy — its answers are still accepted later (see Revisions below).
The questions (schema)
schema.intro is optional text above the questions; schema.blocks lists the
enabled questions in display order. Answers are keyed by the block’s id.
type | Shows | Answer value |
|---|---|---|
rating | 1–5 stars (lowLabel, highLabel optional) | integer 1…5 |
text | multi-line text (maxLength) | string |
short_text | one-line text (maxLength) | string |
yes_no | yes / no | boolean |
single_choice | one of options[] (id, label) | the option id |
price_intent | ”would you buy it?” — pricePoints[] (id, amount, orMore) in currency, plus a “no” choice (noPurchaseLabel) when allowNoPurchase | the price point id, or "would_not_buy" |
contact | e-mail or Steam profile field (kinds) | sent as contact, not in answers |
raffle | ”enter the raffle” checkbox | sent as consents.raffle, not in answers |
Blocks with required: true must be answered; leave unanswered optional blocks
out of answers (never null). The contact and raffle blocks are left out
automatically for a game for children, on a plan without contacts (Free), and
whenever the service cannot store a contact safely; the raffle block also until
the developer has saved the raffle’s prize and deadline — don’t ask for one
when the block isn’t there.
Submit answers
POST /v1/ingest/feedback
Request body
| Field | Type | Required | Description |
|---|---|---|---|
linkToken | string | Yes | The link token |
schemaVersion | integer | Yes | The revision the player answered (from the GET) |
answers | object | Yes | Answers keyed by block id (table above) |
clientRequestId | string (UUID) | Yes | Idempotency key — generate once per answer, keep it for every retry |
sessionId | string (UUID) | No | Current session from /sessions — links the answer to telemetry |
buildId | string (UUID) | No | The build, when there is no session (e.g. the game started offline) |
playtimeSeconds | number | No | Seconds played in the session (the game’s clock) |
sceneName | string | No | Scene the player was in (kept up to 200 characters) |
checkpoint | string | No | Your own progress marker, e.g. boss_2_defeated (kept up to 200 characters). Without it, the name of the session’s last checkpoint event is used |
locale | string | No | Game language — BCP 47 (pt-BR) or as the engine reports it (pt_BR) |
contact | object | No | kind (email or steam_profile, as the block’s kinds allow) and value — only with the contact block and consents.shareContact |
consents | object | No | { "shareContact": bool, "raffle": bool } — separate, never pre-ticked |
consentTextVersion | string | No | Version of your consent wording (v1, v2, …) |
consentTexts | object | No | { "shareContact": "…", "raffle": "…" } — the exact wording shown next to each checkbox |
Response (201)
| Field | Type | Description |
|---|---|---|
responseId | string (UUID) | The stored answer |
status | string | Always "accepted" |
createdAt | string (ISO 8601) | When it was stored |
idempotent | boolean | true when this clientRequestId was already stored — nothing new was saved |
quotaExceeded | boolean | Over the workspace’s monthly response quota: stored, but hidden in the dashboard until an upgrade |
Errors and retries
| Status | code | Meaning | What the game should do |
|---|---|---|---|
400 | — | Malformed body (missing field, unknown field, not a UUID…) | Drop — a bug in the client |
400 | invalid_answers | The answers don’t fit the revision; errors[] has path, code, message | Drop (or let the player fix it if still on screen) |
401 / 403 | — | Missing / invalid or revoked token | Drop |
404 | — | Unknown link, or a link of another project’s form | Drop |
409 | form_unavailable | The form stopped accepting answers; availability says why | Drop, say thanks anyway |
409 | unknown_schema_version | No such revision; schemaVersion is the current one | Fetch the form again |
429 | — | Rate limited | Retry after Retry-After |
503 | contact_unavailable | A contact can’t be stored right now | Retry later, or send without the contact |
5xx | — | Server error | Retry with back-off |
Revisions
Editing the questions creates a new revision (schemaVersion). Older
revisions stay valid: an answer to the form the game cached before the edit
is accepted and shown against the questions the player actually saw. Only a
revision that never existed is a 409 unknown_schema_version.
Idempotency
The game may lose the response to a request that did store the answer, and
send it again from its queue. With the same clientRequestId the API stores
nothing new and returns the original answer with idempotent: true — also
when two retries race, and even if the form closed in between. Use a new
clientRequestId for every new answer.
Session link
With a sessionId of the same project the answer is linked to the session,
and a snapshot is stored with it: when the session started, playtime,
scene, checkpoint, build, platform, how many bug reports the session had and
how many crash events (whether it crashed). Events expire after your plan’s
retention, the snapshot stays with the answer — so the dashboard can split the
answers by playtime, build, platform, crash or link months later with the same
numbers. A session or build of another project is ignored (the answer is
stored without it), and so are playtimeSeconds, sceneName and
checkpoint without a session.
A session counts as crashed when it has at least one event of type crash
(see Events); an error event is not a crash.
Contacts and consents
A player’s contact is personal data: it is accepted only when the form has the
contact block and consents.shareContact is true; a raffle entry also
needs the raffle block and consents.raffle. The contact is stored
encrypted, never returned by the ingest API, and erased
contactRetentionDays after the form closes. Send consentTextVersion and
consentTexts with the exact wording you showed — that is what proves the
player agreed to it.
A raffle also has its own entry deadline (set by the developer in the
dashboard, separate from the form’s own open/closed status): once it has
passed (raffle.entryOpen: false on the form), consents.raffle is silently
dropped — the rest of the answer (including the contact, if shareContact was
also granted) is still stored. The response does not error, so do not treat
this as a reason to retry.
Answers are always checked against what the form asks now: a cached older revision that still has the contact or raffle block does not bring back a contact the developer has switched off since (or one the form can no longer take — no privacy policy, a game for children, a plan without contacts). Such a contact is refused like any unknown field of that revision.
Limits
- Monthly responses — Free 250, Indie 2,500, Studio 10,000 per workspace,
shared by the web and the game, separate from the bug report quota. Over the
limit answers are still stored (
quotaExceeded: true). - Rate limits (per token) — submissions Free 60/min, Indie 120/min, Studio 240/min; form fetches Free 120/min, Indie 240/min, Studio 480/min. They are separate from the bug report limit, so a feedback rush at the end of a festival day never blocks bug reports (and the other way round).
Compatibility
This API only ever adds optional fields. Ignore response fields you don’t
know. Don’t send fields that aren’t documented — the API rejects unknown fields
with 400.
Examples
Fetch the form
curl -i \
-H "Authorization: Bearer flg_your_token" \
https://ingest.forgelogger.dev/v1/ingest/feedback/k3J9x-Qa1B2c3D4e
{
"linkToken": "k3J9x-Qa1B2c3D4e",
"availability": "open",
"schemaVersion": 3,
"schema": {
"format": 1,
"template": "demo",
"blocks": [
{ "id": "rating", "type": "rating", "enabled": true, "required": true,
"label": "How did you like the demo?" },
{ "id": "text", "type": "text", "enabled": true, "required": false,
"label": "What should we improve?", "maxLength": 2000 }
]
},
"branding": { "gameName": "Marble Mill" },
"privacyPolicyUrl": null,
"defaultLocale": null,
"forChildren": false,
"contactRetentionDays": 90,
"poweredBy": true
}
Revalidate the cached copy
curl -i \
-H "Authorization: Bearer flg_your_token" \
-H 'If-None-Match: "-8Aq5hC1NshN7dzP6N6NOmt5P51gUUbD"' \
https://ingest.forgelogger.dev/v1/ingest/feedback/k3J9x-Qa1B2c3D4e
# HTTP/1.1 304 Not Modified
Submit answers
curl -X POST \
-H "Authorization: Bearer flg_your_token" \
-H "Content-Type: application/json" \
-d '{
"linkToken": "k3J9x-Qa1B2c3D4e",
"schemaVersion": 3,
"clientRequestId": "0f0b6f2e-3c1a-4d1e-9b7a-6a4c2f1e8d90",
"sessionId": "f47ac10b-...",
"answers": { "rating": 4, "text": "Loved the marble physics!" },
"playtimeSeconds": 1260,
"sceneName": "demo_end",
"checkpoint": "boss_2_defeated",
"locale": "en"
}' \
https://ingest.forgelogger.dev/v1/ingest/feedback
{
"responseId": "5b1f8f0e-6d2a-4c3b-9e8f-7a6b5c4d3e2f",
"status": "accepted",
"createdAt": "2026-10-20T18:00:00.000Z",
"idempotent": false,
"quotaExceeded": false
}