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:

  1. 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.
  2. The events it wants. It is sent those and no others.
  3. 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.

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
  1. Take t, the unix time the delivery was signed, and every v1.
  2. Refuse it if t is 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.
  3. 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.
  4. 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.