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
- Generate a long random secret, for example with
openssl rand -hex 32. - In the PingOne admin console, go to Integrations > Webhooks, or use the subscriptions API.
- Set the HTTPS URL, and add a header such as
Authorization: Bearer <secret>. - Choose the
ACTIVITYformat andJSON_ARRAYpayloads. - Choose the audit events, and any application, population or tag filters.
- Leave certificate verification on.
Through the API, a subscription looks like this:
{
"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 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.
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:
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 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
200once 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
recordedAtand 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 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
maximumPayloadLimitto reduce batch sizes if your handler needs it. - Choose
JSON_ARRAYso the body is a single JSON document.
How Hookdeck Can Help: Hookdeck stores each batch with its full body. 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.
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.
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 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 for free and handle PingOne webhooks reliably in minutes.