> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewpass.co.uk/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The CrewPass change-feed: events, envelope, outbound signing, and verification.

CrewPass pushes a webhook to your registered endpoint when data about a crew
member on your fleet changes — so you don't have to poll. Delivery is
asynchronous, signed, retried with backoff, and dead-lettered after repeated
failure.

You receive an event only for a crew member on your fleet, only for an event
whose scope you hold, and only when that crew member's consent allows it — the
same gate as the read surface.

## How it works

CrewPass continuously watches the data behind your fleet. When something relevant
changes — a certificate finishes verifying, compliance flips, a status or profile
updates — CrewPass builds the matching event and delivers it to the callback URL
you registered. There is nothing to run on your side beyond an HTTPS endpoint that
accepts a `POST`, verifies the signature, and returns `2xx`.

Two things worth knowing:

* **You don't poll.** Events are pushed in near-real-time off CrewPass's own
  change feed; you react to them.
* **Delivery is at-least-once.** The same event may arrive more than once (e.g.
  after a retry), so deduplicate on `event_id`.

## Subscribing

Webhook registration is **not self-serve**. Ask your CrewPass contact to register
your callback URL and the events you want; they set it up for you during
onboarding, or whenever you need it changed. CrewPass then issues a **webhook
signing secret** used to sign every delivery (this is separate from your API key;
reads are not signed).

## The envelope

Every delivery is a JSON body with this shape:

```json theme={null}
{
  "schema_version": 1,
  "event_id": "evt_abc",
  "event_type": "crew.document.processed",
  "occurred_at": "2026-06-08T10:00:00Z",
  "partner_id": "prt_123",
  "data": { }
}
```

And these headers:

| Header                      | Value                                                                                                               |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `X-CrewPass-Event-Id`       | The event id (dedupe on this).                                                                                      |
| `X-CrewPass-Event-Type`     | The event type.                                                                                                     |
| `X-CrewPass-Timestamp`      | Unix seconds at signing time.                                                                                       |
| `X-CrewPass-Signature`      | `v1=<hex(hmac_sha256(secret, message))>`, where `message` is the timestamp, a dot, then the raw request body bytes. |
| `X-CrewPass-Schema-Version` | `1`                                                                                                                 |

## Signature scheme

<Warning>
  The webhook signature signs the timestamp, a dot, then the raw request body bytes
  (`"{timestamp}." + body`), with **no nonce**. Build your receiver's verification
  against exactly that string.
</Warning>

### Verifying a delivery

```python Python theme={null}
import hashlib, hmac

def verify(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    expected = "v1=" + hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# In your handler: read the RAW body bytes (do not re-serialize), then:
# verify(SECRET, request.headers["X-CrewPass-Timestamp"],
#        request.headers["X-CrewPass-Signature"], raw_body)
```

```javascript Node theme={null}
import crypto from "node:crypto";

export function verify(secret, timestamp, signature, rawBody) {
  const expected =
    "v1=" +
    crypto.createHmac("sha256", secret).update(`${timestamp}.` + rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

## Receiver requirements

1. **Verify** the signature against the raw bytes.
2. **Deduplicate** on `event_id` — delivery is at-least-once, so the same event
   may arrive more than once.
3. **Respond `2xx` quickly** and process asynchronously.
4. Anything else is **retried with exponential backoff** (up to 8 attempts), then
   **dead-lettered**. A `4xx` (other than `408`/`429`) is treated as a permanent
   rejection and not retried.

## Event catalogue (v1)

| Event                     | Fires when                                       | Scope required          |
| ------------------------- | ------------------------------------------------ | ----------------------- |
| `crew.document.processed` | A certificate finishes processing & verification | `crew:documents:read`   |
| `crew.document.updated`   | A document's verification status changes         | `crew:documents:read`   |
| `crew.compliance.changed` | A crew member's compliance state changes         | `crew:compliance:read`  |
| `crew.status.changed`     | Verification or background-check status changes  | `crew:status:subscribe` |
| `crew.profile.updated`    | A crew member updates their profile              | `crew:profile:read`     |

### `crew.document.processed` / `crew.document.updated`

```json theme={null}
{
  "crew_unique_id": "crew_001",
  "document_id": "doc_9",
  "type": "ENG1",
  "category": "Medical Certificate",
  "title": "ENG1 Medical",
  "issuer": "Approved Authority",
  "verification_status": "verified",
  "issue_date": "2026-06-01",
  "expiry_date": "2028-06-01",
  "document_number": "E1-998"
}
```

### `crew.compliance.changed`

```json theme={null}
{
  "crew_unique_id": "crew_001",
  "vessel_id": "ves_abc",
  "overall_status": "at_risk",
  "requirements_met": 7,
  "requirements_total": 8,
  "requirements_expiring": 1,
  "next_expiry_date": "2026-08-01"
}
```

### `crew.status.changed`

```json theme={null}
{
  "crew_unique_id": "crew_001",
  "verification_status": "verified",
  "background_check_status": "completed"
}
```

### `crew.profile.updated`

```json theme={null}
{ "crew_unique_id": "crew_001", "updated_at": "2026-06-08T12:00:00Z" }
```

## Reacting to an event

Every payload carries a **`crew_unique_id`**: the join key between CrewPass and
your own records. You learn it once, store it against your own crew record, then
use it to look up the detail behind any event.

Discover a crew member's `crew_unique_id` once, then keep it:

* `GET /api/v2/employers/me/fleet` returns the fleet roster, where every crew
  member arrives with their `crew_unique_id`.
* `POST /api/v2/employers/me/crew/lookup` resolves a single known crew member to
  their `crew_unique_id`.

From then on, an event's `crew_unique_id` tells you exactly which of your records
changed. An event is a signal, not a full snapshot: the document events carry
their metadata inline, and for the others you call the matching read endpoint to
fetch the current detail.

**`crew.document.processed` and `crew.document.updated`.** The payload already
has the document metadata (type, category, issuer, verification status, issue and
expiry dates, document number). To fetch the **file**, call
`GET /api/v2/employers/me/crew/{crew_unique_id}/documents/{document_id}/download`
and follow the `download_url` it returns; that link is short-lived, so use it
straight away rather than storing it. Both `crew_unique_id` and `document_id` come
from the event payload.

<Note>
  Document events fire on verification **completion**
  (`processingStatus.overall == "completed"`), not on raw upload. Expect a short
  delay between a crew member uploading a certificate and the matching event
  arriving.
</Note>

**`crew.compliance.changed`.** The payload is a summary (overall status plus the
requirement counts). For the full role, STCW, and medical breakdown, call
`POST /api/v2/employers/me/crew/{crew_unique_id}/compliance-checks`. The
`overall_status` values
([`compliant`, `at_risk`, `non_compliant`, `no_role`](/guides/compliance#reading-the-result))
mean the same here as on the compliance read.

**`crew.profile.updated`.** The event is a change signal, not a diff: it tells you
the profile changed, not what changed. Call
`GET /api/v2/employers/me/crew/{crew_unique_id}/profile` to read the current
profile.

**`crew.status.changed`.** The payload carries the new `verification_status` and
`background_check_status` directly, so update your own record straight from the
payload. No follow-up read is required.

<Note>
  Background-check status uses CrewPass's standardised, provider-agnostic
  vocabulary.
</Note>
