# Guide to PingOne Webhooks: Features and Best Practices

PingOne webhooks stream your identity platform's audit trail to another system: users are created or deleted, people sign in, passwords are reset. If your organisation runs identity on PingOne, webhooks, which PingOne calls subscriptions, are how those audit events reach your SIEM or your own services as they happen.

This guide covers how PingOne subscriptions work, the event and payload options, how to authenticate deliveries with a header you add, how PingOne retries and queues, and the practices that keep a PingOne receiver reliable.

## What are PingOne webhooks?

A PingOne subscription sends audit activity events to an HTTPS endpoint. You choose which audit events to include, optionally narrow them by application, population or tag, and choose the format and batching. PingOne then pushes matching events as they're recorded.

PingOne doesn't sign deliveries. Instead, a subscription has `httpEndpoint.headers`, "headers applied to the outbound request" whose purpose "is for the HTTPS endpoint to authenticate the PingOne service". You add a header with a secret value and your receiver compares it. PingOne also supports outbound mutual TLS with a client key, which authenticates at the connection rather than in a header.

## PingOne webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Integrations > Webhooks** in the admin console, or `POST /environments/{envID}/subscriptions` |
| Events | Any audit event in Audit Reporting Events, listed in `filterOptions.includedActionTypes` |
| Filters | Applications and populations (up to 10 IDs each), and tags |
| Formats | `ACTIVITY` (generic JSON), `SPLUNK` or `NEWRELIC` |
| Batching | `JSON_ARRAY` (optionally pretty-printed) or `ND_JSON`, up to 500 events or 4096 KB per request |
| Event identifier | `action.type`, such as `USER.CREATED` |
| Authentication | Headers you set on the subscription, or outbound mTLS. No signature |
| Retries | The current event is retried until it gets a `200` |
| Queueing | Events are lost after the target is down for more than 7 days. A disabled subscription keeps events for up to two weeks |
| Endpoint | HTTPS, IPv4 only |
| Source IPs | Not published |

## Common events

PingOne lets you subscribe to any event in its Audit Reporting Events reference, which lists hundreds of `RESOURCE.ACTION` types. Common choices include:

| `action.type` | Recorded when |
| --- | --- |
| `USER.CREATED`, `USER.UPDATED`, `USER.DELETED` | A user is created, updated or deleted |
| `AUTHENTICATION.CREATED` | An authentication is recorded |
| `PASSWORD.RESET`, `PASSWORD.CHECK_FAILED` | A password is reset, or a password check fails |
| `MFA_SETTINGS.UPDATED` | MFA settings change |
| `SUBSCRIPTION.CREATED`, `SUBSCRIPTION.UPDATED`, `SUBSCRIPTION.DELETED` | A subscription itself changes |

With the `ACTIVITY` format, each event is a JSON object whose attributes are listed in the reference. The ones most receivers use are:

| Attribute | Meaning |
| --- | --- |
| `id` | The audit event's UUID |
| `recordedAt`, `createdAt` | When the event was recorded and created, in ISO 8601 |
| `action.type`, `action.description` | The event type and a description |
| `actors.user`, `actors.client` | Who or what performed the action, with IDs and names |
| `resources` | What the action affected, with type, ID and name |
| `result.status` | `succeeded` or `failed` |
| `correlationId` | Ties together the messages in one transaction |
| `source.ipAddress`, `source.userAgent` | Present when `ipAddressExposed` or `userAgentExposed` is on |

A request holds a batch of events: a JSON array with `JSON_ARRAY`, or one JSON object per line with `ND_JSON`. PingOne doesn't publish a full delivered example, so record a real delivery before relying on exact nesting. The `SPLUNK` and `NEWRELIC` formats wrap events differently and aren't described in detail.

## Setting up PingOne webhooks

1. Generate a long random secret, for example with `openssl rand -hex 32`.
2. In the PingOne admin console, go to **Integrations > Webhooks**, or use the subscriptions API.
3. Set the HTTPS URL, and add a header such as `Authorization: Bearer <secret>`.
4. Choose the `ACTIVITY` format and `JSON_ARRAY` payloads.
5. Choose the audit events, and any application, population or tag filters.
6. Leave certificate verification on.

Through the API, a subscription looks like this:

```json
{
  "name": "Audit events to Hookdeck",
  "enabled": true,
  "format": "ACTIVITY",
  "httpEndpoint": {
    "url": "https://example.com/webhooks/pingone",
    "headers": { "Authorization": "Bearer YOUR_LONG_RANDOM_SECRET" }
  },
  "filterOptions": {
    "includedActionTypes": ["USER.CREATED", "USER.UPDATED", "USER.DELETED"]
  },
  "payloadOptions": {
    "payloadFormat": { "https": { "format": "JSON_ARRAY" } }
  },
  "verifyTlsCertificates": true
}

```

PingOne's own header example is `"Authorization": "Basic username:password"`, with the credentials not base64-encoded. Whatever you type is what PingOne sends, so a value like that isn't a valid HTTP Basic header. A Bearer-style random value avoids the confusion.

Use an IPv4 endpoint: PingOne says IPv6 addresses aren't fully supported. If `verifyTlsCertificates` is on and your endpoint uses a private CA or a self-signed certificate, upload it to PingOne's certificate service before creating the subscription. Turning verification off trusts every certificate, which PingOne flags as a security risk.

