# Guide to Ably Webhooks: Features and Best Practices

Ably webhooks push what is happening on your realtime channels to your own servers. When a client publishes a message, a member enters or leaves presence, a channel opens or closes, or occupancy changes, an Ably integration rule can send that event to an HTTP endpoint. That is how you persist chat history, moderate content, trigger notifications or feed analytics from Ably traffic without keeping a subscriber connected.

This guide covers how Ably webhook rules are configured, the payload shapes each combination of settings produces, how to sign and verify requests, how retries differ between single and batched delivery, and the best practices for running an Ably receiver in production.

## What are Ably webhooks?

Ably calls them outbound webhook integrations. Each one is a rule on an Ably app that pairs a source, such as `channel.message`, with a target URL, an optional channel filter and a few delivery settings. When an event matches, Ably sends a `POST` to the URL.

Three settings decide what arrives. **Request Mode** chooses between one request per event and batched requests. **Enveloped**, on by default, wraps each event in metadata such as the app, channel and rule. **Encoding** chooses JSON or MsgPack. The body your handler sees depends on all three, so the first job in any Ably integration is to know which shape your rule sends.

Signing is opt-in. With **Sign with key** on, Ably signs the raw body with one of your app's API keys and sends the result in `X-Ably-Signature`. The HMAC key is not the whole API key but only its secret part, the text after the colon.

## Ably webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (**Integrations > New Integration Rule > Webhook**), the Control API or the Ably CLI |
| Subscription model | One rule per source, optionally narrowed by a channel name regular expression |
| Request modes | Single request (one event per `POST`) or batched (up to 1,000 events per `POST`) |
| Formats | Enveloped (default) or non-enveloped. JSON or MsgPack |
| Signature header | `X-Ably-Signature`, only when **Sign with key** is on. `X-Ably-Key` names the key |
| Scheme | HMAC-SHA256, RFC 3548 base64 |
| Signed content | The raw request body only. Headers are not signed |
| Secret | The `keyValue` of an Ably API key (`appId.keyId:keyValue`), the part after the colon |
| Replay window | None. No timestamp is signed |
| Retries, single | `5XX` responses are retried. A timeout is retried twice, after 4 and 20 seconds |
| Retries, batched | Exponential backoff by a factor of the square root of 2, up to 60 seconds, then every 60 seconds. Events are dropped after 5 minutes |
| Version header | `x-ably-version`, currently `1.2` |
| Custom headers | Optional `key:value` headers on each request |

Ably recommends an HTTPS URL. It publishes no source IP list for webhooks, and it documents no response timeout value.

## Common events

A rule subscribes to one of four sources:

| Source | Fires when | Batched `name` |
| --- | --- | --- |
| `channel.message` | A message is published on a matching channel | `channel.message` |
| `channel.presence` | A member enters, leaves or updates their presence | `presence.message` |
| `channel.lifecycle` | A channel is created or discarded | `channel.opened` or `channel.closed` |
| `channel.occupancy` | The number of users on a channel changes | Not documented |

Ably gives no example payload for `channel.occupancy`. For metachannels, Ably says to use the lifecycle and occupancy sources rather than `channel.message`.

### Batched requests

A batched body is an `items` array. Here is Ably's batched `channel.message` example:

```json
{
  "items": [
    {
      "webhookId": "ABcDEf",
      "source": "channel.message",
      "serial": "a7bcdEFghIjklm123456789:4",
      "timestamp": 1562124922426,
      "name": "channel.message",
      "data": {
        "channelId": "my-channel-4",
        "site": "eu-west-1-A",
        "messages": [
          {
            "id": "ABcDefgHIj:1:0",
            "clientId": "user-3",
            "connectionId": "ABcDefgHIj",
            "timestamp": 1123145678900,
            "data": "the message data",
            "name": "a message name"
          }
        ]
      }
    }
  ]
}

```

Every example in Ably's docs has one item, but a batch can hold up to 1,000, so always iterate `items`. Presence items carry `data.presence` instead of `data.messages`, and lifecycle items carry `data.status` with the channel's occupancy metrics. Timestamps are milliseconds since the epoch.

### Enveloped single requests

An enveloped single request has the metadata at the top level and the events in an array named for their type:

```json
{
  "source": "channel.message",
  "appId": "aBCdEf",
  "channel": "channel-name",
  "site": "eu-central-1-A",
  "ruleId": "1-a2Bc",
  "messages": [
    {
      "id": "ABcDefgHIj:1:0",
      "connectionId": "ABcDefgHIj",
      "timestamp": 1123145678900,
      "data": "some message data",
      "name": "my message name"
    }
  ]
}

```

Ably's enveloped presence example has a `presence` array but `"source": "channel.message"`. Until you have seen what your own presence rule sends, check for the `presence` key rather than trusting `source` alone.

