Showing a player their replies, and letting them answer
The contract for a game that wants a report to be a conversation rather than a letter posted into a hole: the player sees what was written back, and can answer it or close the thread from inside the game.
Filing the report in the first place is sending. This document is the half that comes afterwards, and it is a different job with a different rule attached.
Base address: https://dasmaffin.com.
Which games can use this
Every game that takes feedback, at its own address. These moved out of TGSC's module and into
the reports integration on 7 September 2026; the addresses did not change, because
/{module}/api/me/reports is /Tgsc/api/me/reports for TGSC, which is where it already was.
What a game needs is a way to answer "who is asking", and there are exactly two:
| Your game | Mode | Who is proven, and how |
|---|---|---|
| Has no server of its own | Public key | The client proves the player. TGSC does this with a Steam ticket |
| Has a server | Private key | The server holds the key and names the player; it is believed |
A game with neither answers 404 on every route here. That is deliberate and it is the whole
security model: see Who is asking at the end.
The rule: reading is not sending
Sending a report trusts whatever sender id is in the form. It can afford to: the worst somebody achieves by lying is filing a report under another player's name, and a report is a thing they wrote themselves.
Reading one back cannot afford it. A report carries a screenshot of whatever was on that player's screen and the log of their session. An endpoint that hands those to anybody who can type a player id is a leak with a query string. So every route here establishes who is asking before it answers, by one of the two means in the table above, and never from a player id in the request body alone.
That is also why every route is a POST, including the ones that only read. A ticket and a private key are both credentials, and credentials do not belong in a URL, where they land in the server's access log and in every proxy in between.
The four routes
POST /{module}/api/me/reports what I have sent
POST /{module}/api/me/reports/{id} one of them, with the conversation
POST /{module}/api/me/reports/{id}/comment answer it
POST /{module}/api/me/reports/{id}/close close it
Content-Type: application/json
X-Api-Key: <this game's key, when it has one>
{module} is Tgsc or TouchGrass, the same segment you post a report to.
Every one takes the same body, and only ever needs the fields that route uses:
{ "ticket": "5410...hex...", "body": "Still happens on build 41." }
| Field | Used by | Meaning |
|---|---|---|
ticket |
public-key games | What vouches for the player. For TGSC, a Steam auth ticket as hex |
player |
private-key games | Which player this is about. Read in this mode and no other |
body |
/comment |
What the player wrote. Anything empty is refused |
A public-key game never sends the player id: it is what the ticket resolves to, which is the entire point, because a value the game supplies is a claim and a value Steam returns is an answer. A private-key game does send it, and is believed, because the only thing holding that key is a server you run.
Sending player to a game that is not in private-key mode does nothing at all. It is not merely
discouraged - it is never read, because a field that is trusted sometimes is a field somebody
eventually trusts always.
What I have sent
POST /{module}/api/me/reports
{
"unread": 2,
"reports": [
{
"id": 412,
"kind": "Bug",
"summary": "Garden resets after closing the app",
"receivedAt": "2026-09-02T19:04:11.318204",
"open": true,
"unread": 1,
"messages": 3
}
]
}
Newest first. The top-level unread is the sum across every report, which is what a badge on a
menu button wants: ask once, not once per report.
One of them
POST /{module}/api/me/reports/{id}
{
"id": 412,
"kind": "Bug",
"summary": "Garden resets after closing the app",
"description": "Planted three plots, closed the app, came back and it was empty.",
"receivedAt": "2026-09-02T19:04:11.318204",
"editedAt": null,
"closedAt": null,
"open": true,
"messages": [
{ "fromStaff": false, "body": "Still happens on build 41.", "writtenAt": "2026-09-03T08:00:00.114900" },
{ "fromStaff": true, "body": "Which device is this on?", "writtenAt": "2026-09-03T09:12:00.902551" }
]
}
Every time on this API is UTC and says so with nothing. They are ISO 8601 with sub-second
precision and no Z and no offset - 2026-09-02T19:04:11.318204. Parse them as UTC
explicitly. A parser left to guess will call them local time, which on a phone in Warsaw is two
hours of drift in a timestamp the player reads back as when they sent it.
DateTime.Parse(t, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
messages is the whole conversation, oldest first, both sides. fromStaff is the only thing that
says who wrote which, so it is what decides the side of the screen a line is drawn on.
Opening a report is reading it. This route clears that report's unread count as a side effect, which is why it is the one to call when the player actually looks. Listing does not clear anything, and posting a comment does not either, or answering a thread would mark the reply nobody has read as read.
Text only. Nothing here hands back a file: no screenshot, no log, no save. The game shows the conversation and offers a button to the site for everything else, which is one less way for a leak to be a large one.
Answer it
POST /{module}/api/me/reports/{id}/comment with body. Answers { "ok": true }.
Close it
POST /{module}/api/me/reports/{id}/close. Answers { "ok": true }.
Either side can close a thread, and closing starts the 28 day clock after which the report and its files are deleted, sooner than the 120 days an open one gets. A closed report cannot be commented on by either side.
What comes back
| Code | Meaning |
|---|---|
200 |
Done |
401 |
The proof failed: a missing or wrong X-Api-Key, or a ticket that was expired, malformed or not for this game. Body says no_key or bad_key when it was the key |
404 |
No such report, or it is not this player's, or this game has no way to establish who is asking |
409 |
{"ok":false,"why":"closed"} - the thread is closed, so nothing can be added |
400 |
{"ok":false,"why":"empty"} - the comment had nothing in it |
A report belonging to somebody else answers 404, not 403. Whether a report exists is not
something to confirm to somebody who cannot see it, so "not yours" and "not there" are one answer.
Do not write a client that tells them apart, because it cannot.
404 also covers "this game cannot establish who is asking" - it is open with no prover configured,
or TGSC without a Steam publisher key. A client that reads 404 on the list route as "no reports"
will show an empty inbox on a site that simply could not answer, so treat it as an error on that
route rather than as an empty one.
The ticket
Steam's own call, in the game:
// Unity, Steamworks.NET
var buffer = new byte[1024];
SteamUser.GetAuthSessionTicket(buffer, buffer.Length, out uint written, ref identity);
string ticket = BitConverter.ToString(buffer, 0, (int)written).Replace("-", string.Empty);
The site hands that to Steam's ISteamUserAuth/AuthenticateUserTicket with the game's App ID and
uses the SteamID that comes back. A ticket is short-lived and single-purpose: get a fresh one for a
session rather than storing one, and let it be rejected rather than trying to predict when it has
expired.
Who is asking
Everything behind these routes is shared. The conversation, the ownership check, the unread count, closing, the retention clock: all of it takes the module as a parameter, exactly as sending a report does. The only thing that was ever game-specific is the answer to "who is asking", and there is no generic answer to that, because it depends on what your game has.
With a server (private key). The request reaching this site comes from a machine you run, which
holds a key no player has. It names the player in player and is believed. This is the strongest
option and the least work: your server already knows who its users are.
Without a server (public key). The only thing that can call this site is the player's own copy of the game, and anything that copy holds, the player holds. So a public key identifies the build, not the player - it is worth having for attribution, rate limiting and being able to turn one version off, and it is worth nothing as a secret. The player themselves still has to be proven, and TGSC does that with a Steam ticket: the game asks Steam for one, this site asks Steam whose it is, and neither side has to take the other's word.
With neither, there are no routes. A game that is open and identifies players by an id its own
client supplies gets 404 here, on purpose. Answering would hand anybody holding a player id that
player's screenshots and session logs, and player ids are not secret: they sit in the game's own
logs and on every report. Turning the feature on for such a game is not a setting, it is bringing
something that vouches.
Touch Grass is that case today. It has a server, so private-key mode is the short path: point the
server at these routes with the key from
/TouchGrass/Reports/Settings and it works without the game changing at all. Unity Authentication
would be the other way, if the phone is ever to call this site directly - it issues a signed token
for the same anonymous player id reports are already filed under, and that token can be verified
against Unity's published keys.