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:
{
"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:
{
"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
- In the Ably dashboard, open your app's Integrations page, click New Integration Rule and choose Webhook, then Webhook again.
- Enter your HTTPS URL, pick the event type and, if you want, a channel filter such as
^public.*. - Choose Single request or Batch request, JSON or MsgPack, and leave Enveloped on unless you need raw data.
- Turn on Sign with key and choose one of the app's API keys.
- 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:
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 (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.
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:
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_ 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 verifies
X-Ably-Signaturewith 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-Keyand 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
2XXas soon as the request is verified and stored, and do the work afterwards. - Return a
5XX, not a4XX, 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, 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
timestampor messageidbefore 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.
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.
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 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 for free and handle Ably webhooks reliably in minutes.