# Webhooks

**URL:** https://heroiclabs.com/docs/satori/concepts/webhooks/
**Summary:** Webhooks send an HTTP request to your backend when something happens in Satori, so your systems can react to live events, messages, journeys, and feature flag changes without polling.
**Keywords:** webhooks, backend integration, signing secret, webhook retries, webhook payload, scheduled message webhook, journey webhook
**Categories:** satori

---


# 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](#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:

<!-- Internal note: These are WebhookEvent.Type enums -->
| Event | Sent when |
|---|---|
| `LIVE_EVENT_STARTED` | A live event run starts. |
| `LIVE_EVENT_ENDED` | A live event run ends. |
| `SCHEDULED_MESSAGE_SENT` | A scheduled message that uses the **Webhook** message integration is sent to players. |
| `DIRECT_MESSAGE_SENT` | A direct message that uses the **Webhook** message integration is sent from the console or the Console API. |
| `JOURNEY_STEP_REACHED` | Players reach a **Send Webhook** step in a [journey](../journeys/). |
| `FEATURE_FLAG_CREATED` | A feature flag is created. |
| `FEATURE_FLAG_UPDATED` | A feature flag is updated. |
| `FEATURE_FLAG_DELETED` | A feature flag is deleted. |
| `FEATURE_FLAG_VARIANT_CREATED` | A feature flag variant is created. |
| `FEATURE_FLAG_VARIANT_UPDATED` | A feature flag variant is updated. |
| `FEATURE_FLAG_VARIANT_DELETED` | A 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](/docs/heroic-cloud/concepts/titles/satori-deployments/#configuration-and-management) of your Satori deployment.

{{< note "important" "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](../journeys/) with a **Send Webhook** step. The journey's entry criteria define the condition.
{{< /note >}}

## 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](/docs/heroic-cloud/concepts/titles/satori-deployments/#configuration-and-management) of your Satori deployment.

The response status code decides whether Satori retries:

| Response | Retried |
|---|---|
| `2xx` | No, the delivery succeeded. |
| `429 Too Many Requests` | Yes |
| `5xx`, except `501 Not Implemented` | Yes |
| `501 Not Implemented` | No |
| Other `4xx` | No |
| `3xx` redirect | No. Satori doesn't follow redirects and records the response as a failure. |
| Network error, such as a connection failure or timeout | Yes, 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](#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](#scheduled-message-sent).
- **`JOURNEY_STEP_REACHED`**: Up to 1,000 players per request, in the `events` array. See [Journey step reached](#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:

    {{< screenshot
      src="/images/pages/satori/concepts/monitoring/monitoring_webhooks-create.png"
      alt="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](#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.

{{< screenshot
  src="/images/pages/satori/concepts/monitoring/monitoring_webhooks-details.png"
  alt="Webhook details page showing settings and the events log"
  caption="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.

{{< screenshot
  src="/images/pages/satori/concepts/monitoring/monitoring_webhooks-attempts.png"
  alt="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](../../console/).

### 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.

{{< screenshot
  src="/images/pages/satori/concepts/monitoring/monitoring_webhooks-send-test-event.png"
  alt="Send test event modal showing event type selection and request payload preview"
  width="70%"
>}}

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:

```javascript
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:

| Field | Description |
|---|---|
| `id` | The unique identifier of the delivery. It stays the same across retries. |
| `event` | The Satori event that triggered the request, for example `LIVE_EVENT_STARTED`. |
| `data` | Details 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:

```json
{
  "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:

| Field | Description |
|---|---|
| `id` | The live event's unique identifier. |
| `name` | The live event's name. |
| `start_time_sec` | The live event's start time, as a UNIX timestamp in seconds. |
| `duration_sec` | The duration of each run, in seconds. |
| `reset_cron` | The live event's reset cron expression. |
| `run_start` | The start time of this run, as a UNIX timestamp in seconds. |
| `run_end` | The 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.

```json
{
  "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:

| Field | Description |
|---|---|
| `messages` | The messages in this request. |
| `messages[].id` | The unique identifier of this message instance. |
| `messages[].identity_id` | The identity ID of the player receiving the message. |
| `messages[].title` | The rendered message title. |
| `messages[].payload` | The rendered message body. |
| `messages[].metadata` | The rendered message metadata, as key-value pairs. |
| `messages[].image_url` | The URL of the message's image, if any. |
| `messages[].send_time_sec` | The scheduled send time, as a UNIX timestamp in seconds. |
| `message_schedule.id` | The identifier of the message schedule. |
| `live_event` | The 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](../player-messaging/create-message-templates/).

### Direct message sent

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

```json
{
  "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:

| Field | Description |
|---|---|
| `id` | The unique identifier of this message instance. |
| `identity_id` | The identity ID of the player receiving the message. |
| `title` | The message title. |
| `payload` | The message body. |
| `metadata` | The message metadata, as key-value pairs. |
| `image_url` | The URL of the message's image, if any. |
| `send_time_sec` | The send time, as a UNIX timestamp in seconds. |
| `value_type` | The value type of the message content, if set. |

### Journey step reached

When players reach a **Send Webhook** step in a [journey](../journeys/), 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.

```json
{
  "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:

| Field | Description |
|---|---|
| `identity_id` | The identity ID of the player who reached the step. |
| `journey_id` | The journey's unique identifier. |
| `journey_name` | The journey's name. |
| `step_id` | The step's unique identifier. |
| `step_name` | The step's name. |
| `reached_time_sec` | When the player reached the step, as a UNIX timestamp in seconds. |
| `payload` | The 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:

```json
{
  "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:

| Field | Description |
|---|---|
| `id` | The feature flag's unique identifier. |
| `name` | The feature flag's name. |
| `description` | The feature flag's description. |
| `value` | The feature flag's value. |
| `variants` | The flag's variants. Each variant has the fields described in [Feature flag variant events](#feature-flag-variant-events). |
| `schema_id` | The ID of the schema that validates the flag's value. See **Taxonomy** > **Schema Validators**. |
| `create_time_sec` | When the flag was created, as a UNIX timestamp in seconds. |
| `update_time_sec` | When 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:

```json
{
  "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:

| Field | Description |
|---|---|
| `id` | The variant's unique identifier. |
| `name` | The variant's name. |
| `audiences` | The audiences the variant targets, each with an `id` and `name`. |
| `value` | The variant's value. |
| `flag_id` | The unique identifier of the parent feature flag. |
| `create_time_sec` | When the variant was created, as a UNIX timestamp in seconds. |
| `update_time_sec` | When 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

- [Journeys](../journeys/)
- [Message integrations](../player-messaging/message-integrations/)
- [Live events](../live-events/)
- [Satori Console API](../../console/)
