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

# Webhooks API: manage subscriptions and signed events

> Manage webhook subscriptions and handle passport.sealed, room.order_attached, and room.passport_sealed payloads with SignSealShip-Signature HMAC checks.

SignSealShip delivers signed webhook events for three topics:

| Event                  | Fires when                                                 |
| ---------------------- | ---------------------------------------------------------- |
| `passport.sealed`      | A Proof Passport seal completes                            |
| `room.order_attached`  | An order is attached to one of your Closing Rooms          |
| `room.passport_sealed` | A Closing Passport version is sealed for one of your rooms |

Every delivery is an HTTPS POST with a JSON body, a `SignSealShip-Signature` HMAC header, and a `SignSealShip-Event` header naming the event.

## Two ways to register

* **Partner API key** — [`POST /api/passport/webhooks`](/api-reference/proof-passport#register-a-passport-webhook) registers a `passport.sealed`-only endpoint. Subscriptions created this way have no topic list and never receive room events.
* **Partner dashboard session** — the `/api/partner/webhooks` endpoints below let you choose topics, including the room events. They are authenticated by your dashboard login session, not the API key.

## Dashboard session authentication

The `/api/partner/webhooks` routes are authenticated by the SignSealShip login session cookie, and your signed-in user must be linked to your partner account. Linking happens once, by proving possession of a valid partner API key — sign in at `https://signsealship.com/partner` and paste your key (the underlying call is `POST /api/partner/link` with `{"apiKey": "..."}`). The first linked member of a partner becomes `owner`; later ones `member`.

An unlinked session gets:

```json 403 Forbidden theme={null}
{ "error": "This session is not linked to a partner. Link with a partner API key first." }
```

These routes share the `partner-portal` rate limit of 120 requests per minute per session.

## List subscriptions

`GET /api/partner/webhooks`

Returns a JSON array of your partner's subscriptions, newest first. Legacy subscriptions created with the API-key route report `"topics": ["passport.sealed"]`.

```bash curl theme={null}
curl https://signsealship.com/api/partner/webhooks \
  -H "Cookie: __Host-session=<your session cookie>"
```

```json 200 OK theme={null}
[
  {
    "id": "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
    "url": "https://example.com/hooks/signsealship",
    "topics": ["passport.sealed", "room.order_attached", "room.passport_sealed"],
    "active": true,
    "createdAt": "2026-07-12T17:00:00+00:00"
  }
]
```

## Create a subscription

`POST /api/partner/webhooks`

<ParamField body="url" type="string" required>
  Your webhook endpoint. Must be an absolute `https://` URL.
</ParamField>

<ParamField body="topics" type="string[]" required>
  One or more of `passport.sealed`, `room.order_attached`,
  `room.passport_sealed`. Deliveries go only to subscriptions whose topics
  include the event.
</ParamField>

```bash curl theme={null}
curl -X POST https://signsealship.com/api/partner/webhooks \
  -H "Cookie: __Host-session=<your session cookie>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/signsealship",
    "topics": ["room.order_attached", "room.passport_sealed"]
  }'
```

<ResponseField name="id" type="string">
  Subscription id (UUID) — use it to delete.
</ResponseField>

<ResponseField name="secret" type="string">
  The signing secret, prefixed `sss_whsec_`. **Shown exactly once** —
  SignSealShip stores only its SHA-256 hash. Save it now.
</ResponseField>

<ResponseField name="url" type="string">
  The registered endpoint.
</ResponseField>

<ResponseField name="topics" type="string[]">
  The subscribed topics.
</ResponseField>

<ResponseField name="signatureHeader" type="string">
  `"SignSealShip-Signature"` — the header carrying the signature on every
  delivery.
</ResponseField>

Errors: `400` `{"error": "A https:// webhook URL is required."}`, `400` when `topics` is empty or contains anything outside the allowed set.

<Tip>
  To rotate a signing secret, create a new subscription and delete the old one
  once your endpoint accepts both.
</Tip>

## Delete a subscription

`DELETE /api/partner/webhooks/{id}`

<ParamField path="id" type="string" required>
  The subscription id. You can only delete your own partner's subscriptions.
</ParamField>

```bash curl theme={null}
curl -X DELETE https://signsealship.com/api/partner/webhooks/{id} \
  -H "Cookie: __Host-session=<your session cookie>"
```

Returns `{"ok": true}`, or `404` `{"error": "No such webhook."}`.

## Event payloads

All payloads are PII-free and never carry another partner's data. Optional fields are omitted from the JSON entirely (not sent as `null`) when they don't apply.

### passport.sealed

`id` is the passport's UUID.

```json passport.sealed theme={null}
{
  "type": "passport.sealed",
  "id": "0d5e2f6a-1b3c-4d7e-8f90-a1b2c3d4e5f6",
  "data": {
    "passportId": "0d5e2f6a-1b3c-4d7e-8f90-a1b2c3d4e5f6",
    "verifyCode": "abcdefghijklmnopqrstuvwxyz",
    "verifyUrl": "https://signsealship.com/api/passport/verify/abcdefghijklmnopqrstuvwxyz",
    "docSha256": "5f6e...1a2b",
    "sealedSha256": "8c9d...3e4f",
    "status": "sealed",
    "sealedAtUtc": "2026-07-12T18:30:11+00:00"
  }
}
```

### room.order\_attached

`id` is deterministic per event, room, and subject — `{event}:{roomCode}:{orderCode}` — so you can deduplicate a redelivery. `orderCode` is the attached order's public code; `passportVerifyCode` is omitted.

```json room.order_attached theme={null}
{
  "type": "room.order_attached",
  "id": "room.order_attached:3f1c9a7e5b2d8c4a6e0f9b1d3a5c7e9f2b4d6a8c:an-order-public-code",
  "data": {
    "roomCode": "3f1c9a7e5b2d8c4a6e0f9b1d3a5c7e9f2b4d6a8c",
    "roomName": "1428 Maple St — Refinance",
    "event": "room.order_attached",
    "orderCode": "an-order-public-code",
    "occurredAtUtc": "2026-07-12T18:10:02+00:00"
  }
}
```

### room.passport\_sealed

`passportVerifyCode` is the room's latest sealed passport verify code (for `/v/room/{passportVerifyCode}`); `orderCode` is omitted.

```json room.passport_sealed theme={null}
{
  "type": "room.passport_sealed",
  "id": "room.passport_sealed:3f1c9a7e5b2d8c4a6e0f9b1d3a5c7e9f2b4d6a8c:zyxwvutsrqponmlkjihgfedcba",
  "data": {
    "roomCode": "3f1c9a7e5b2d8c4a6e0f9b1d3a5c7e9f2b4d6a8c",
    "roomName": "1428 Maple St — Refinance",
    "event": "room.passport_sealed",
    "passportVerifyCode": "zyxwvutsrqponmlkjihgfedcba",
    "occurredAtUtc": "2026-07-12T20:01:00+00:00"
  }
}
```

## Verifying the signature

Every delivery carries two headers:

```text Delivery headers theme={null}
SignSealShip-Signature: t=1783290062,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
SignSealShip-Event: room.passport_sealed
```

The scheme mirrors Stripe's `t=,v1=` format, so you can reuse familiar verification code:

<Steps>
  <Step title="Derive the key">
    The HMAC key is `lowercase_hex(sha256(secret))` — the SHA-256 of your raw
    signing secret, as a lowercase hex string, used as UTF-8 bytes.
  </Step>

  <Step title="Compute the expected signature">
    HMAC-SHA256 over the string `"{t}.{rawBody}"`, where `t` is the value from
    the header and `rawBody` is the exact request body bytes. Compare the
    lowercase hex result to `v1` with a constant-time comparison.
  </Step>

  <Step title="Reject stale timestamps">
    Reject deliveries whose `t` is more than 5 minutes from now to blunt
    replay.
  </Step>
</Steps>

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

  export function verifySignature(rawBody, signatureHeader, secret) {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((p) => p.split("=", 2)),
    );
    const t = Number(parts.t);
    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
      return false;
    }

    // Key = lowercase hex of sha256(raw secret), as UTF-8 bytes.
    const key = crypto.createHash("sha256").update(secret).digest("hex");
    const expected = crypto
      .createHmac("sha256", key)
      .update(`${parts.t}.${rawBody}`)
      .digest("hex");

    const a = Buffer.from(expected);
    const b = Buffer.from(parts.v1 ?? "");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

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


  def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      try:
          ts = int(parts["t"])
      except (KeyError, ValueError):
          return False
      if abs(time.time() - ts) > 300:
          return False

      # Key = lowercase hex of sha256(raw secret), as UTF-8 bytes.
      key = hashlib.sha256(secret.encode()).hexdigest().encode()
      signed = parts["t"].encode() + b"." + raw_body
      expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```
</CodeGroup>

<Warning>
  Verify against the **raw request body bytes**, before any JSON parsing or
  re-serialization — a re-serialized body will not match the signature.
</Warning>

## Delivery semantics — honest version

Webhook delivery is **best-effort and fire-and-forget**. A slow or failing endpoint can never block, slow, or fail the seal or room operation that triggered it.

* Outbound POSTs time out after 10 seconds; a non-2xx response is logged and not retried by the dispatcher.
* `passport.sealed` deliveries may be routed through an internal task queue when available, which can produce a redelivery — treat deliveries as at-least-once and deduplicate by `id`.
* Room events are dispatched after the underlying write commits; if your endpoint is down, that notification is lost.

Design accordingly: treat webhooks as a nudge, not a ledger. Reconcile state with the read endpoints — [`GET /api/rooms/{roomCode}`](/api-reference/rooms#read-the-room-public), [`GET /api/verify/room/{verifyCode}`](/api-reference/passports#verify-a-passport-public), and [`GET /api/passport/{id}`](/api-reference/proof-passport#fetch-a-passport) — whenever correctness matters.
