View as Markdown

Webhooks in Satori

When something happens in Satori that affects your players (a live event starts or ends, a message goes out, players reach a journey step, or someone changes a feature flag), your backend might need to respond. Webhooks let you connect actions in Satori to your backend or your own toolchains. When a subscribed event occurs, Satori sends an HTTP POST request with a JSON body to an endpoint on your server.

For example, your backend can listen for LIVE_EVENT_STARTED to prepare event rewards, or for SCHEDULED_MESSAGE_SENT to deliver each message to additional third-party channels.

How webhooks work #

A webhook is an endpoint configuration in your Satori project. Each endpoint has:

  • URL: The address on your server that receives the requests.
  • Signing secret: A secret Satori generates automatically and uses to sign every request, so your server can confirm the request came from Satori. See Verify webhook signatures.
  • Custom header: An optional HTTP header name and value that Satori adds to every request.
  • Event types: The webhook events the endpoint subscribes to.

Webhook events #

Each endpoint subscribes to one or more of the following event types:

EventSent when
LIVE_EVENT_STARTEDA live event run starts.
LIVE_EVENT_ENDEDA live event run ends.
SCHEDULED_MESSAGE_SENTA scheduled message that uses the Webhook message integration is sent to players.
DIRECT_MESSAGE_SENTA direct message that uses the Webhook message integration is sent from the console or the Console API.
JOURNEY_STEP_REACHEDPlayers reach a Send Webhook step in a journey.
FEATURE_FLAG_CREATEDA feature flag is created.
FEATURE_FLAG_UPDATEDA feature flag is updated.
FEATURE_FLAG_DELETEDA feature flag is deleted.
FEATURE_FLAG_VARIANT_CREATEDA feature flag variant is created.
FEATURE_FLAG_VARIANT_UPDATEDA feature flag variant is updated.
FEATURE_FLAG_VARIANT_DELETEDA feature flag variant is deleted.

Each project can have up to 10 webhooks by default, including disabled ones. To change the limit, edit webhook_max_webhooks in the deployment configuration of your Satori deployment.

Audience changes don't trigger webhooks
There’s no webhook event for when an identity enters or leaves an audience i.e., meets certain conditions. Instead, to call your backend when players meet a condition, use a journey with a Send Webhook step. The journey’s entry criteria define the condition.

Delivery behavior #

Satori treats any 2xx response as a successful delivery. Any other response, or no response before the timeout, is a failure.

Timeouts and retries #

Satori waits up to 30 seconds for a response to each attempt. If an attempt fails with a retryable error, Satori retries up to 3 more times, waiting 2, 4, and then 8 seconds between attempts. If your endpoint returns 429 or 503 with a Retry-After header, Satori waits for the time the header specifies instead.

To change the timeout or the retry count, edit webhook_timeout or webhook_retries in the deployment configuration of your Satori deployment.

The response status code decides whether Satori retries:

ResponseRetried
2xxNo, the delivery succeeded.
429 Too Many RequestsYes
5xx, except 501 Not ImplementedYes
501 Not ImplementedNo
Other 4xxNo
3xx redirectNo. Satori doesn’t follow redirects and records the response as a failure.
Network error, such as a connection failure or timeoutYes, except for TLS certificate errors and an invalid URL scheme.

Return a 4xx status (but not 429) only when retrying won’t help, such as for a request that fails signature verification. Return 429 or 503 when your service is temporarily overloaded, and include Retry-After to tell Satori when to try again.

Satori stores a record of each delivery. Review the records in the console or through the Console API. See Audit webhook requests. Satori doesn’t have a dead-letter queue. Use the delivery records to find and reconcile failed requests.

Delivery guarantees #

Satori doesn’t guarantee that every webhook request arrives, or that requests arrive in order. Your endpoint could also receive the same request more than once. For example, if your endpoint processes a request but responds after the timeout, Satori treats the attempt as failed and sends the request again.

To handle duplicates, deduplicate on the top-level id, which stays the same across retries. To decide which state is newest, use the timestamps in the payload, such as update_time_sec, rather than arrival order.

Batched requests #

Scheduled messages and journey steps can reach many players at once, so Satori groups those players into one request instead of sending a request for each player. Your handler needs to process every player in the request:

  • SCHEDULED_MESSAGE_SENT: Up to 10,000 players per request, in the messages array. A message sent to 300,000 players arrives as at least 30 requests. See Scheduled message sent.
  • JOURNEY_STEP_REACHED: Up to 1,000 players per request, in the events array. See Journey step reached.

Every other event type sends one request for each event: one for each live event start or end, one for each feature flag or variant change, and one for each recipient of a direct message.