### Non-enveloped requests

With **Enveloped** off, the body is the message data itself, which can be JSON, plain text or binary. The context moves into headers: `x-ably-envelope-source`, `x-ably-envelope-channel`, `x-ably-envelope-appid`, `x-ably-envelope-rule-id`, `x-ably-message-id`, `x-ably-message-name` and others. Those headers are not covered by the signature.

### Decoding message data

Messages and presence entries arrive in Ably's encoded wire form. Ably recommends decoding them with `Message.fromEncodedArray()` and `PresenceMessage.fromEncodedArray()` from its SDK, which restores the original data type, decrypts encrypted channels when you pass the key, and turns the numeric presence `action` into `enter`, `leave` or `update`.

## Setting up Ably webhooks

1. In the Ably dashboard, open your app's **Integrations** page, click **New Integration Rule** and choose **Webhook**, then **Webhook** again.
2. Enter your HTTPS URL, pick the event type and, if you want, a channel filter such as `^public.*`.
3. Choose **Single request** or **Batch request**, JSON or MsgPack, and leave **Enveloped** on unless you need raw data.
4. Turn on **Sign with key** and choose one of the app's API keys.
5. Click **Create**, then use **Test rule** on the rule to send a test request.

From the **API Keys** page, copy the key you chose. It has the form `appId.keyId:keyValue`. Store only the part after the colon, for example as `ABLY_WEBHOOK_KEY_SECRET`.

To create the rule from code, use the Control API with an access token:

```bash
curl -X POST "https://control.ably.net/v1/apps/$ABLY_APP_ID/rules" \
  -H "Authorization: Bearer $ABLY_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "ruleType": "http",
    "requestMode": "single",
    "source": { "channelFilter": "", "type": "channel.message" },
    "target": {
      "url": "https://example.com/webhooks/ably",
      "format": "json",
      "enveloped": true
    }
  }'

```

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 ably --path /webhooks/ably`) gives you a public HTTPS URL that forwards to your local server, with no account required.

## Securing Ably webhooks

Read the raw body, compute an HMAC-SHA256 of it keyed with the `keyValue`, base64-encode the result and compare it with `X-Ably-Signature` in constant time. Base64 is case-sensitive, so compare it exactly.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAblySignature(rawBody, signatureHeader, keySecret) {
  if (!signatureHeader || !keySecret) return false;
  const expected = crypto.createHmac('sha256', keySecret).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// Raw bytes for every content type: bodies can be JSON, MsgPack, text or binary.
app.post('/webhooks/ably', express.raw({ type: '*/*' }), (req, res) => {
  // Only the part of the API key after the ':' is the HMAC key.
  if (!verifyAblySignature(req.body, req.get('x-ably-signature'), process.env.ABLY_WEBHOOK_KEY_SECRET)) {
    return res.sendStatus(401);
  }

  // Decode according to content-type, then iterate items (batched) or messages/presence (enveloped).
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_ably_signature(raw_body: bytes, signature_header, key_secret: str) -> bool:
    if not signature_header or not key_secret:
        return False
    digest = hmac.new(key_secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_ably_signature(raw_body, request.headers.get("x-ably-signature"),
#                       os.environ["ABLY_WEBHOOK_KEY_SECRET"])

```

If you store the full API key instead, split it first: `api_key.partition(":")[2]` in Python or `apiKey.slice(apiKey.indexOf(':') + 1)` in Node.

To check your verifier, use the key secret `ably-test-key-value` and this exact body:

```
{"items":[{"webhookId":"ABcDEf","source":"channel.message","timestamp":1562124922426,"name":"channel.message","data":{"channelId":"my-channel-4","messages":[{"id":"ABcDefgHIj:1:0","data":"the message data"}]}}]}

```

The signature should be:

```
+An+wiWBxstrz3TqDW7xiE2No/n6lIUbY9OnNzOG0C0=

```

`X-Ably-Key` tells you which key signed the request. If you receive rules signed with different keys, use it to pick the right secret. If a signing key is removed or revoked, Ably stops delivering for the rule rather than falling back to unsigned requests.

