API Reference
Webhooks

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 the webhooks:read / webhooks:write scopes.
  • /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 (admin and superadmin by default; custom roles need the settings_manage permission) - 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.

MethodPathDescription
GET/api/webhooksList endpoints for the org
POST/api/webhooksCreate an endpoint (returns the signing secret once)
PATCH/api/webhooks/:idUpdate url, the events (events or allEvents), or active
DELETE/api/webhooks/:idRemove an endpoint
POST/api/webhooks/:id/rotate-secretReplace the signing secret (returns the new one once)
POST/api/webhooks/:id/pingSend a test ping event
GET/api/webhooks/:id/deliveriesThe delivery log, newest first (page, limit, status)
GET/api/webhooks/:id/deliveries/:deliveryIdOne delivery, with its payload
POST/api/webhooks/:id/deliveries/:deliveryId/redeliverSend 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:

EventFired when
offboarding.createdAn 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_changedThe offboarding moves to another stage, from any source (the app, bulk actions, the API, automation rules or the schedule)
offboarding.completedThe offboarding moves to the completed stage
offboarding.deletedAn offboarding is deleted, on its own, in a bulk delete, or by a data retention policy
revocation.completedAfter 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.failedA revocation run left an app failed or needing removal by hand. Same data as revocation.completed
asset.returnedAn 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.overdueAn 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.completedA 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.signedA 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.completed to an endpoint subscribed to it (even if it also subscribes to offboarding.stage_changed), and as offboarding.stage_changed to one subscribed only to that
  • A revocation run that left an app failed or by hand goes as revocation.failed to an endpoint subscribed to it, and as revocation.completed to one subscribed only to that. So an endpoint subscribed to revocation.completed alone 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 also offboarding.stage_changed) and revocation.failed (not also revocation.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 above
  • timestamp - when PurpletGo sent it
  • type - PurpletGo's internal name for what happened. Route on event, not on this
  • title, body - a short human-readable summary
  • data - the event's fields:
Eventdata
offboarding.createdoffboardingId, employeeName, source (how it was started: app, clone, bulk, csv, hris, initiation, api, slack, scim or contract_expiry)
offboarding.stage_changed, offboarding.completedoffboardingId, employeeName, status, previousStatus, source (app, bulk, api, automation or schedule)
offboarding.deletedoffboardingId, source (app, bulk or retention). No name: these deletions are often made for privacy
revocation.completed, revocation.failedoffboardingId, 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.returnedassetId, itemName, serialNumber, category, employeeId (the holder's id in the employee directory), source (shipment, manual or bulk)
asset.overdueassetId, itemName, serialNumber, category, employeeId, returnDeadline (YYYY-MM-DD), daysOverdue
checklist.completedoffboardingId, employeeName, source (app, linked or it_ticket)
document.signeddocumentId, 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.

StatusMeaning
pendingRecorded, not yet tried (or sent again by hand)
failedThe last attempt failed; another is scheduled at next_attempt_at
deliveredYour endpoint answered 2xx
deadPurpletGo 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.


Related

  • API Keys - generating a key with webhooks:read/webhooks:write scopes
  • Audit Log - the full record, if your endpoint can be down for longer than the retry window