Create a webhook #

  1. In the Satori console, open the Webhooks tab and select Create Webhook.

  2. Enter the webhook settings:

    Create webhook modal showing URL, description, custom header, and listening for fields
    • URL (required): The endpoint on your server that receives webhook requests, for example https://www.your-domain.com/satoriwebhook. If the endpoint is a Nakama RPC, append unwrap=true to the query string so Nakama passes the raw request body to your function, for example https://your-nakama-server.com/v2/rpc/my_function?http_key=defaulthttpkey&unwrap=true.
    • Description: A note about the webhook’s purpose.
    • Custom Header: An optional HTTP header to include with every request. For example, add a game ID if several games share one endpoint.
      • Name: The header name, for example game_id.
      • Value: The header value, for example game4.
    • Listening for (required): The webhook events that trigger this webhook.
  3. Select Create. The new webhook appears in the Webhooks tab.

Satori generates the signing secret when you create the webhook.

Manage a webhook #

Select a webhook in the Webhooks tab to open its details page.

Webhook details page showing settings and the events log
Webhook Details

The details page shows the webhook’s settings and an Events section with its delivery history. Use this page to:

  • Send a test event.
  • Edit the webhook.
  • Reset the signing secret, from the more options (…) menu.
  • Delete the webhook, from the more options (…) menu.

Audit webhook requests #

The Events section lists the webhook’s deliveries, both successful and failed. Select an attempt to view its request headers, request body, response status, and response body.

Webhook events section showing delivery attempts with request headers, body, and response details

Satori stores successful and failed deliveries for every event type, with one exception. For DIRECT_MESSAGE_SENT, Satori always stores failed deliveries but stores successful deliveries only if the webhook opts in, through the persist_events field in the Console API WebhookCreate and WebhookUpdate requests. Test events are always stored. Satori keeps delivery records for 180 days.

To retrieve delivery records programmatically, for example to reconcile failures in your own tooling, list a webhook’s events through the Console API.

Test a webhook #

To confirm your endpoint receives and processes requests, select Send Test Event. In the dialog, choose an event type and review the request contents before you send it.

Send test event modal showing event type selection and request payload preview

Test events appear in the Events section with a TEST label.

Verify webhook signatures #

Satori signs every request with the webhook’s signing secret. The X-Satori-Signature header has the format t=<timestamp>,sig=<signature>:

  • t is the time Satori signed the request, as a UNIX timestamp in seconds.
  • sig is the hex-encoded HMAC-SHA256 of the string <timestamp>.<raw request body>, computed with the signing secret. The string is the timestamp, a period, and then the raw body.

Your server computes the same signature with its copy of the secret and rejects the request if the two don’t match. A check on the timestamp also rejects old requests that someone captured and resent.

Each request also includes the headers User-Agent: Satori and Content-Type: application/json, and the webhook’s custom header if you set one.

Verify the signature against the raw request body, not a re-serialized copy of the parsed JSON, because any change to the body changes the signature.

The following JavaScript example verifies the signature and rejects requests older than 5 minutes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import express from 'express';
import bodyParser from 'body-parser';

const SIGNING_SECRET = 'YOUR_SIGNING_SECRET';

const hexStringToUint8Array = hexString => {
  const bytes = new Uint8Array(Math.ceil(hexString.length / 2));
  for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hexString.substr(i * 2, 2), 16);
  return bytes;
};

