Webhooks
PurpletGo can POST event notifications to any HTTPS endpoint you configure. There are two ways to manage webhook endpoints, and it's worth knowing which one you're using:
/api/v1/webhooks- the public API-key surface. Use this if you're integrating from outside PurpletGo. Requires a Bearer API key with thewebhooks:read/webhooks:writescopes./api/webhooks- the same underlying delivery mechanism, but authenticated by browser session (cookie) rather than an API key, and restricted to members who can manage organisation settings (adminandsuperadminby default; custom roles need thesettings_managepermission) - listing included. This is what the PurpletGo dashboard itself calls, and it's the only place you can update an endpoint (including resuming a paused one), rotate its signing secret, or send a manual test ping. The API-key surface supports list, create and delete, and the delivery log with Redeliver.
Both require the webhooks feature on your plan (Team and Enterprise).
Managing webhooks with an API key
List webhooks
GET /api/v1/webhooks
Required scope: webhooks:read
Response 200 OK
[
{
"id": "3f1b2c4a-...",
"url": "https://your-app.com/purpletgo-events",
"events": ["offboarding.completed", "asset.returned"],
"active": true,
"created_at": "2024-03-01T00:00:00Z"
}
]Create a webhook
POST /api/v1/webhooks
Required scope: webhooks:write
Request body
{
"url": "https://your-app.com/purpletgo-events",
"events": ["offboarding.completed", "offboarding.stage_changed"]
}url must be HTTPS and resolve to a public host - PurpletGo rejects loopback, private-network, and link-local addresses. events is required and must be a non-empty array (up to 50) of names from the event types list; any other name is rejected with 400. Subscribing also needs the read scope for the event's data, or the request is rejected with 403: offboardings:read for the offboarding.*, revocation.*, checklist.completed and document.signed events, and employees:read for the asset.* events.
Response 201 Created
{
"id": "3f1b2c4a-...",
"url": "https://your-app.com/purpletgo-events",
"events": ["offboarding.completed", "offboarding.stage_changed"],
"active": true,
"created_at": "2024-03-01T00:00:00Z",
"secret": "whsec_a1b2c3d4..."
}secret is returned only in this response. Store it - you'll need it to verify the X-PurpletGo-Signature header on incoming deliveries.
Delete a webhook
DELETE /api/v1/webhooks/:id
Required scope: webhooks:write
Response 200 OK - { "message": "Webhook deleted" }
List a webhook's deliveries
GET /api/v1/webhooks/:id/deliveries
Required scope: webhooks:read
:id is the webhook's id as listed above, or the endpoint's id as the dashboard shows it. Newest first, paginated with page (from 1) and limit (1 to 100, default 20). Add status (pending, failed, delivered or dead) to show only those.
Response 200 OK
{
"data": [
{
"id": "9b2e...",
"event": "offboarding.completed",
"delivery_id": "4f6a...",
"status": "failed",
"attempts": 2,
"last_status_code": 503,
"last_error": "Endpoint responded with HTTP 503",
"next_attempt_at": "2026-10-14T10:36:00.000Z",
"created_at": "2026-10-14T10:00:00.000Z",
"last_attempt_at": "2026-10-14T10:06:00.000Z",
"delivered_at": null
}
],
"meta": { "page": 1, "limit": 20, "total": 1, "total_pages": 1 }
}delivery_id is the X-PurpletGo-Delivery header value your endpoint received. The list leaves out the payload; get one delivery to see it.
Get one delivery
GET /api/v1/webhooks/:id/deliveries/:deliveryId
Required scope: webhooks:read
:deliveryId is the delivery's id or its delivery_id (the header value). The response is the delivery as listed above, plus payload: the body exactly as it was sent.
Redeliver
POST /api/v1/webhooks/:id/deliveries/:deliveryId/redeliver
Required scope: webhooks:write
Sends a failed or dead delivery again: it goes back to pending with its attempts reset, under the same X-PurpletGo-Delivery ID, and the first attempt is made straight away. The response is the delivery, now pending. A pending or delivered delivery, or one whose endpoint is paused, is refused with 409. Each redelivery is recorded in the audit log (webhook.delivery_redelivered).
Managing webhooks from the dashboard session
These mirror the API-key endpoints above but live under /api/webhooks, use your logged-in session, and require the settings_manage permission (admin or superadmin by default) for every call, including the list.
| Method | Path | Description |
|---|---|---|
GET | /api/webhooks | List endpoints for the org |
POST | /api/webhooks | Create an endpoint (returns the signing secret once) |
PATCH | /api/webhooks/:id | Update url, the events (events or allEvents), or active |
DELETE | /api/webhooks/:id | Remove an endpoint |
POST | /api/webhooks/:id/rotate-secret | Replace the signing secret (returns the new one once) |
POST | /api/webhooks/:id/ping | Send a test ping event |
GET | /api/webhooks/:id/deliveries | The delivery log, newest first (page, limit, status) |
GET | /api/webhooks/:id/deliveries/:deliveryId | One delivery, with its payload |
POST | /api/webhooks/:id/deliveries/:deliveryId/redeliver | Send a failed or dead delivery again |
Choose what an endpoint receives in one of two ways: events, a non-empty array of names from the event types list, or allEvents: true for All events (every event in the list, including ones added later). Anything else - a single string, an object, an empty array, or both at once - is rejected with 400, so unticking an endpoint's last event never widens it to all events. An endpoint set to all events is listed with events: []. active must be true or false; setting it to true also clears an automatic pause.
GET /api/webhooks lists each endpoint's health with it: last_delivered_at (when a delivery last arrived), failures_24h (deliveries that failed or were given up on in the last 24 hours), consecutive_dead (deliveries given up on since the last one that arrived), and disabled_at and disabled_reason (consecutive_failures) when PurpletGo paused the endpoint itself.
Creating, updating, deleting and rotating the secret of an endpoint are recorded in the audit log (webhook.created, webhook.updated, webhook.deleted, webhook.secret_rotated), and so are a redelivery (webhook.delivery_redelivered) and an automatic pause (webhook.auto_disabled). The secret itself is never logged, and only the host of an endpoint's URL is.
Rotate the signing secret
POST /api/webhooks/:id/rotate-secret
Generates a new signing secret for the endpoint. The previous secret stops working immediately, so update your receiver straight away. In the dashboard, use the rotate button next to the endpoint.
Response 200 OK
{ "id": "3f1b2c4a-...", "secret": "9c1d0e..." }Like the secret returned on creation, the new secret is returned only in this response.
Ping a webhook
POST /api/webhooks/:id/ping
Sends a signed test payload ({ "event": "ping", "timestamp": "..." }), with the same headers as a real delivery, to the configured URL so you can confirm connectivity and signature verification before relying on real events.
Response 200 OK
{ "success": true, "status": 200 }If your endpoint answers with a non-2xx status you'll get { "success": false, "status": 500, "error": "Endpoint responded with HTTP 500" }. If the request fails outright (DNS, timeout, connection refused), you'll get { "success": false, "error": "Webhook delivery failed" } instead of an HTTP error.
PurpletGo checks the endpoint's address again every time it sends - a ping or a real delivery - and refuses to contact a host that now resolves to a private, loopback or link-local address. It also never follows redirects: an endpoint that answers 3xx counts as a failed delivery (the ping reports it as a redirect). Point the webhook at the final URL.
A ping is not a delivery: it is not retried and does not appear in the delivery log.
Event types
Every event PurpletGo can deliver belongs to this fixed set:
| Event | Fired when |
|---|---|
offboarding.created | An offboarding is created, whichever way: the New Offboarding form, copying one, bulk offboarding, CSV import, HRIS sync, an approved initiation, the API, Slack, SCIM or a contract expiry. One event per offboarding |
offboarding.stage_changed | The offboarding moves to another stage, from any source (the app, bulk actions, the API, automation rules or the schedule) |
offboarding.completed | The offboarding moves to the completed stage |
offboarding.deleted | An offboarding is deleted, on its own, in a bulk delete, or by a data retention policy |
revocation.completed | After every access revocation run, whether someone clicked Nuke Access or it ran at the end of the last day. Carries each app's result |
revocation.failed | A revocation run left an app failed or needing removal by hand. Same data as revocation.completed |
asset.returned | An asset changes to returned: marked returned on its own or in bulk, or in a return that is marked received. One delivery per asset; an asset that was already returned sends nothing |
asset.overdue | An asset still out passes its return deadline: once per deadline, the night after it passes, on your organisation's calendar. A new deadline is reported again |
checklist.completed | A change closes the last open checklist task on an offboarding: ticked or marked not needed in the app, or closed by its linked record or a resolved IT ticket |
document.signed | A compliance document changes to signed, through an e-signature provider or marked signed by hand |
audit.created is no longer offered: to stream the audit log, use SIEM export.
Both management surfaces check events against this list and reject any other name with 400. All events is a choice of its own, made in the dashboard (allEvents: true on POST/PATCH /api/webhooks); the API-key surface always takes a list of at least one event.
One delivery per event
Each endpoint gets one delivery per event, named the most specific way it subscribes to:
- A completed offboarding goes as
offboarding.completedto an endpoint subscribed to it (even if it also subscribes tooffboarding.stage_changed), and asoffboarding.stage_changedto one subscribed only to that - A revocation run that left an app failed or by hand goes as
revocation.failedto an endpoint subscribed to it, and asrevocation.completedto one subscribed only to that. So an endpoint subscribed torevocation.completedalone gets every run, failed ones included - An endpoint set to All events receives every event in the list, and only those, named the most specific way:
offboarding.completed(not alsooffboarding.stage_changed) andrevocation.failed(not alsorevocation.completed)
Payload
Every delivery is a JSON object with the same top-level fields:
{
"event": "revocation.failed",
"timestamp": "2026-10-11T00:05:12.000Z",
"type": "revocation_completed",
"title": "Access revocation needs attention",
"body": "Jordan Lee: 1 revoked, 0 skipped, 1 failed, 0 to remove by hand",
"data": {
"offboardingId": "6b1e...",
"employeeName": "Jordan Lee",
"source": "schedule",
"counts": { "revoked": 1, "failed": 1, "manualRequired": 0, "skipped": 0 },
"results": [
{ "service": "Google Workspace", "outcome": "revoked", "message": "...", "vendorStatus": null, "verified": true },
{ "service": "Okta", "outcome": "failed", "message": "Okta revocation failed (HTTP 403): Insufficient permissions", "vendorStatus": 403, "verified": false }
]
}
}event- the event name, as in the list abovetimestamp- when PurpletGo sent ittype- PurpletGo's internal name for what happened. Route onevent, not on thistitle,body- a short human-readable summarydata- the event's fields:
| Event | data |
|---|---|
offboarding.created | offboardingId, employeeName, source (how it was started: app, clone, bulk, csv, hris, initiation, api, slack, scim or contract_expiry) |
offboarding.stage_changed, offboarding.completed | offboardingId, employeeName, status, previousStatus, source (app, bulk, api, automation or schedule) |
offboarding.deleted | offboardingId, source (app, bulk or retention). No name: these deletions are often made for privacy |
revocation.completed, revocation.failed | offboardingId, employeeName, source (app or schedule), counts (revoked, failed, manualRequired, skipped), results (one per app: service, outcome, message, vendorStatus - the vendor's HTTP status when it refused, otherwise null - and verified) |
asset.returned | assetId, itemName, serialNumber, category, employeeId (the holder's id in the employee directory), source (shipment, manual or bulk) |
asset.overdue | assetId, itemName, serialNumber, category, employeeId, returnDeadline (YYYY-MM-DD), daysOverdue |
checklist.completed | offboardingId, employeeName, source (app, linked or it_ticket) |
document.signed | documentId, offboardingId, documentName, documentType, source (provider or manual), provider (docusign, adobe_sign, dropbox_sign, or null when marked signed by hand) |
A revocation message never includes the vendor's raw response. It gives the vendor's HTTP status and reason (cut to 300 characters, with anything that looks like a secret removed), or says the vendor didn't answer in time or couldn't be reached. An error inside PurpletGo, such as a database or storage error, gives no detail, only <app> revocation failed. A run stopped by its time limit still sends its event: an app whose call it cut off ends stopped: the run reached its time limit, and an app it never reached is failed with a message starting Not attempted.
Verifying signatures
Every request carries an X-PurpletGo-Signature header of the form sha256=<hex digest>, computed as an HMAC-SHA256 of the raw request body using your endpoint's secret. There's also an X-PurpletGo-Event header with the event name and an X-PurpletGo-Delivery header with a UUID for the delivery. The delivery ID is the same on every retry of one delivery, so you can drop a retry you've already processed.
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
app.post('/purpletgo-events', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-purpletgo-signature'];
if (!sig || !verifyWebhook(req.body, sig, process.env.PURPLETGO_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// handle event...
res.json({ received: true });
});Retry policy
Every delivery is recorded before it is sent, so a restart of PurpletGo never loses one: a delivery waiting for its next attempt is picked up again when the servers are back.
If a delivery doesn't get a 2xx response within 10 seconds (or the request errors out), PurpletGo tries again, up to 7 attempts in all, waiting 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and then 24 hours between them: about a day and a half from the first attempt to the last. Every attempt carries the same X-PurpletGo-Delivery ID, so drop a delivery you've already processed.
After the seventh failed attempt the delivery is dead: PurpletGo stops trying, and it stays in the delivery log, where you can send it again. A redirect, or an address that is no longer public, is dead at once: retrying can't change the answer.
Deliveries are not guaranteed to arrive in the order the events happened, particularly once one has been retried. Use the payload's timestamp to order them.
Delivery log
Each endpoint keeps a log of its deliveries: in the dashboard under Settings → Webhooks (the arrow next to an endpoint), or through GET /api/webhooks/:id/deliveries and GET /api/v1/webhooks/:id/deliveries. Each delivery shows its event, its status, how many attempts were made, the last HTTP status and error, and when the next attempt is due.
| Status | Meaning |
|---|---|
pending | Recorded, not yet tried (or sent again by hand) |
failed | The last attempt failed; another is scheduled at next_attempt_at |
delivered | Your endpoint answered 2xx |
dead | PurpletGo gave up. Send it again with Redeliver once your endpoint is fixed |
Redeliver (POST .../deliveries/:deliveryId/redeliver) sends a failed or dead delivery again, starting the schedule afresh, under the same X-PurpletGo-Delivery ID. It is recorded in the audit log.
Delivered deliveries are kept for 30 days and dead ones for 90 days, then deleted. Deleting an endpoint deletes its log.
Automatic pause
When 25 deliveries in a row to one endpoint are dead, PurpletGo pauses the endpoint, so it stops queuing events nobody receives. Super Admins and Admins are told in the app and by email, the pause is recorded in the audit log (webhook.auto_disabled), and the endpoint shows Paused by PurpletGo in the dashboard. A delivery that arrives resets the count.
Fix the endpoint, then resume it: the resume button in the dashboard, or PATCH /api/webhooks/:id with { "active": true }. Resuming starts the count again from zero. Events that happened while it was paused are not sent; the deliveries given up on before the pause are in the log and can be sent again.