# Guide to PubNub Webhooks: Features and Best Practices

PubNub webhooks let your backend hear about what happens on your realtime network: a message is published, a user joins or leaves a channel, an App Context user or channel changes, or a mobile push fails. If you build chat, live updates or IoT on PubNub, a Webhook action is how those events reach a service you run, for moderation, analytics, archiving or syncing state.

This guide covers how Events & Actions listeners and Webhook actions fit together, the event types and payload formats, how to authenticate requests with a secret header, PubNub's retry and batching rules, and the best practices for production.

## What are PubNub webhooks?

PubNub delivers webhooks through **Events & Actions**. You build two things in the Admin Portal:

* An **event listener** chooses a source, such as messages or presence, and a filter for the events you want.
* A **Webhook action** sends each matching event to your HTTPS endpoint as a JSON request.

A listener can trigger one or more actions, depending on your plan. Webhook is one action type among several. Others send events to Amazon SQS, Kinesis, S3 or an AMQP exchange.

Events & Actions replaced the older webhooks configured on the **Keysets** page. PubNub migrated paid-plan keyset webhooks automatically before 16 January 2024, but the payload shape changed, so handlers written for legacy webhooks need updating. This guide covers Webhook actions only.

PubNub does not sign Webhook action requests. To authenticate them, you add custom headers to the action, with values taken from secret keys stored on your keyset, and your endpoint checks the header.

## PubNub webhook features

| Feature | Details |
| --- | --- |
| Configuration | Admin Portal, **Events & Actions**: an event listener paired with a Webhook action |
| Transport | JSON over HTTPS |
| Event identifier | `dataSchema`, a schema identifier such as `pubnub.com/schemas/events/messages.pubsub.publish?v=1.0.0` |
| Formats | No Envelope (default), or V1, V2 or V2.1 envelopes with **Is Enveloped** turned on |
| Authentication | Custom headers with values from keyset secret keys. No signature |
| Batching | Optional. Sends when 100 events (1 to 10,000) or 5 seconds (1 to 300) is reached, whichever is first |
| Success response | Any `2xx`. A `301` is followed for up to three redirects |
| Retries | Paid tiers only: 1 to 4 retries (default 2), jittered backoff from a 10 to 900 second base (default 450) |
| Free tier | One listener, Webhook actions only, no retries |
| Source IPs | Not published |

## Common events

PubNub's payloads page documents 22 event types. Message sent and File sent share one schema identifier, so there are 21 distinct `dataSchema` values. The table shows the part between `pubnub.com/schemas/events/` and `?v=`.

| Area | Event types |
| --- | --- |
| Messages | `messages.pubsub.publish` (message and file sent), `messages.action.created`, `messages.action.deleted` |
| Presence | `presence.user.channel.joined`, `presence.user.channel.left`, `presence.user.timedout.in.channel`, `presence.channel.state.active`, `presence.channel.state.inactive`, `presence.channel.occupancy.counted`, `presence.channel.user.state.in.changed` |
| App Context | `user.created`, `user.updated`, `user.deleted`, `channel.created`, `channel.updated`, `channel.deleted`, `membership.created`, `membership.updated`, `membership.deleted` |
| Mobile push | `push.device.removed`, `push.message.sending.failed` |

The default No Envelope body has two fields: the schema identifier, and a `data` array of event records. Here is PubNub's Message sent example:

```json
{
  "dataSchema": "pubnub.com/schemas/events/messages.pubsub.publish?v=1.0.0",
  "data": [
    {
      "timestamp": "2025-06-05T12:53:15.888Z",
      "message": "Message Test",
      "meta": null,
      "senderId": "Jack-device",
      "channel": "Channel-Barcelona",
      "timetoken": "17491279958880737"
    }
  ]
}

```

PubNub says the `dataSchema` and `schema` values "are identifiers, not URLs you can open" and should be compared as exact strings. Each one ends with a version (`?v=1.0.0`), so if you want routing that survives a version change, strip the prefix and version and route on the dotted name.

With **Is Enveloped** turned on, the same event moves down a level. In the V2 and V2.1 envelopes (2.1 is the default), the event sits at `event.payload` beside a `metadata` object, and the top-level `schema` is the envelope's own identifier, not the event's. The V1 envelope uses `schema` instead of `dataSchema` for most event types, and Message sent and File sent use a different V1 layout again. Pick one format per action and parse only that one.

## Setting up PubNub webhooks

1. Generate a long random value, for example with `openssl rand -hex 32`, and store it as a secret key on your keyset.
2. In the Admin Portal, open **Events & Actions**, click **+ Add Action**, and choose **Webhook**.
3. Under **Configuration**, enter your HTTPS endpoint URL.
4. Add a custom header, such as `X-PubNub-Secret`, and select the secret key as its value.
5. Decide whether you want **Batching**, **Webhook retry** and **Is Enveloped**. Turn on Webhook retry if your plan allows it.
6. Click **Add event listener** to pair the action with an existing listener, or create one with the source and filter you need, then **Save changes**.
7. Publish a test message that matches the listener's filter and check that your endpoint receives it.

**In Hookdeck.** Create a source with the PubNub type. Enter the custom header's name in **Header Name** and the secret key's exact value in **Header Value**, and use the source URL as the action's endpoint. Hookdeck matches the header name case-insensitively and the value exactly. The source type covers Events & Actions Webhook actions only, because legacy keyset webhooks can't send a secret header.