const verifySignature = async (body, header, tolerance = 300) => {
  if (!header) return false;

  header = header.split(',').reduce((accum, x) => {
    const [k, v] = x.split('=');
    return { ...accum, [k]: v };
  }, {});

  const timestamp = Number(header.t);
  if (!Number.isFinite(timestamp) || !/^[0-9a-f]{64}$/i.test(header.sig ?? '')) return false;

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey("raw", encoder.encode(SIGNING_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  const verified = await crypto.subtle.verify("HMAC", key, hexStringToUint8Array(header.sig), encoder.encode(`${header.t}.${body}`));
  const elapsed = Math.abs(Math.floor(Date.now() / 1000) - timestamp);

  return verified && !(tolerance && elapsed > tolerance);
};

const app = express();

// Middleware to capture the raw request body
app.use(bodyParser.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

// ...process the webhook payload
async function processWebhookPayload(payload) { }

app.use('/webhook', async (req, res) => {
  const verified = await verifySignature(req.rawBody, req.headers['x-satori-signature'])

  if (!verified) return res.status(403).send({ message: 'Invalid signature' });

  await processWebhookPayload(req.body);

  res.status(200).send({ message: 'Valid signature' });
});

app.listen(8039, () => {
  console.log('Server running on http://localhost:8039');
});

If you suspect the signing secret has been exposed, select Reset Secret from the more options (…) menu to generate a new one, then update the secret on your server.

Webhook payloads #

Every webhook request body has the same top-level structure:

FieldDescription
idThe unique identifier of the delivery. It stays the same across retries.
eventThe Satori event that triggered the request, for example LIVE_EVENT_STARTED.
dataDetails of the event. The contents depend on the event type.

Live event started and ended #

LIVE_EVENT_STARTED and LIVE_EVENT_ENDED requests have the following body:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "id": "ec65eb00-4546-4004-8045-2e262651defd",
  "data": {
    "id": "57b0ef5d-fbfb-49f3-bb85-0bc20509bc22",
    "name": "test-event",
    "start_time_sec": 1728998280,
    "duration_sec": 360,
    "reset_cron": "* * * * *",
    "run_start": 1729854480,
    "run_end": 1729854510
  },
  "event": "LIVE_EVENT_STARTED"
}

The data object contains:

FieldDescription
idThe live event’s unique identifier.
nameThe live event’s name.
start_time_secThe live event’s start time, as a UNIX timestamp in seconds.
duration_secThe duration of each run, in seconds.
reset_cronThe live event’s reset cron expression.
run_startThe start time of this run, as a UNIX timestamp in seconds.
run_endThe end time of this run, as a UNIX timestamp in seconds.

Scheduled message sent #

SCHEDULED_MESSAGE_SENT requests have the following body. Each request contains up to 10,000 messages, one for each recipient.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
  "id": "9b8401bc-9c85-445c-94fd-06da9ead7e6e",
  "data": {
    "messages": [
      {
        "id": "39b1abac-59f7-4bcb-a815-22029142a0f1",
        "identity_id": "00000000-0000-0000-0000-000000000001",
        "title": "Attention!",
        "payload": "Jack, the world needs your help!",
        "metadata": {
          "campaign": "winter"
        },
        "image_url": "",
        "send_time_sec": 1736354260
      },
      {
        "id": "8a9f8033-8669-40da-acf4-ba7499ecdcd6",
        "identity_id": "00000000-0000-0000-0000-000000000003",
        "title": "Attention!",
        "payload": "Paul, the world needs your help!",
        "metadata": {
          "campaign": "winter"
        },
        "image_url": "",
        "send_time_sec": 1736354260
      }
    ],
    "message_schedule": {
      "id": "55e39305-bd09-4a89-877f-e7262fb2a506"
    },
    "live_event": {
      "id": "83e6e6d1-ad06-4ca3-8b0d-c1f01bbf935f",
      "value": "{}",
      "duration_sec": 360
    }
  },
  "event": "SCHEDULED_MESSAGE_SENT"
}

The data object contains:

FieldDescription
messagesThe messages in this request.
messages[].idThe unique identifier of this message instance.
messages[].identity_idThe identity ID of the player receiving the message.
messages[].titleThe rendered message title.
messages[].payloadThe rendered message body.
messages[].metadataThe rendered message metadata, as key-value pairs.
messages[].image_urlThe URL of the message’s image, if any.
messages[].send_time_secThe scheduled send time, as a UNIX timestamp in seconds.
message_schedule.idThe identifier of the message schedule.
live_eventThe live event linked to the message schedule, if any: its id, value, and duration_sec.

Satori renders title, payload, and metadata for each recipient before it sends the request. Property references, such as {{Properties.name}}, and other template variables are already replaced with the player’s values, as in the “Jack” and “Paul” messages in the example. The request doesn’t include the player’s raw properties as separate fields. If your backend needs a property value, reference it in the message template.

Direct message sent #

DIRECT_MESSAGE_SENT requests have the following body. Each request contains the message for one recipient. Satori omits empty fields.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "id": "1beb497a-3110-40c0-a035-b473eebe291b",
  "data": {
    "id": "b0ce0ab4-8d39-42cc-80ac-d07fcc78e4d1",
    "identity_id": "00000000-0000-0000-0000-000000000001",
    "title": "Attention!",
    "payload": "Jack, the world needs your help!",
    "metadata": {
      "key-1": "value-1"
    },
    "send_time_sec": 1736354260
  },
  "event": "DIRECT_MESSAGE_SENT"
}

The data object can contain:

FieldDescription
idThe unique identifier of this message instance.
identity_idThe identity ID of the player receiving the message.
titleThe message title.
payloadThe message body.
metadataThe message metadata, as key-value pairs.
image_urlThe URL of the message’s image, if any.
send_time_secThe send time, as a UNIX timestamp in seconds.
value_typeThe value type of the message content, if set.

Journey step reached #

