Platform
Webhooks
A webhook tells your application that something changed on the other side of a customer’s identity system — a person joined, a group lost a member, a connection went live. Register an endpoint, verify the signature, and treat every delivery as something you may see twice.
Overview
Endpoints are registered per environment, either in the dashboard or through the API. Each endpoint has its own signing secret and its own subscription list — you can send directory events to one service and session events to another.
1curl -X POST https://api.paycux.com/webhooks \2 -H "Authorization: Bearer sk_example_123456789" \3 -H "Content-Type: application/json" \4 -d '{5 "endpoint_url": "https://api.yourapp.example/paycux/webhook",6 "events": ["dsync.user.created", "dsync.user.deleted", "dsync.group.user_added"]7 }'
- Your endpoint must answer with a 2xx status within 10 seconds
- Acknowledge first, process afterwards — slow handlers cause retries, not lost work
- A staging endpoint never receives production events, and the reverse
Event types
Subscribe only to what you handle. An endpoint that receives events it ignores is an endpoint that will one day time out on a busy sync.
Directory Sync
Emitted when a customer's directory changes. This is the group most applications subscribe to first.
| dsync.activated | A directory finished its first successful sync |
| dsync.deactivated | A directory was disconnected or its credentials expired |
| dsync.user.created | A person was added to the directory |
| dsync.user.updated | A profile, job title or custom attribute changed |
| dsync.user.deleted | A person was removed or deprovisioned |
| dsync.group.created | A group appeared in the directory |
| dsync.group.updated | A group was renamed or its attributes changed |
| dsync.group.deleted | A group was removed |
| dsync.group.user_added | Someone joined a group — often your signal to grant access |
| dsync.group.user_removed | Someone left a group — often your signal to revoke access |
Connections
Emitted while a customer sets up single sign-on, usually through the Admin Portal.
| connection.activated | A connection completed setup and can be used to sign in |
| connection.deactivated | A connection was disabled by you or by the customer |
| connection.deleted | A connection was removed entirely |
Users and organizations
Emitted by user management, whether the change came from your API calls or from a self-serve action.
| user.created | A user record was created |
| user.updated | Profile fields or verification state changed |
| user.deleted | A user was deleted along with their sessions |
| organization.created | A tenant was created |
| organization.updated | A tenant was renamed or its domains changed |
| organization_membership.created | Someone was added to a tenant |
| organization_membership.updated | A membership role changed |
| organization_membership.deleted | Someone was removed from a tenant |
Sessions and risk
Emitted around authentication itself. Useful for security tooling and for your own audit trail.
| session.created | A sign-in succeeded and a session was issued |
| session.revoked | A session was ended by sign-out, expiry or an administrator |
| authentication.failed | A sign-in attempt was rejected, with the reason |
| radar.risk_detected | A request was flagged or blocked by risk signals |
Delivery payload
Every delivery has the same envelope: an id, an event name, a timestamp and the object the event is about. The object matches the shape you would get from the corresponding endpoint in the API reference.
1{2 "id": "event_01HQ8ZK3M4N5P6R7S8T9V0W1X2",3 "event": "dsync.group.user_added",4 "created_at": "2026-06-18T08:14:03.220Z",5 "data": {6 "object": "directory_user",7 "id": "directory_user_01HQ8ZK3M4N5P6R7S8T9V0W1X2",8 "directory_id": "directory_01HQ8ZK3M4N5P6R7S8T9V0W1X2",9 "organization_id": "org_01HQ8ZK3M4N5P6R7S8T9V0W1X2",10 "emails": [{ "primary": true, "value": "alan@foo-corp.example" }],11 "group": { "id": "directory_group_01HQ8ZK3M4N5P6R7S8T9V0W1X2", "name": "Engineering" },12 "state": "active"13 }14}
Verifying signatures
The Paycux-Signature header carries a timestamp and an HMAC-SHA256 of timestamp + "." + rawBody, keyed with the endpoint’s signing secret. Verify it against the raw request body — parsing the JSON first and re-serialising it will change the bytes and break the comparison.
1import express from 'express';2import { paycux } from './lib/paycux';34const app = express();56// raw body, not the JSON parser7app.post('/paycux/webhook', express.raw({ type: 'application/json' }), (req, res) => {8 let event;910 try {11 event = paycux.webhooks.constructEvent({12 payload: req.body,13 signature: req.headers['paycux-signature'],14 secret: process.env.PAYCUX_WEBHOOK_SECRET,15 tolerance: 300,16 });17 } catch {18 return res.status(400).send('invalid signature');19 }2021 // acknowledge first, work afterwards22 res.sendStatus(202);23 queue.enqueue(event);24});
The signing secret is shown once when the endpoint is created and can be rotated at any time. During a rotation both secrets are accepted for one hour, so you can deploy the new value without dropping a delivery.
Retries and ordering
A delivery is successful when your endpoint answers 2xx within ten seconds. Anything else — a 5xx, a timeout, a connection reset — is retried with exponential backoff and jitter across roughly three days.
| Attempt | Delay after previous | Elapsed |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 30 seconds | 30 seconds |
| 3 | 5 minutes | ~6 minutes |
| 4 | 30 minutes | ~36 minutes |
| 5 | 2 hours | ~3 hours |
| 6 | 8 hours | ~11 hours |
| 7 | 24 hours | ~35 hours |
| 8 | 36 hours | ~3 days |
After the last attempt the endpoint is marked degraded and you are notified. The events are not lost: read them back from GET /events and replay at your own pace.
Ordering is not guaranteed. A retry can arrive after a newer event. Compare created_at, or refetch the object from the API, before overwriting state you already hold.
Idempotency
Delivery is at least once. A network hiccup after your handler committed but before the response reached us produces a duplicate, and duplicates are normal rather than exceptional. Key your processing on Paycux-Event-Id.
1const eventId = request.headers['paycux-event-id'];23// unique index on event_id turns a duplicate into a no-op4const inserted = await db.processedEvents.insertIfAbsent(eventId);5if (!inserted) return reply.status(202).send();67await applyEvent(event);
Testing locally
Every endpoint has a delivery log in the dashboard with the exact bytes we sent, the status we got back and a replay button. For local work, expose your development server over a tunnel and register that URL in your staging environment.
- Replay any past delivery from the dashboard without touching production data
- Send a synthetic test event for any type you subscribe to
- Delivery logs keep the request and response bodies for 30 days
Where events come from
Most webhook traffic originates in a customer’s directory. If you are still deciding what to subscribe to, start from what Directory Sync produces.