**In Hookdeck.** Create a source with the PingOne type, enter the header name in **API Key Header** and paste the exact header value you set on the subscription into **API Key**, prefix included. Use the source URL as `httpEndpoint.url`. Hookdeck matches the name case-insensitively and the value exactly. The source accepts `POST`. It doesn't check mutual TLS, so authenticate with a header when you route through Hookdeck.

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

## Securing PingOne webhooks

Look up your header and compare its whole value with the secret in constant time, then parse the batch.

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

const app = 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);
}

function parseEvents(text) {
  const trimmed = text.trim();
  if (trimmed.startsWith('[')) return JSON.parse(trimmed); // JSON_ARRAY
  return trimmed.split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line)); // ND_JSON
}

app.post('/webhooks/pingone', express.raw({ type: '*/*', limit: '5mb' }), (req, res) => {
  const secret = process.env.PINGONE_WEBHOOK_SECRET; // the exact header value
  if (!secret) return res.sendStatus(500); // fail closed when unconfigured

  const name = process.env.PINGONE_WEBHOOK_HEADER || 'Authorization';
  const received = req.get(name); // case-insensitive lookup
  if (typeof received !== 'string' || !safeEqual(received, secret)) {
    return res.sendStatus(401);
  }

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

  // Store the batch, keyed on each event's id, then acknowledge.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_pingone_request(headers) -> bool:
    secret = os.environ.get("PINGONE_WEBHOOK_SECRET")
    if not secret:
        return False  # fail closed
    name = os.environ.get("PINGONE_WEBHOOK_HEADER", "Authorization")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A matching header shows the sender knows the value. It doesn't prove the events are unchanged, and a captured request can be replayed, since there's no signature. For audit data that feeds security decisions, use HTTPS, a long random value, rotate it if it may have leaked, and deduplicate on each event's `id`.

> **Make PingOne webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your header on every PingOne delivery, answers straight away so the stream never stalls on your service, and keeps every batch for replay.

## PingOne webhook limitations and pain points

### One failing event blocks the stream

**The Problem:** A single delivery your endpoint rejects holds up every event behind it.

**Why It Happens:** "The webhook continues to retry the current event until a 200 response is returned. It doesn't move on to the next event until a 200 response has returned or the event has expired from Kafka." Only a `200` is described as moving the stream on.

**Workarounds:**

* Return `200` once a batch is stored, even if some events in it fail later processing.
* Never return an error for an event type you don't recognise.
* Watch for a growing gap between `recordedAt` and the time events arrive.

**How Hookdeck Can Help:** Hookdeck answers PingOne with a `200` as soon as each request arrives, so the stream keeps moving. A failure in your service becomes a failed delivery in Hookdeck that you can [retry](/docs/retries) or replay, without blocking newer events.

### Seven days, then events are lost

**The Problem:** A long outage at your end loses audit history.

**Why It Happens:** "If the webhook target is down for longer than 7 days, PingOne starts to lose events." A disabled subscription keeps events for up to two weeks and delivers them when re-enabled, but that only helps if you disable it before the outage.

**Workarounds:**

* Disable the subscription ahead of planned maintenance, and re-enable it within two weeks.
* Backfill gaps from PingOne's audit reporting if you need complete history.

**How Hookdeck Can Help:** With Hookdeck accepting every request, your endpoint's downtime no longer counts against PingOne's 7-day window. Events wait in Hookdeck until your service is back.

### Batches, not single events

**The Problem:** A handler that expects one event per request misses most of them.

**Why It Happens:** A request carries up to 500 events by default, as a JSON array or NDJSON lines.

**Workarounds:**

* Always iterate. Use `maximumPayloadLimit` to reduce batch sizes if your handler needs it.
* Choose `JSON_ARRAY` so the body is a single JSON document.

**How Hookdeck Can Help:** Hookdeck stores each batch with its full body. [Transformations](/docs/transformations) can reshape a batch before delivery, and filters can match on fields inside it.

### A static header, not a signature

**The Problem:** Anyone who learns the header value can inject fake audit events into your SIEM.

**Why It Happens:** `httpEndpoint.headers` are fixed values. Nothing is computed over the body.

**Workarounds:**

* Use a long random value, HTTPS only, and rotate it on a schedule.
* For high-assurance cases, use outbound mTLS directly to your own endpoint.

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

## Best practices

### Subscribe to what you use

`includedActionTypes` is required and must not be empty. Listing only the events you process keeps batches small, and leaves fewer events that can hold up the stream.

### Deduplicate on `id`

Retries can resend events. Each audit event has a UUID `id`. Store it and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Check `result.status`

Many audit events record failures as well as successes. Branch on `result.status` before treating an event as something that happened.

### Acknowledge fast and process asynchronously

Store the batch, respond with `200`, then process. Because PingOne won't move on until it gets a `200`, a slow handler delays every event after it. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

PingOne subscriptions push batches of audit events, identified by `action.type`, with headers you set as the only in-request credential. Compare your header in constant time over HTTPS, use the `ACTIVITY` format with `JSON_ARRAY`, iterate over every event, deduplicate on `id`, and return `200` quickly, because one stuck event holds up the rest.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the header, acknowledges every batch straight away so the stream keeps flowing, and holds each one until your service has processed it.

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