When players reach a Send Webhook step in a journey, Satori sends a JOURNEY_STEP_REACHED request to each enabled webhook that subscribes to this event type. These requests group several players into one events array, with up to 1,000 entries in each request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
  "id": "1beb497a-3110-40c0-a035-b473eebe291b",
  "data": {
    "events": [
      {
        "identity_id": "00000000-0000-0000-0000-000000000001",
        "journey_id": "8a374e63-df9c-4bb4-9634-45b0a54103bf",
        "journey_name": "onboarding",
        "step_id": "e26fd7f8-e0b5-42b9-b447-131a1ad94b1c",
        "step_name": "Welcome webhook",
        "reached_time_sec": 1728998280,
        "payload": {
          "campaign": "winter"
        }
      },
      {
        "identity_id": "00000000-0000-0000-0000-000000000003",
        "journey_id": "8a374e63-df9c-4bb4-9634-45b0a54103bf",
        "journey_name": "onboarding",
        "step_id": "e26fd7f8-e0b5-42b9-b447-131a1ad94b1c",
        "step_name": "Welcome webhook",
        "reached_time_sec": 1728998280,
        "payload": {
          "campaign": "winter"
        }
      }
    ]
  },
  "event": "JOURNEY_STEP_REACHED"
}

Each entry in events contains:

FieldDescription
identity_idThe identity ID of the player who reached the step.
journey_idThe journey’s unique identifier.
journey_nameThe journey’s name.
step_idThe step’s unique identifier.
step_nameThe step’s name.
reached_time_secWhen the player reached the step, as a UNIX timestamp in seconds.
payloadThe JSON payload configured on the step.

Feature flag events #

FEATURE_FLAG_CREATED, FEATURE_FLAG_UPDATED, and FEATURE_FLAG_DELETED requests contain the feature flag. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "id": "63f70120-1ed4-49dd-9c4e-85effb186ade",
  "data": {
    "id": "c29ab570-b9ba-47f9-ba6a-1e48762f34fd",
    "name": "Min-Build-Number",
    "variants": [
      {
        "id": "4fa27911-d5dd-4117-9676-057690cdc9d8",
        "name": "Early-Access-Build-Number",
        "audiences": [
          {
            "id": "37dbd115-9223-46c0-979f-07dc2c0b5690",
            "name": "Early-Access"
          }
        ],
        "value": "71",
        "create_time_sec": 1748860768,
        "update_time_sec": 1748861025,
        "flag_id": "c29ab570-b9ba-47f9-ba6a-1e48762f34fd"
      }
    ],
    "value": "70",
    "update_time_sec": 1748861087,
    "create_time_sec": 1748860768,
    "schema_id": "00000000-0000-0000-0000-000000000003"
  },
  "event": "FEATURE_FLAG_UPDATED"
}

The data object can contain:

FieldDescription
idThe feature flag’s unique identifier.
nameThe feature flag’s name.
descriptionThe feature flag’s description.
valueThe feature flag’s value.
variantsThe flag’s variants. Each variant has the fields described in Feature flag variant events.
schema_idThe ID of the schema that validates the flag’s value. See Taxonomy > Schema Validators.
create_time_secWhen the flag was created, as a UNIX timestamp in seconds.
update_time_secWhen the flag was last updated, as a UNIX timestamp in seconds.

Satori sends FEATURE_FLAG_UPDATED for any change to the flag itself, including its name, description, category, or value. Changes to a variant send a FEATURE_FLAG_VARIANT_UPDATED request instead. The request contains the flag’s state after the change, not a list of what changed. To see what changed, store the previous payload and compare it with the new one.

Feature flag variant events #

FEATURE_FLAG_VARIANT_CREATED, FEATURE_FLAG_VARIANT_UPDATED, and FEATURE_FLAG_VARIANT_DELETED requests contain the variant. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "id": "52a23ea2-aabe-411f-befa-3d864da7d478",
  "data": {
    "id": "4fa27911-d5dd-4117-9676-057690cdc9d8",
    "name": "Early-Access-Build-Number",
    "audiences": [
      {
        "id": "37dbd115-9223-46c0-979f-07dc2c0b5690",
        "name": "Early-Access"
      }
    ],
    "value": "71",
    "create_time_sec": 1748860768,
    "update_time_sec": 1748861025,
    "flag_id": "c29ab570-b9ba-47f9-ba6a-1e48762f34fd"
  },
  "event": "FEATURE_FLAG_VARIANT_UPDATED"
}

The data object can contain:

FieldDescription
idThe variant’s unique identifier.
nameThe variant’s name.
audiencesThe audiences the variant targets, each with an id and name.
valueThe variant’s value.
flag_idThe unique identifier of the parent feature flag.
create_time_secWhen the variant was created, as a UNIX timestamp in seconds.
update_time_secWhen the variant was last updated, as a UNIX timestamp in seconds.

Satori sends FEATURE_FLAG_VARIANT_UPDATED for any change to the variant, including changes to its audience list.

See also #