> **Make Ably webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Ably-Signature` with your key secret, accepts every single or batched request as soon as it arrives, and durably queues it so Ably's short retry windows stop being your problem.

## Ably webhook limitations and pain points

### The whole API key is the wrong key

**The Problem:** Every request fails verification, although the key is the right one.

**Why It Happens:** Ably API keys look like `appId.keyId:keyValue`, and the HMAC key is only `keyValue`. Pasting the whole string into your verifier, or into any tool that verifies for you, produces a valid-looking base64 digest that never matches.

**Workarounds:**

* Store only the part after the colon, or split it in code before use.
* Log `X-Ably-Key` and check it names the key you think it does.

**How Hookdeck Can Help:** When you configure Hookdeck's Ably source, enter only the key secret, the part after the colon. Hookdeck reads `X-Ably-Signature` only, not `X-Ably-Key`, so every rule pointed at one source must sign with the same key.

### Signing is off unless you turn it on

**The Problem:** Requests arrive with no `X-Ably-Signature`.

**Why It Happens:** **Sign with key** is an optional setting on each rule, and a rule without it sends unsigned requests.

**Workarounds:**

* Turn on **Sign with key** for every rule that reaches a public URL, and reject unsigned requests.
* If you add custom headers to the rule as a shared secret instead, remember that they are not a signature.

**How Hookdeck Can Help:** Hookdeck verifies the signature when you give the source your key secret. If you didn't turn on signing in Ably, leave verification off on the source.

### Short retry windows

**The Problem:** Events published during a short outage never reach your service.

**Why It Happens:** In single request mode, Ably retries `5XX` responses and retries a timed-out request twice, after 4 and 20 seconds. Ably does not say whether a `4XX` is retried. In batched mode the backoff grows from about 1.4 seconds to 60 seconds, but the queue of events is kept for only 5 minutes, after which they are discarded. A batched endpoint that falls behind a sustained rate of more than 1,000 events a second also builds a backlog.

**Workarounds:**

* Return a `2XX` as soon as the request is verified and stored, and do the work afterwards.
* Return a `5XX`, not a `4XX`, for transient failures so single requests are retried.
* For high volumes, Ably itself recommends sending to a queue such as Kinesis or SQS rather than straight to an HTTP function.

**How Hookdeck Can Help:** Hookdeck accepts and stores each request as it arrives, so Ably's retry clock never starts. Delivery to your service then follows [your own retry policy](/docs/retries), and anything that fails can be replayed later.

### Non-enveloped metadata is unsigned

**The Problem:** A non-enveloped request verifies, but its channel name or source could have been changed.

**Why It Happens:** The signature covers only the body. In non-enveloped mode the source, channel, app and rule are in `x-ably-envelope-*` headers, and Ably says those headers "cannot be verified in the same way".

**Workarounds:**

* Use enveloped events when your handler makes decisions based on the channel or source.
* If you need non-enveloped bodies, give each rule its own URL so the path, not a header, tells you what arrived.

**How Hookdeck Can Help:** A separate Hookdeck source per rule gives each rule its own URL and its own verification setting.

### No ordering in single request mode

**The Problem:** A `leave` is processed before the `enter` it follows, or messages land out of order.

**Why It Happens:** Ably says single request webhooks "cannot guarantee order". Batched webhooks preserve order for messages from the same publisher on the same channel, but publishing over REST removes even that.

**Workarounds:**

* Use batched mode where order matters, and sort each batch by `timestamp` or message `id` before processing.
* Make state updates commutative where you can, for example by keeping the latest timestamp per member.

**How Hookdeck Can Help:** Hookdeck can cap the delivery rate to your destination, which smooths out bursts of concurrent single requests. It does not reorder events, so the ordering logic still belongs in your handler.

## Best practices

### Verify against the raw bytes, for every content type

Bodies can be `application/json`, `application/x-msgpack`, `text/plain` or `application/octet-stream`, and all of them are signed. Mount `express.raw({ type: '*/*' })`, use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or read `await request.body()` in FastAPI. Verify first, then decode.

### Know your rule's shape

Write the handler for the request mode and envelope setting you configured. Batched bodies are `{ items: [...] }`, enveloped bodies have `messages` or `presence` at the top level, and non-enveloped bodies are raw data. If you change a rule's settings, change the handler at the same time.

### Decode with the Ably SDK

Pass `messages` and `presence` arrays through `Message.fromEncodedArray()` and `PresenceMessage.fromEncodedArray()` before using them, as Ably recommends. Raw `data` can be base64 or encrypted, and presence actions arrive as numbers.

### Deduplicate on message IDs

Retries can deliver an event twice. Messages and presence entries carry an `id`, and batch items carry a `serial`. Store them before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Ably limits how many requests are in flight per rule, and its docs say to "accept requests quickly and defer any work to be done asynchronously". Verify, store and respond, then process from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Ably webhook rules send channel messages, presence, lifecycle and occupancy events in single or batched requests, enveloped or raw, as JSON or MsgPack. Turn on **Sign with key**, verify `X-Ably-Signature` against the raw bytes with only the secret part of the API key, iterate batches, decode with the SDK and respond quickly, because single requests get two timeout retries and batches are dropped after 5 minutes.

[Hookdeck Event Gateway](https://hookdeck.com) accepts Ably's requests the moment they arrive, checks every signature and holds each event until your service has processed it, so a slow deploy does not cost you a window of realtime traffic.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Ably webhooks reliably in minutes.