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

MethodEndpointDescription
GET/v1/ingest/feedback/:linkTokenThe form to render (questions, branding, privacy settings) with an ETag
POST/v1/ingest/feedbackSubmit 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

FieldTypeDescription
linkTokenstringThe link token
availabilitystringopen — 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
schemaVersioninteger or nullRevision of the questions — send it back with the answers. null unless open
schemaobject or nullThe questions (below). null unless open
brandingobjectgameName, imageUrl, wishlistUrl, wishlistLabel, thankYouMessage — all optional
privacyPolicyUrlstring or nullYour privacy policy — link it next to the contact consent
defaultLocalestring or nullPreset language of the link, if any
forChildrenbooleanGame for children: address parents; the form never asks for a contact or a raffle entry
contactRetentionDaysintegerDays after the form closes until shared contacts are erased — part of the consent wording
poweredBybooleanShow “Powered by Forge Logger”
raffleobject or nullThe 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)

FieldTypeDescription
prizestringWhat the winners get
winnerCountintegerHow many winners are drawn
entryDeadlinestring (ISO 8601)After this instant an entry is no longer accepted
drawDatestring or nullWhen the developer plans to draw
announcementMethodstringdirect_contact, social_post or other (announcementDetails says how)
announcementDetailsstring or nullFree text for other
entryOpenbooleanfalse 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.

typeShowsAnswer value
rating1–5 stars (lowLabel, highLabel optional)integer 1…5
textmulti-line text (maxLength)string
short_textone-line text (maxLength)string
yes_noyes / noboolean
single_choiceone 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 allowNoPurchasethe price point id, or "would_not_buy"
contacte-mail or Steam profile field (kinds)sent as contact, not in answers
raffle”enter the raffle” checkboxsent 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

FieldTypeRequiredDescription
linkTokenstringYesThe link token
schemaVersionintegerYesThe revision the player answered (from the GET)
answersobjectYesAnswers keyed by block id (table above)
clientRequestIdstring (UUID)YesIdempotency key — generate once per answer, keep it for every retry
sessionIdstring (UUID)NoCurrent session from /sessions — links the answer to telemetry
buildIdstring (UUID)NoThe build, when there is no session (e.g. the game started offline)
playtimeSecondsnumberNoSeconds played in the session (the game’s clock)
sceneNamestringNoScene the player was in (kept up to 200 characters)
checkpointstringNoYour 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
localestringNoGame language — BCP 47 (pt-BR) or as the engine reports it (pt_BR)
contactobjectNokind (email or steam_profile, as the block’s kinds allow) and value — only with the contact block and consents.shareContact
consentsobjectNo{ "shareContact": bool, "raffle": bool } — separate, never pre-ticked
consentTextVersionstringNoVersion of your consent wording (v1, v2, …)
consentTextsobjectNo{ "shareContact": "…", "raffle": "…" } — the exact wording shown next to each checkbox

Response (201)

FieldTypeDescription
responseIdstring (UUID)The stored answer
statusstringAlways "accepted"
createdAtstring (ISO 8601)When it was stored
idempotentbooleantrue when this clientRequestId was already stored — nothing new was saved
quotaExceededbooleanOver the workspace’s monthly response quota: stored, but hidden in the dashboard until an upgrade

Errors and retries

StatuscodeMeaningWhat the game should do
400—Malformed body (missing field, unknown field, not a UUID…)Drop — a bug in the client
400invalid_answersThe answers don’t fit the revision; errors[] has path, code, messageDrop (or let the player fix it if still on screen)
401 / 403—Missing / invalid or revoked tokenDrop
404—Unknown link, or a link of another project’s formDrop
409form_unavailableThe form stopped accepting answers; availability says whyDrop, say thanks anyway
409unknown_schema_versionNo such revision; schemaVersion is the current oneFetch the form again
429—Rate limitedRetry after Retry-After
503contact_unavailableA contact can’t be stored right nowRetry later, or send without the contact
5xx—Server errorRetry 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.

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
}