Crew Pass public API
The Crew Pass API lets your systems read events, rosters, readiness, workers, certificates and the competency register, and import roster rows, without opening the dashboard. It also supports outbound webhooks so you can react to changes in real time instead of polling.
Create an API key at Settings → API and webhooks (company owners only). The machine-readable schema is always available at /api/v1/openapi.json, generated directly from the same route list this page is written from.
Authentication
Every request needs an Authorization header with your API key as a bearer token:
curl https://induct.nirvu.io/api/v1/events \
-H "Authorization: Bearer ik_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
A missing, malformed or revoked key returns 401. A key that does not have the scope a route requires returns 403. Keys are scoped at creation, so give an integration only the scopes it needs:
read:eventsread:rosterread:readinessread:workersread:competenciesread:certificateswrite:roster
Rate limits
Each key is limited to 600 requests per minute. A request over the limit returns 429 with a Retry-After header (seconds).
Errors
Every error response uses the same shape:
{ "error": { "code": "not_found", "message": "Event not found" } }
code is one of unauthorized, forbidden, not_found, invalid_request, rate_limited, or internal_error. A resource that exists but belongs to another company also returns 404. The API never reveals whether a foreign id exists.
Pagination
List endpoints are cursor-paginated. Pass ?limit= (1-100, default 25) and, for a later page, ?cursor= with the previous response's nextCursor:
{ "data": [ ], "nextCursor": "clx1a2b3c4d5e6f7g8h9i0j1" }
nextCursor is null on the last page.
Endpoints
GET /api/v1/events
Cursor-paginated list of your company's events, newest first. Scope: read:events.
{
"data": [
{ "id": "evt_123", "name": "Grand Final Parade 2026", "slug": "grand-final-parade-2026", "status": "PLANNED", "startsAt": "2026-09-26T00:00:00.000Z", "endsAt": "2026-09-26T23:00:00.000Z" }
],
"nextCursor": null
}
GET /api/v1/events/{id}
One event with its zones and assigned modules (event-level and per-zone). Scope: read:events.
{
"id": "evt_123",
"name": "Grand Final Parade 2026",
"zones": [{ "id": "zone_1", "name": "Chute", "order": 1 }],
"modules": [{ "courseId": "course_1", "title": "Site safety induction", "zoneId": null }]
}
GET /api/v1/events/{id}/roster
Cursor-paginated list of workers rostered on this event. Scope: read:roster.
POST /api/v1/events/{id}/roster
Adds or updates roster rows for this event, using the exact same row validation and auto-assignment logic as the dashboard's CSV import. zone is matched case-insensitively against the event's real zones; an unknown zone or invalid email is reported per row. Scope: write:roster.
Each row also accepts optional licenceNumber/licenceState/licenceClass (multi-licence UI + API surfaces slice), mirroring the dashboard CSV's trailing licence_number,licence_state, licence_class columns — a non-blank licenceNumber adds a licence for the matched worker, additive to any licences they already hold. A repeated email across multiple rows adds one licence per row, rather than the later row overwriting the earlier one's.
curl -X POST https://induct.nirvu.io/api/v1/events/evt_123/roster \
-H "Authorization: Bearer ik_live_..." \
-H "Content-Type: application/json" \
-d '{"rows":[{"name":"Alex Nguyen","email":"[email protected]","mobile":"0412345678","zone":"Chute","licenceNumber":"SEC-100001","licenceState":"VIC"}]}'
{ "rosterImportId": "ri_123", "createdCount": 1, "updatedCount": 0, "errorCount": 0, "licencesAddedCount": 1 }
GET /api/v1/events/{id}/readiness
The same readiness engine the dashboard's Readiness tab uses, per rostered worker. Scope: read:readiness. Carries an ETag; send it back as If-None-Match on a later request and get a bodyless 304 if nothing has changed. That's cheaper than re-downloading the full payload on every poll.
GET /api/v1/workers
Cursor-paginated list of workers. Add ?search= to match against name or email. Scope: read:workers.
GET /api/v1/workers/{id}
One worker's module history, certificates and competency register. Scope: read:workers.
The response's existing competencies[] array is unchanged. A worker who holds more than one security licence (e.g. two states) also gets licences[], one entry per WorkerCompetency record, additive and safe to ignore if you only need the summary shape:
{ "workerCompetencyId": "wc_1", "competencyTypeId": "type_1", "isPrimary": true, "issuingState": "VIC", "licenceClass": "Crowd Controller", "verificationMethod": "AUTOMATED_REGISTER", "checkResult": "VALID" }
verificationMethod is one of AUTOMATED_REGISTER, MANUAL_REGISTER_LINK, EVIDENCE_ONLY (the legacy internal values MANUAL_REGISTER/DOCUMENT_ONLY are reported under these public names).
GET /api/v1/certificates/{number}
Verifies a certificate number, scoped to your own company. Scope: read:certificates. state is one of VALID, EXPIRED, REVOKED, or SUPERSEDED (the course was republished with "require re-completion" and a newer version now exists — not a currently valid pass).
{ "certNumber": "NC-2026-000001", "state": "VALID", "workerName": "Alex Nguyen", "courseTitle": "Site safety induction", "issuedAt": "2026-08-01T00:00:00.000Z", "expiresAt": null, "courseVersion": 1 }
GET /api/v1/competencies
Cursor-paginated list of worker competency register records. Scope: read:competencies.
Each data[] item now also carries issuingState, licenceClass, verificationMethod (see above for its value mapping), checkResult, and isPrimary — additive fields alongside the existing shape, present for every record whether or not the worker holds more than one licence.
Zapier-ready endpoints
These four endpoints exist specifically to back a Zapier-style integration (or any tool that wants to discover your account and manage its own webhook subscriptions) without an OAuth flow — create an API key with the manage:webhooks scope and use it like any other key.
GET /api/v1/me
Identifies the calling key: your company, and the key's own name/prefix/scopes. No scope required beyond a valid key.
{ "company": { "id": "co_1", "name": "Northlight Studios", "slug": "northlight" }, "key": { "name": "Zapier", "prefix": "ik_live_ab12", "scopes": ["read:events", "manage:webhooks"] } }
POST /api/v1/hooks
Creates a webhook endpoint subscribed to one event (the Zapier "REST Hook" subscribe convention — one endpoint per event, call it once per trigger your Zap uses). Scope: manage:webhooks. The targetUrl is validated with the same public-HTTPS/SSRF checks as every other webhook endpoint (see Webhooks below). A signing secret is generated and returned once, at creation, the same "shown once" policy as every other secret in this API.
curl -X POST https://induct.nirvu.io/api/v1/hooks \
-H "Authorization: Bearer ik_live_..." \
-H "Content-Type: application/json" \
-d '{"targetUrl":"https://hooks.zapier.com/hooks/catch/123/abc","event":"certificate.issued"}'
{ "id": "wh_123" }
DELETE /api/v1/hooks/{id}
Deletes a webhook endpoint this same API key created. An id that doesn't exist, belongs to another company, or was created manually in Settings → API and webhooks (rather than through this endpoint) returns 404 — a key can only ever manage the endpoints it created itself. Scope: manage:webhooks.
GET /api/v1/hooks/samples/{event}
Up to three of the most recent real payloads Crew Pass has sent for this event (falling back to one static example if none have fired yet) — handy for Zapier's "test trigger" step, which needs a sample payload to let someone build the rest of their Zap without waiting for a real event. Scope: manage:webhooks.
[
{ "id": "whd_123", "event": "certificate.issued", "createdAt": "2026-09-08T00:00:00.000Z", "data": { "companyId": "co_1", "certificateId": "cert_1" } }
]
Webhooks
Add an endpoint at Settings → API and webhooks and choose which events it should receive. The endpoint URL must use https:// (a plain http:// URL is only accepted when it points at localhost/127.0.0.1, for testing against a receiver running on your own machine). Every event carries a companyId and is delivered as an HTTP POST with a JSON body:
{ "id": "whd_123", "event": "competency.reviewed", "createdAt": "2026-09-08T00:00:00.000Z", "data": { "companyId": "co_1", "workerCompetencyId": "wc_1", "workerId": "worker_1", "decision": "APPROVED" } }
Events you can subscribe to:
readiness.changed: a worker's readiness status for an event changed (debounced to at most
once per minute per worker+event).
competency.submitted: a worker submitted a licence/qualification for review.competency.reviewed: an admin approved or rejected a submitted licence/qualification.competency.expiring: an approved licence/qualification is approaching its expiry reminder
threshold.
certificate.issued: a worker passed a course and a certificate was issued.certificate.expiring: a certificate is approaching its expiry reminder threshold.attempt.locked: a worker exhausted their attempts on a course and is locked out.roster.imported: a CSV or API roster import completed for an event.incident.created: an incident report was submitted for an event.form.submitted: a worker or the public submitted a filled-in form (incident reports also fire
their own incident.created event).
licence.flagged: a worker's security licence check came back SUSPENDED or NOT_FOUND on the
state register. data now also carries workerCompetencyId, identifying which of the worker's licences (a worker may hold more than one) triggered the flag, alongside the existing companyId/workerId/licenceCheckId/result/source fields.
Verifying a delivery
Every delivery carries an X-CrewPass-Signature header:
X-CrewPass-Signature: t=1757289600,v1=5e2f...c9
t is the unix timestamp (seconds) the request was sent, and v1 is hmac-sha256(secret, "{t}.{raw request body}"), hex-encoded, using the signing secret shown once when you created the endpoint. Verify it in Node like this:
const crypto = require("node:crypto");
function verifyCrewPassSignature(secret, header, rawBody, toleranceSeconds = 300) {
const match = header.match(/t=(\d+),v1=([0-9a-f]+)/);
if (!match) return false;
const [, timestampText, signature] = match;
const timestamp = Number(timestampText);
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
Use the raw request body, not a re-serialized copy, since re-encoding JSON can reorder keys or change whitespace and break the comparison.
Retries
A delivery that does not get a 2xx response is retried with backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours. After the 12-hour retry also fails, the delivery is marked DEAD and stops retrying. Redeliver it manually from the settings page if needed. Deliveries, their status and a "Redeliver" action are all visible on Settings → API and webhooks.