## Securing PubNub webhooks

Compare the custom header with the secret value in constant time, and reject the request if the header is missing or different.

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

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const SCHEMA = /^pubnub\.com\/schemas\/events\/([^?]+)\?v=/;

const app = express();

app.post('/webhooks/pubnub', express.raw({ type: '*/*' }), (req, res) => {
  const expected = process.env.PUBNUB_WEBHOOK_SECRET;
  if (!expected) return res.sendStatus(500); // fail closed when unconfigured

  const received = req.get(process.env.PUBNUB_WEBHOOK_HEADER || 'X-PubNub-Secret');
  if (typeof received !== 'string' || !safeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // No Envelope format: { dataSchema, data: [...] }
  const type = SCHEMA.exec(body.dataSchema || '')?.[1]; // e.g. "messages.pubsub.publish"
  for (const record of body.data ?? []) {
    // queue { type, record } for a background worker
  }
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_pubnub_request(headers) -> bool:
    expected = os.environ.get("PUBNUB_WEBHOOK_SECRET")
    if not expected:
        return False  # fail closed
    header_name = os.environ.get("PUBNUB_WEBHOOK_HEADER", "X-PubNub-Secret")
    received = headers.get(header_name)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

A matching header proves that the sender knows the secret. It doesn't prove that the body is unchanged, and a captured request can be replayed later and still pass, because PubNub sends no signature and no timestamp. Keep the endpoint on HTTPS, use a long random secret, and rotate it on the keyset and in your configuration if it may have leaked.

> **Make PubNub webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the secret header on every request, stores each event durably, and retries delivery to your service on any plan.

## PubNub webhook limitations and pain points

### No retries on the Free tier

**The Problem:** On the Free tier, any failed delivery is lost. On paid tiers, the default is only two retries.

**Why It Happens:** Events & Actions retries are a paid feature. Even with them on, an action retries 1 to 4 times, with a random delay of at least 10 seconds and at most 900 seconds per attempt, so a long outage still loses events.

**Workarounds:**

* Turn on **Webhook retry** and raise the retry count to 4 if your plan allows it.
* Keep your endpoint fast and highly available, and answer `2xx` as soon as the header checks out.
* For data you can't lose, such as chat messages, also enable Message Persistence and backfill from history after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each request as soon as it arrives and stores it, then delivers it to your service with retries on a schedule you control, on any PubNub tier. You can replay any event after you fix a bug.

### Several payload shapes

**The Problem:** A handler written against one example fails on real traffic, or a migrated legacy handler reads fields that no longer exist.

**Why It Happens:** The same event can arrive in four formats (No Envelope, V1, V2 and V2.1), batching changes how many events a request holds, and the legacy keyset webhooks used another shape again. PubNub's payloads page doesn't show the batched body.

**Workarounds:**

* Choose one format per action and keep it. No Envelope is the simplest.
* Always iterate over `data`, even when you expect one record.
* Send a test event after any configuration change and look at the actual body before deploying.

**How Hookdeck Can Help:** Every request that reaches a Hookdeck source is stored with its full body and headers, so you can see the exact shape your action sends and replay it against your handler while you write the parser.

### A secret header, not a signature

**The Problem:** Anyone who learns the header value can post fake messages or presence events to your endpoint.

**Why It Happens:** Webhook actions don't sign requests. The custom header is the same static value on every request.

**Workarounds:**

* Use a long random secret, HTTPS only, and keep it out of logs and client code.
* Deduplicate on fields that identify the event, such as `timetoken` and `channel` for messages.
* Before acting on something sensitive, confirm it through the PubNub API.

**How Hookdeck Can Help:** Hookdeck's PubNub source type compares the header you name with the exact value you enter. Hookdeck answers PubNub with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination.

### Presence and message volume

**The Problem:** Busy channels produce far more requests than your endpoint was sized for.

**Why It Happens:** Every published message or presence change that matches a listener's filter produces an event. Without batching, each one is its own request.

**Workarounds:**

* Narrow listener filters to the channels and events you act on.
* Turn on batching for high-volume listeners, and size the batch for your endpoint's request limits.

**How Hookdeck Can Help:** Hookdeck absorbs bursts at the edge and can rate-limit delivery to your service, so a busy channel can't overwhelm it. [Filters](/docs/filters) can route message and presence events to different destinations.

## Best practices

### Route on `dataSchema` as an exact string

Compare schema identifiers exactly, as PubNub recommends, or strip the prefix and version and route on the dotted name. Don't try to fetch them as URLs.

### Iterate every record

`data` is an array. Handle each record, and make sure one bad record doesn't fail the whole request, because a failed request is retried in full on paid tiers.

### Deduplicate deliveries

Retries resend events you may already have processed. For messages, `timetoken` with `channel` is a good key. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return `2xx` as soon as the header checks out, then process in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep the secret server-side

The header value comes from a keyset secret key. Keep it in your server's environment and never in code that ships to browsers or devices.

## Conclusion

PubNub forwards realtime events through Events & Actions Webhook actions, with a custom header from your keyset as the only credential. Compare that header in constant time over HTTPS, route on `dataSchema`, iterate every record in `data`, pick one payload format per action, and plan for the limited retries, or none at all on the Free tier.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the secret header, absorbs bursts, and keeps every event until your service has processed it.

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