> ## Documentation Index
> Fetch the complete documentation index at: https://docs.immutable.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time Audience CDP events as signed HTTP callbacks

Audience webhooks push CDP events to your HTTPS endpoint as they happen—account links, and additional event types as the catalog grows. Delivery is a signed HTTP POST you configure once in Hub; new event types reuse the same envelope and verification flow.

## How delivery works

1. You register an endpoint in [Hub](https://hub.immutable.com) under **Settings** → **Webhooks**.
2. Immutable sends each event as a signed SNS notification to that URL.
3. Your server verifies the signature, acknowledges with `200`, and handles the payload asynchronously.

All Audience webhook types share the same outer envelope (`id`, `type`, `api_version`, `created_at`, `project_id`, `data`). When new types ship, you subscribe to them in Hub and branch on `type`—you do not need a new integration pattern.

## Available event types

The catalog starts with account-link lifecycle events. More Audience event types will be added over time; treat the table below as the current set, not the final set.

| Event | Fired when |
| - | - |
| `audience_joined` | A player newly enters your project's audience |
| `audience_account_linked` | A player links an external account while already in your audience |
| `audience_account_unlinked` | A player disconnects an external account while already in your audience |

Every event is scoped to whichever project a player's game currently maps to, not a project it used to belong to. If a game moves between projects, past events aren't regenerated for the new one.

Select the types you care about when you create or edit the webhook in Hub. Unsupported or future types simply do not appear until they are released.

## Configure in Hub

Go to [Hub](https://hub.immutable.com) → **Settings** → **Webhooks** → **Add Webhook**, enter your HTTPS URL, and choose which Audience event types to receive.

You can add up to 10 webhook endpoints per environment, so routing the same events to more than one system (for example, a production and a staging backend) doesn't require a second project.

## Payload shape

Every event uses this envelope. The `data` object is type-specific.

```json theme={null}
{
  "id": "evt_11111111-1111-1111-1111-111111111111",
  "type": "audience_joined",
  "api_version": "2026-05-01",
  "created_at": "2026-05-01T10:30:00.183947231Z",
  "project_id": "22222222-2222-2222-2222-222222222222",
  "data": {}
}
```

> [accordion] audience\_joined
>
> Bundles every account the player currently has linked into one event, rather than one event per provider.
>
> | Field | Type | Description |
> | - | - | - |
> | `player.id` | `string` | The player's identifier. |
> | `linked_accounts` | `array` | Every provider account currently linked, each in the shape below. Empty if the player hasn't linked anything yet, a normal payload, not an error, use it as your general "player joined" signal. Never has two entries with the same `provider`. |
>
> Shape of each entry in `linked_accounts`:
>
> | Field | Type | Description |
> | - | - | - |
> | `provider` | `string` | One of `steam`, `discord`, `epic_games`, `x`, `telegram`, `tiktok`, `twitch`. |
> | `provider_account_id` | `string` | The account's ID on that provider. |
> | `username` | `string` | Display name. Always `""` for `provider: "steam"`, its link flow doesn't return one. Other providers populate it. |
> | `linked_at` | `string` (ISO 8601) | When that account was originally linked, not when `audience_joined` fired, which can be well before. Use the envelope's `created_at` for when the player joined. |
>
> ```json theme={null}
> {
>   "id": "evt_11111111-1111-1111-1111-111111111111",
>   "type": "audience_joined",
>   "api_version": "2026-05-01",
>   "created_at": "2026-05-01T10:30:00.183947231Z",
>   "project_id": "22222222-2222-2222-2222-222222222222",
>   "data": {
>     "player": {
>       "id": "auth0|abc123"
>     },
>     "linked_accounts": [
>       {
>         "provider": "discord",
>         "provider_account_id": "111111111111111111",
>         "username": "player#1234",
>         "linked_at": "2026-03-15T08:12:00Z"
>       },
>       {
>         "provider": "steam",
>         "provider_account_id": "76561198000000000",
>         "username": "",
>         "linked_at": "2026-04-02T14:05:33Z"
>       }
>     ]
>   }
> }
> ```
>
> **No guaranteed order against `audience_account_linked`.** If a player links an account and joins your audience around the same time, the two events can arrive in either order and may describe an account you've already seen from the other. Handle each independently.

> [accordion] audience\_account\_linked / audience\_account\_unlinked
>
> Share the same shape.
>
> | Field | Type | Description |
> | - | - | - |
> | `player.id` | `string` | The player's identifier. |
> | `linked_account` | `object` | The account that was linked or unlinked, in the shape below. |
>
> Shape of `linked_account`:
>
> | Field | Type | Description |
> | - | - | - |
> | `provider` | `string` | One of `steam`, `discord`, `epic_games`, `x`, `telegram`, `tiktok`, `twitch`. |
> | `provider_account_id` | `string` | The account's ID on that provider. |
> | `username` | `string` | Display name. Always `""` for `provider: "steam"`, its link flow doesn't return one. Other providers populate it. |
> | `linked_at` | `string` (ISO 8601) | When this link was made. A later relink on the same provider starts this over with its own new value, it doesn't stay pinned to the first link forever. |
>
> ```json theme={null}
> {
>   "id": "evt_11111111-1111-1111-1111-111111111111",
>   "type": "audience_account_linked",
>   "api_version": "2026-05-01",
>   "created_at": "2026-05-01T10:30:00.183947231Z",
>   "project_id": "22222222-2222-2222-2222-222222222222",
>   "data": {
>     "player": {
>       "id": "auth0|abc123"
>     },
>     "linked_account": {
>       "provider": "steam",
>       "provider_account_id": "76561198000000000",
>       "username": "",
>       "linked_at": "2026-05-01T10:29:58Z"
>     }
>   }
> }
> ```
>
> For unlink events, `type` is `audience_account_unlinked`. All other fields are identical, including `linked_at`, it's still the time of the link being undone, not the unlink time. Use the envelope's `created_at` if you need the unlink time itself.
>
> **A linked account belongs to at most one player at a time.** Linking a different account on the same provider is rejected until the current one is unlinked first.

## Verify the signature

Webhooks are signed by AWS SNS using RSA. Verify signatures using the [`sns-validator`](https://www.npmjs.com/package/sns-validator) npm package. There is no shared secret or custom header.

SNS delivers messages with `Content-Type: text/plain`. If you use Express, configure a raw text body parser for your webhook route:

```typescript theme={null}
app.use('/webhooks/immutable', express.text({ type: '*/*' }));
app.use('/webhooks/immutable', (req, res, next) => {
  if (typeof req.body === 'string') req.body = JSON.parse(req.body);
  next();
});
```

Then verify the signature and topic ARN:

```typescript theme={null}
import MessageValidator from 'sns-validator';

const validator = new MessageValidator();
const TOPIC_ARN = 'arn:aws:sns:us-east-2:362750628221:webhook-outbound-prod';

app.post('/webhooks/immutable', (req, res) => {
  res.status(200).send('OK');

  validator.validate(req.body, (err, message) => {
    if (err) {
      console.error('Invalid SNS signature', err);
      return;
    }

    if (message.TopicArn !== TOPIC_ARN) {
      console.error('Unexpected topic ARN', message.TopicArn);
      return;
    }

    if (message.Type === 'SubscriptionConfirmation') {
      fetch(message.SubscribeURL);
      return;
    }

    const event = JSON.parse(message.Message);
    handleEvent(event);
  });
});
```

Checking `TopicArn` confirms the event originated from Immutable's infrastructure and not a third-party SNS topic.

Handle events by switching on `event.type` so new types can be added without changing verification.

## Retry policy

Failed deliveries retry automatically with exponential backoff: 15 attempts, starting at 5 seconds and increasing up to 260 seconds between tries. AWS caps total retry time at 60 minutes for any HTTPS endpoint, so retries stop within that window at the latest. After that, the event is held in a dead-letter queue for 14 days, then discarded.

There is no manual redelivery from Hub today. If your endpoint is down for longer than the retry window, treat those events as lost.

See the [SNS delivery retry policy](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html) for background on how AWS implements this.

## Best practices

* **Respond quickly.** Return `200` to SNS immediately and process the event asynchronously to avoid delivery timeouts.
* **Deduplicate.** Use the `id` field to detect duplicate deliveries. The same event may arrive more than once.
* **Order by `created_at`, not delivery order.** Events aren't guaranteed to arrive in the order they happened. Sort by the envelope's `created_at` if sequencing matters.
* **Scope by project.** The `project_id` field identifies which of your projects the player belongs to. A player can appear in multiple projects and trigger events for each.
* **Ignore unknown types safely.** Log and skip `type` values your code does not recognize so catalog growth does not break your endpoint.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.