Webhooks
A webhook sends your server an event the moment something happens in one of your environments: a player signs in for the first time, a purchase settles, a crash comes back after you fixed it. Your server no longer has to ask the platform API whether anything changed.
Each environment has its own endpoints, so test never sends to production's server, nor production to test's.
Adding an endpoint
In the console, open an environment and go to Live ops, Webhooks, then Add endpoint:
- The address your server listens on. It must be
https://, on the public internet. Private, loopback, link-local and cloud metadata addresses are refused, in IPv4 and IPv6, and so is a name that resolves to one. The address is checked again before every delivery, so a name pointed at a private address later is refused then too. - The events it wants. It is sent those and no others.
- The signing secret, shown once when the endpoint is made. Store it in your server's secrets: it is how your server knows a delivery came from GreenMind.
Then press Send test to send it a webhook.test event, and read how it went in the delivery log below.
Managing webhooks takes the Webhooks permission (manage_webhooks) on the environment or its project. An org's admins and the built-in admin role hold it; an org's own roles may be given it.
How many endpoints an environment may have depends on your org's plan:
| Plan | Endpoints per environment | Deliveries per environment per minute |
|---|---|---|
| Free | 2 | 60 |
| Studio | 10 | 600 |
| Partner | Unlimited | Unlimited |
Deliveries past the minute's limit are not lost: they wait for the next minute, and the wait does not count as a try.
What is sent
Each delivery is a POST of one event as JSON:
{
"id": "01927f50-0000-7000-8000-00000000000a",
"type": "player.created",
"version": 1,
"environment": "skyward",
"createdAt": "2026-10-05T12:00:00.000Z",
"data": {
"playerId": "01927f3a-5c1e-7d2b-9a40-8e7b1c2d3e4f",
"displayName": "Ashur",
"identities": [{ "provider": "server", "subject": "acct-42" }],
"createdAt": "2026-10-05T12:00:00.000Z"
}
}
| Field | Meaning |
|---|---|
id |
The event's id. The same on every retry, and for every endpoint the event goes to |
type |
One of the events below |
version |
The version of data's shape for this type (see Versions) |
environment |
The app key of the environment it happened in |
createdAt |
When it happened, in UTC |
data |
What happened, as the event's own table describes |
With these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
GreenMind-Webhooks/1, with a link to this page |
GreenMind-Event-Id |
The event's id |
GreenMind-Event-Type |
The event's type |
GreenMind-Delivery-Id |
This delivery of the event to this endpoint |
GreenMind-Signature |
t=<unix seconds>,v1=<hex>: see Verifying a delivery |
The same events are described for programs in the webhooks section of the OpenAPI document, each with its body as JSON Schema.
Events
| Type | Version | Sent when |
|---|---|---|
player.created |
1 | A player signed in to the environment for the first time |
player.deleted |
1 | A player was deleted, by your server, from the console, or with their GreenMind account |
purchase.settled |
1 | A purchase was paid for, and what it buys was granted |
purchase.refunded |
1 | The store refunded or charged back a purchase, in whole or in part, and its grants were taken back |
grant.created |
1 | A purchase or your staff gave a player something: time on a pass, a count, something held for good, or currency |
crash_group.created |
1 | A crash nobody had reported in this environment before |
crash_group.regressed |
1 | A crash marked fixed was reported again, from a build it had not been seen in |
config.promoted |
1 | Config was promoted into this environment from another of its project |
session.ended |
1 | Your server reported a session complete, or failed with an error |
support_request.created |
1 | A player sent a support request, or reported another player, from your game |
legal.accepted |
1 | A player accepted versions of your legal documents they had not accepted before |
webhook.test |
1 | You pressed Send test. Never sent otherwise, and needs no choosing |
An event is sent only once what it describes has been saved: if the change fails and is undone, nothing is sent. Every time is ISO 8601 in UTC.
player.created
| Field | Type | Meaning |
|---|---|---|
playerId |
string | The platform player's id |
displayName |
string or null | Their name, if they have one yet |
identities |
array of { provider, subject } |
How they signed in: eos and a product user id, server and your id |
createdAt |
string | When they were made |
player.deleted
| Field | Type | Meaning |
|---|---|---|
playerId |
string | The player's id |
deletedAt |
string | When they were deleted |
Delete what your own server holds of them.
purchase.settled
| Field | Type | Meaning |
|---|---|---|
orderId |
string | The order |
playerId |
string or null | Who bought it; null for a purchase by a GreenMind account |
provider |
string | The store: steam |
providerOrderId |
string | The store's own id for it |
currency |
string | ISO 4217 |
amountMinor |
integer | The price in the currency's minor unit: cents for USD |
items |
array of { sku, quantity } |
What was bought |
settledAt |
string | When |
purchase.refunded
orderId, playerId, provider and providerOrderId as for purchase.settled, and:
| Field | Type | Meaning |
|---|---|---|
kind |
string | refund, partial-refund or chargeback |
items |
array of { sku, quantity } |
The lines refunded |
refundedAt |
string | When the platform learned of it |
grant.created
| Field | Type | Meaning |
|---|---|---|
grantId |
string | The grant |
playerId |
string or null | Who was given it; null for a GreenMind account |
key |
string | The entitlement, or <app key>:<currency> for a currency |
kind |
string | time, count, permanent or currency |
seconds |
integer or null | For time: how long it added |
quantity |
integer or null | For count and currency: how many |
source |
string | order for a purchase, staff for one given from the console |
orderItemId |
string or null | The order line it pays for |
grantedAt |
string | When |
crash_group.created
| Field | Type | Meaning |
|---|---|---|
crashGroupId |
string | The crash, as the console's Crashes page has it |
title |
string | What crashed |
crashType |
string | The kind the report gave, Crash if it gave none |
version |
string or null | The build it was first seen in, where it said |
firstSeenAt |
string | When |
crash_group.regressed
crashGroupId, title and crashType as above, and:
| Field | Type | Meaning |
|---|---|---|
fixedInVersion |
string or null | The build you said the fix went into |
version |
string | The build it came back in |
seenAt |
string | When |
Sent once per build: a second report from the same new build is not a second regression, and a report from a build it was already seen in is expected (players who have not updated).
config.promoted
| Field | Type | Meaning |
|---|---|---|
promotionId |
string | The promotion, as the project's history has it |
fromEnvironment |
string | The app key it was promoted from |
changeSetId |
string | The change set that applied it |
added |
integer | Rows added |
changed |
integer | Rows changed |
removed |
integer | Rows removed |
tables |
array of strings | The kinds of config it touched |
promotedAt |
string | When |
session.ended
| Field | Type | Meaning |
|---|---|---|
sessionId |
string | The session |
status |
string | complete or error |
gameMode |
string | As the session was made with |
map |
string | As the session was made with |
conclusion |
string or null | What your server reported, for complete |
error |
string or null | What your server reported, for error |
playerIds |
array of strings | The players seated in it when it ended |
startedAt |
string or null | When it started playing |
endedAt |
string | When it ended |
support_request.created
| Field | Type | Meaning |
|---|---|---|
requestId |
string | The request, in the environment's support inbox |
kind |
string | support or report |
playerId |
string | Who sent it |
reportedPlayerId |
string or null | For a report: who it is about |
subject |
string | Its subject |
createdAt |
string | When |
Its message is read in the console, where it can be answered; it is not sent, since it is the player's own words and may say anything.
legal.accepted
| Field | Type | Meaning |
|---|---|---|
playerId |
string | Who accepted |
documents |
array of { document, version } |
What they accepted that they had not before |
acceptedAt |
string | When |
webhook.test
| Field | Type | Meaning |
|---|---|---|
message |
string | A line saying it is one |
Verifying a delivery
Anybody can send a request to your endpoint, so check every delivery before acting on it. GreenMind-Signature looks like this:
t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
- Take
t, the unix time the delivery was signed, and everyv1. - Refuse it if
tis more than 5 minutes from your server's clock. The time is part of what is signed, so an old delivery cannot be sent again with a new time. - Compute HMAC-SHA256, keyed with the endpoint's secret, of
t, a full stop, and the request body exactly as received, as bytes. Do not parse the JSON and write it out again first: any change of spacing or order changes the signature. - Accept it if your hex digest equals any
v1, compared in constant time.
While a rotated secret still signs, there are two v1 values; a receiver holding either secret accepts the delivery.
Node
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'
const SECRET = process.env.GREENMIND_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 5 * 60
const app = express()
// The raw body: the signature covers the bytes as sent, not parsed JSON.
app.post('/greenmind', express.raw({ type: 'application/json' }), (req, res) => {
const parts = (req.get('GreenMind-Signature') ?? '').split(',')
const timestamp = parts.find((part) => part.startsWith('t='))?.slice(2)
const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3))
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!timestamp || !(age <= TOLERANCE_SECONDS)) return res.status(400).end()
const expected = createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest()
const valid = signatures.some((signature) => {
const given = Buffer.from(signature, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
})
if (!valid) return res.status(400).end()
const event = JSON.parse(req.body.toString('utf8'))
// Answer first, then work: a slow answer is a failed delivery.
res.status(204).end()
handleOnce(event)
})
async function handleOnce(event) {
// A delivery can arrive more than once: skip an event id you have already handled.
if (await alreadyHandled(event.id)) return
// ...act on event.type and event.data...
}
C++ and Unreal
The same steps for a C++ service or an Unreal dedicated server. This is pseudocode around Unreal's types: HMAC-SHA256 comes from your crypto library, such as OpenSSL's HMAC with EVP_sha256(), which Unreal builds against as a third-party module.
// Pseudocode: HmacSha256, ToLowerHex and ConstantTimeEquals stand for your crypto library's.
bool VerifyGreenMindDelivery(const TArray<uint8>& Body, const FString& Header, const FString& Secret)
{
int64 Timestamp = 0;
TArray<FString> Signatures;
TArray<FString> Parts;
Header.ParseIntoArray(Parts, TEXT(","));
for (const FString& Part : Parts)
{
if (Part.StartsWith(TEXT("t=")))
{
Timestamp = FCString::Atoi64(*Part.Mid(2));
}
else if (Part.StartsWith(TEXT("v1=")))
{
Signatures.Add(Part.Mid(3));
}
}
const int64 Now = FDateTime::UtcNow().ToUnixTimestamp();
if (Timestamp == 0 || FMath::Abs(Now - Timestamp) > 5 * 60)
{
return false;
}
// The timestamp, a full stop, then the body's bytes exactly as received.
TArray<uint8> Signed;
const FTCHARToUTF8 Prefix(*FString::Printf(TEXT("%lld."), Timestamp));
Signed.Append(reinterpret_cast<const uint8*>(Prefix.Get()), Prefix.Length());
Signed.Append(Body);
const FString Expected = ToLowerHex(HmacSha256(Secret, Signed));
for (const FString& Given : Signatures)
{
if (ConstantTimeEquals(Given, Expected))
{
return true;
}
}
return false;
}
Keep the secret on the server, from its command line or environment, never in a build players get.
Answering
Answer with any 2xx status within 10 seconds. Anything else is a failed delivery: another status, no answer in time, a connection refused, or a redirect, which is never followed. The body of your answer is ignored, though the start of it is kept in the delivery log to help you debug.
Do the work after answering, from a queue of your own: a delivery that waits on your database is one that can time out.
Retries
A failed delivery is tried again, waiting a minute after the first failure and twice as long after each one since, up to four hours between tries, with some randomness so endpoints that failed together are not all retried together. A delivery is given up as failed about 24 hours after its event happened, a dozen or so tries in all.
So a delivery can arrive more than once (a try your server handled but did not answer in time is tried again), and events can arrive out of order (a retry of an older one after a newer one). Handle each event id once, and use createdAt and the event's own data, not the order deliveries arrive in, to decide what is current.
Failing endpoints
An endpoint whose every delivery has failed for three days, and at least ten tries in a row, is switched off: nothing more is sent to it, and what was waiting to be sent is skipped. Its org's admins are shown it on the org's page in the console, and the Webhooks page says why it was switched off. One delivery that succeeds clears its record.
Once your server is fixed, send it a test, then switch it back on. What happened while it was off is not sent; read anything you need from the platform API.
Rotating the secret
Rotate secret makes a new secret and shows it once. The old one goes on signing every delivery beside it for 24 hours, as a second v1, so you can deploy the new secret to every server without refusing anything in between. Rotate at once if a secret may have leaked.
The delivery log
Each environment's Webhooks page lists every delivery for 30 days: its event, its endpoint, its status (pending, delivered, failed, or skipped because its endpoint was switched off or deleted), what your server answered and how long it took, how many tries it has had, and when the next is due. Details shows each try and the body that was sent; Retry now sends a delivery again at once.
Versions
An event's version changes only when a change to its data would break a receiver written for the version before: a field removed, renamed or given another meaning. New fields may be added to data within a version, so read the fields you know and ignore the rest, as Versioning asks of the API's answers. New event types may be added at any time; an endpoint is only sent the types it chose.