> For the complete documentation index, see [llms.txt](https://documentation.pushly.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://documentation.pushly.com/platform/event-webhooks.md).

# Event Webhooks

## Event Webhooks

Event Webhooks deliver real-time HTTP callbacks when subscriber events occur. Configure one or more endpoints per domain to receive structured event data as it happens.

### Configuration

Navigate to **Domain Settings → Event Webhooks** to create, edit, or archive webhook endpoints.

Each webhook specifies:

* **URL**: the destination endpoint
* **Headers**: custom headers, with optional Liquid macros for secrets
* **Events**: one or more event types to subscribe to

All deliveries are POST with a JSON body containing the full event payload.

<figure><img src="https://810756845-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-Lry9Z27iWOZyQEGAgY7%2Fuploads%2FcPE6zmhV6y3IDxn1Vrkk%2FScreenshot%202026-05-13%20at%208.02.32%E2%80%AFAM.png?alt=media&#x26;token=b63582bb-7a22-477a-bd94-e4b57fdbbbc8" alt=""><figcaption></figcaption></figure>

### Supported Events

| Group             | Event                       | Description                                  |
| ----------------- | --------------------------- | -------------------------------------------- |
| Notification      | `notification.displayed`    | A subscriber's device renders a notification |
| Notification      | `notification.clicked`      | A subscriber clicks a notification           |
| Push Subscription | `push_subscription.created` | A device becomes reachable for push          |
| Push Subscription | `push_subscription.deleted` | A device stops being reachable for push      |

**Notification events fire per action.** Each fires once per subscriber per action — a notification sent to 10,000 subscribers produces up to 10,000 individual events.

**Push subscription events fire on a change.** A device that is already unsubscribed and receives another unsubscribe produces no event, because nothing changed.

#### Subscriptions are per device

A push subscription is a single device, not a person. Someone reachable on both a phone and a laptop has two subscriptions and produces two events.

#### Push subscription events start when you enable them

Subscribing to these events does not replay history or send the current state of your audience. You receive changes from the moment the subscription is saved, so reachability built purely from these events is complete only for devices that changed after that point. If you need a starting picture of your audience, establish it separately.

### Payload Structure

Fixed envelope with event-specific data. Null fields are omitted.

{% tabs %}
{% tab title="Notification Events" %}
{% code expandable="true" %}

```javascript
{
  "version": 1,
  "event_type": "notification.displayed",
  "event_id": "d426d74a-b0cc-46e3-9a6d-d12ac346effe",
  "event_timestamp": "2026-05-08T14:17:30.452Z",
  "domain": {
    "id": 1234,
    "name": "Cafe 80s",
    "url": "cafe80s.com"
  },
  "user": {
    "id": "atk3i6E0NtohVy4UlvvkGQfl6J5wNTYp",
    "external_id": "11298b50-c1a7-4db3-aebc-829fed8b0d94",
    "device": {
      "platform": "web",
      "type": "desktop",
      "os": "macos",
      "os_version": "14.4",
      "browser": "chrome",
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
      "time_zone": "America/Chicago",
      "location": {
        "city": "Hill Valley",
        "province": "California",
        "country_code": "US",
        "postal_code": "19851",
        "continent_code": "NA"
      }
    }
  },
  "data": {
    "notification": {
      "id": 21015,
      "title": "Save the clock tower!",
      "body": "Mayor Wilson's preservation society needs your help — donate before lightning strikes",
      "keywords": ["hill-valley", "preservation", "clock-tower"],
      "send_timestamp": "2026-05-08T14:17:25.000Z",
      "source": "manual",
      "delivery_type": "scheduled",
      "segment_ids": [1955, 1985],
      "segment_names": ["Hill Valley Residents", "Preservation Society Members"],
      "campaign": {
        "id": 88,
        "step_id": 1
      }
    }
  }
}
```

{% endcode %}
{% endtab %}

{% tab title="Push Subscription Events" %}
A creation carries `subscribed_url` and no `reason_code`.

{% code expandable="true" %}

```javascript
{
  "version": 1,
  "event_type": "push_subscription.created",
  "event_id": "01a05848-62b0-77d9-add4-af0567c92290",
  "event_timestamp": "2026-08-31T14:45:34.000Z",
  "domain": {
    "id": 1234,
    "name": "Cafe 80s",
    "url": "cafe80s.com"
  },
  "user": {
    "id": "u7CGRJJoGjejItgAEIV3X0uadD2byP81",
    "external_id": "11298b50-c1a7-4db3-aebc-829fed8b0d94",
    "device": {
      "platform": "web",
      "type": "mobile",
      "os": "android",
      "os_version": "11",
      "browser": "chrome",
      "time_zone": "America/Chicago",
      "location": {
        "city": "Hill Valley",
        "province": "California",
        "country_code": "US",
        "postal_code": "19851",
        "continent_code": "NA"
      }
    }
  },
  "data": {
    "push_subscription": {
      "subscribed_url": "https://cafe80s.com/menu"
    }
  }
}
```

{% endcode %}

A deletion carries `reason_code` and no `subscribed_url`.

{% code expandable="true" %}

```javascript
{
  "version": 1,
  "event_type": "push_subscription.deleted",
  "event_id": "01a05849-1f30-7c41-9b02-3ad7715c8f19",
  "event_timestamp": "2026-08-31T15:02:11.000Z",
  "domain": {
    "id": 1234,
    "name": "Cafe 80s",
    "url": "cafe80s.com"
  },
  "user": {
    "id": "u7CGRJJoGjejItgAEIV3X0uadD2byP81",
    "external_id": "11298b50-c1a7-4db3-aebc-829fed8b0d94",
    "device": {
      "platform": "web",
      "type": "mobile",
      "os": "android",
      "os_version": "11",
      "browser": "chrome",
      "time_zone": "America/Chicago"
    }
  },
  "data": {
    "push_subscription": {
      "reason_code": "user_unsubscribed"
    }
  }
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

#### Envelope

| Field             | Type                   | Description                                                                  |
| ----------------- | ---------------------- | ---------------------------------------------------------------------------- |
| `version`         | int                    | Payload schema version                                                       |
| `event_type`      | string                 | Dot-namespaced event identifier (e.g. `notification.clicked`)                |
| `event_id`        | string (UUID)          | Stable identifier for this event. Use as your deduplication key.             |
| `event_timestamp` | string (ISO 8601, UTC) | When the event occurred                                                      |
| `domain`          | object                 | The Pushly domain the event belongs to                                       |
| `user`            | object                 | The subscriber who triggered the event                                       |
| `data`            | object                 | Event-specific content. Contains exactly one key matching the event subject. |

The `data` object contains a single key matching the event's subject. For `notification.*` events, that key is `notification`; for `push_subscription.*` events, `push_subscription`.

The `user` and `device` blocks are identical in shape and in values across every event type, so you can key on them regardless of which event carried them. `device.user_agent` is only present on notification events.

All timestamps are ISO 8601 UTC with millisecond precision.

#### Push subscription data

| Field            | Type   | Present on | Description                                                  |
| ---------------- | ------ | ---------- | ------------------------------------------------------------ |
| `subscribed_url` | string | `created`  | The page the device subscribed from, when known              |
| `reason_code`    | string | `deleted`  | Why the device stopped being reachable. See the table below. |

#### Reason codes

A deletion always carries a `reason_code`. The distinction worth acting on is whether the person made a decision or whether delivery simply stopped working — the first two are their choice, the next two are not.

| Reason code             | Meaning                                                                                                                                                  |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user_unsubscribed`     | The person opted out — either through a control in your own app or site, or by withdrawing notification permission from the browser or operating system. |
| `data_deletion_request` | The device was removed as part of a user data deletion request.                                                                                          |
| `token_invalidated`     | The push provider rejected the device token as no longer valid. No action was taken by the person, and they may subscribe again from the same device.    |
| `scrub`                 | The device was removed by list hygiene after a period without engagement. No action was taken by the person, and the token may still be valid.           |
| `consolidated`          | The device's record was merged into another record for the same person. **They are still reachable**, under the surviving record. See the note below.    |
| `other`                 | An internal reason that does not map to any of the above.                                                                                                |

{% hint style="warning" %}
**`consolidated` does not mean the person stopped receiving notifications.** Every other reason code does. This one means Pushly recognised two records as the same person and merged them — the person remains reachable under the surviving record, which will have a different `user.id`. Treat it as a change of identifier rather than as an opt-out; suppressing on it removes someone who is still subscribed.
{% endhint %}

`user_unsubscribed` covers both an opt-out through your own interface and a permission withdrawn at the browser or OS level. Those two are not distinguishable from one another.

A `push_subscription.created` event carries no `reason_code`. A first-time subscribe and a device becoming reachable again are both reported as a creation and are not distinguished.

Treat any `reason_code` you do not recognise the same way you treat `other` — see [Versioning](#versioning).

### Header Macros

Custom header values support Liquid template syntax for referencing stored domain secrets:

```
Authorization: Bearer {{ secret.analytics_token }}
```

Only secrets explicitly referenced in header templates are loaded. System-managed secrets (aliases starting with `_`) are not accessible in templates.

### Headers

Every delivery includes:

| Header                        | Description                                                  |
| ----------------------------- | ------------------------------------------------------------ |
| `X-Pushly-Webhook-Id`         | UUID identifying which webhook config produced this delivery |
| `X-Pushly-Event-Id`           | The unique event ID from the payload. Use for deduplication. |
| `X-Pushly-Signature`          | HMAC-SHA256 signature of the request body (see below)        |
| `X-Pushly-Delivery-Timestamp` | Unix timestamp (seconds) when the delivery was initiated     |
| `X-Pushly-Attempt-Number`     | 1-indexed attempt number                                     |
| `Content-Type`                | `application/json`                                           |

Reserved header names (`Content-Type`, `Host`, `Content-Length`, `Transfer-Encoding`, anything starting with `X-Pushly-`) cannot be overridden by custom headers in your webhook configuration.

### Signature Verification

Every delivery includes an `X-Pushly-Signature` header for authenticity verification.

Format: `sha256=<hex_digest>`

Verification algorithm:

1. Encode your signing secret as UTF-8 bytes
2. Compute HMAC-SHA256 over the raw request body using the secret as the key
3. Hex-encode the digest
4. Compare to the value after `sha256=` in the header using constant-time comparison

**Important:** Verify against the raw request body, not a parsed and re-serialized version. JSON key ordering and whitespace affect the digest.

**Replay prevention (optional):** Compare `X-Pushly-Delivery-Timestamp` to your server's current time. Reject deliveries older than your tolerance window (e.g. 5 minutes).

Your signing secret is available in **Domain Settings → Event Webhooks → Signing Secret**.

### Idempotency

Pushly produces one event per subscriber per action. Events may be delivered more than once if your endpoint fails or times out — use `event_id` for deduplication, and use a durable queue on your side if processing happens asynchronously after acknowledgment.

An `event_id` identifies a specific change, and stays the same across redeliveries of it.

### Delivery

#### Timeout

Your endpoint must respond within 5 seconds. Acknowledge immediately and process asynchronously if your logic takes longer.

#### Retries

| Attempt | Delay                           |
| ------- | ------------------------------- |
| 1       | Immediate                       |
| 2       | 4 seconds after first failure   |
| 3       | 16 seconds after second failure |

After 3 attempts, the event moves to a dead-letter queue.

Retried: HTTP 5xx, timeouts, connection errors. Not retried: HTTP 4xx, SSRF blocks.

#### Ordering

Events may arrive out of order. Use `event_timestamp` for chronological ordering, not delivery order.

This matters most for push subscription events, where a subscribe and an unsubscribe for the same device describe a sequence. `event_timestamp` has second precision, so a device that unsubscribes and re-subscribes within the same second cannot be ordered from the payload alone.

### Network Security

The primary authentication mechanism for webhook deliveries is HMAC signature verification (above). Every delivery includes an `X-Pushly-Signature` header you can validate against the request body using your signing secret. This is cryptographic proof the request came from Pushly — independent of network origin and resilient to infrastructure changes on our side.

#### IP Allowlisting

We don't publish a static list of egress IPs. If your security or compliance policy requires IP-based network controls, contact your Pushly account manager to discuss your requirements.

#### TLS

Webhook endpoints must be reachable over HTTPS. Pushly does not deliver to plain HTTP URLs. We require TLS 1.2 or higher and validate your endpoint's certificate against standard public certificate authorities. Self-signed certificates are not supported.

#### SSRF Protection

Pushly validates every destination URL against private and reserved IP ranges before connecting. Requests to private (RFC 1918), loopback, link-local, and instance-metadata addresses are rejected.

### Versioning

New fields and event types are added without incrementing `version`. Ignore unknown fields in your handlers.

Breaking changes (field removals or renames) increment `version` with a deprecation window.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://documentation.pushly.com/platform/event-webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
