> ## 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 guide: subscribe and verify events

> Subscribe to passport.sealed, room.order_attached, and room.passport_sealed events, and verify the SignSealShip-Signature HMAC in Node and Python.

SignSealShip pushes signed events to your HTTPS endpoint the moment things happen: a document is sealed, an order joins a room, a Closing Passport is minted. Every delivery carries an HMAC signature you must verify before trusting the payload.

## Topics

| Topic                  | Fires when                                                   |
| ---------------------- | ------------------------------------------------------------ |
| `passport.sealed`      | A document you submitted to the Proof Passport API is sealed |
| `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   |

## Subscribe

There are two ways to register an endpoint. Both require an `https://` URL, and both return the signing secret **exactly once** — SignSealShip stores only its SHA-256 hash, so a breach of our database never yields a key that can forge deliveries.

**Partner dashboard (all topics).** Sign in at [signsealship.com/partner](https://signsealship.com/partner) and add a webhook under webhook management, choosing the topics you want. The response shows your `sss_whsec_` secret once; copy it into your secret manager immediately.

**Proof Passport API (`passport.sealed` only).** Registering with your partner key subscribes you to sealed-passport events:

```bash Register a passport webhook theme={null}
curl -X POST https://signsealship.com/api/passport/webhooks \
  -H "Authorization: Bearer sss_pk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/signsealship" }'
```

```json Response theme={null}
{
  "webhookId": "0d4c8e2a-9f31-4b7e-8a5d-2c6f1e0b3a97",
  "url": "https://example.com/webhooks/signsealship",
  "signingSecret": "sss_whsec_shown_exactly_once_copy_it_now",
  "signatureHeader": "SignSealShip-Signature",
  "verification": "HMAC-SHA256 over \"{t}.{rawBody}\" using key = lowercase_hex(sha256(signingSecret)); compare against the v1= value and reject timestamps older than 5 minutes."
}
```

See the [webhooks API reference](/api-reference/webhooks) for the full endpoint shapes.

## Event payloads

Deliveries are JSON POSTs. The event name also rides in a `SignSealShip-Event` header, and the `id` field is deterministic per event so you can deduplicate redeliveries.

```json passport.sealed theme={null}
{
  "type": "passport.sealed",
  "id": "7c2e9a41-5b8f-4d03-9e6a-1f4b8c2d7e50",
  "data": {
    "passportId": "7c2e9a41-5b8f-4d03-9e6a-1f4b8c2d7e50",
    "verifyCode": "b7e2c4a1d9f0e8b6a5c3d2e1f0a9b8c7",
    "verifyUrl": "https://signsealship.com/api/passport/verify/b7e2c4a1d9f0e8b6a5c3d2e1f0a9b8c7",
    "docSha256": "9d377b10ce778c4938b3c7e2c63a229a33884810d7743fbe4b5f2b7657b706da",
    "sealedSha256": "4f9a1c2b3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcd",
    "status": "sealed",
    "sealedAtUtc": "2026-07-13T18:03:11Z"
  }
}
```

```json room.order_attached theme={null}
{
  "type": "room.order_attached",
  "id": "room.order_attached:3f8c1a9b2e4d5f60718293a4b5c6d7e8f9012345:your-order-public-code",
  "data": {
    "roomCode": "3f8c1a9b2e4d5f60718293a4b5c6d7e8f9012345",
    "roomName": "1428 Maple St - Refinance",
    "event": "room.order_attached",
    "orderCode": "your-order-public-code",
    "occurredAtUtc": "2026-07-13T18:03:11Z"
  }
}
```

```json room.passport_sealed theme={null}
{
  "type": "room.passport_sealed",
  "id": "room.passport_sealed:3f8c1a9b2e4d5f60718293a4b5c6d7e8f9012345:b7e2c4a1d9f0e8b6a5c3d2e1f0a9b8c7",
  "data": {
    "roomCode": "3f8c1a9b2e4d5f60718293a4b5c6d7e8f9012345",
    "roomName": "1428 Maple St - Refinance",
    "event": "room.passport_sealed",
    "passportVerifyCode": "b7e2c4a1d9f0e8b6a5c3d2e1f0a9b8c7",
    "occurredAtUtc": "2026-07-13T18:03:11Z"
  }
}
```

Room events carry only the field relevant to them: `orderCode` on order-attached, `passportVerifyCode` on passport-sealed. Payloads are PII-free and never contain another partner's data.

## The signature scheme

Every delivery includes:

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

The scheme mirrors the widely used `t=,v1=` format, so existing verification code ports directly:

1. **Derive the key once.** The HMAC key is the UTF-8 bytes of `lowercase_hex(sha256(your_raw_secret))`. You re-derive it from the `sss_whsec_` secret you were shown; SignSealShip signs with the stored hash, so the two sides always agree without the secret ever being stored in reversible form.
2. **Build the signed payload.** Concatenate the `t` value, a literal `.`, and the **raw request body bytes**: `{t}.{rawBody}`.
3. **Compute HMAC-SHA256** over that payload with the derived key, hex-encoded lowercase.
4. **Compare** against `v1` with a constant-time comparison.
5. **Reject stale timestamps.** Refuse deliveries where `t` is more than 5 minutes from now — this bounds replay of a captured request.

<Warning>
  Verify against the raw body bytes exactly as received. Parsing the JSON and re-serializing it will change the bytes and the signature will not match.
</Warning>

## Complete verification recipe

Both examples read the raw body, verify the signature and timestamp, and only then parse the event.

<CodeGroup>
  ```javascript verify.js theme={null}
  const crypto = require("node:crypto");

  const TOLERANCE_SECONDS = 5 * 60;

  // The HMAC key is the UTF-8 bytes of lowercase_hex(sha256(rawSecret)).
  // Derive it once at startup, not per request.
  function deriveKey(rawSecret) {
    return crypto.createHash("sha256").update(rawSecret, "utf8").digest("hex");
  }

  function verifySignature(rawBody, signatureHeader, key) {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((kv) => {
        const i = kv.indexOf("=");
        return i === -1 ? [kv, ""] : [kv.slice(0, i), kv.slice(i + 1)];
      })
    );
    const t = Number(parts.t);
    const v1 = parts.v1;
    if (!Number.isInteger(t) || !v1) return false;

    // Reject deliveries older (or newer) than the tolerance window.
    if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

    const expected = crypto
      .createHmac("sha256", key)
      .update(`${t}.${rawBody}`, "utf8")
      .digest("hex");

    // Constant-time comparison; lengths must match first.
    return (
      expected.length === v1.length &&
      crypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(v1, "utf8"))
    );
  }

  // Express endpoint — express.raw keeps the body bytes untouched.
  const express = require("express");
  const app = express();
  const KEY = deriveKey(process.env.SSS_WEBHOOK_SECRET);

  app.post(
    "/webhooks/signsealship",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const header = req.header("SignSealShip-Signature") ?? "";
      const rawBody = req.body.toString("utf8");

      if (!verifySignature(rawBody, header, KEY)) {
        return res.status(400).send("invalid signature");
      }

      const event = JSON.parse(rawBody);
      switch (event.type) {
        case "passport.sealed":
          // event.data.verifyUrl, event.data.sealedSha256, ...
          break;
        case "room.order_attached":
          // event.data.roomCode, event.data.orderCode, ...
          break;
        case "room.passport_sealed":
          // event.data.roomCode, event.data.passportVerifyCode, ...
          break;
      }

      res.sendStatus(200);
    }
  );

  app.listen(3000);
  ```

  ```python verify.py theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  TOLERANCE_SECONDS = 5 * 60


  def derive_key(raw_secret: str) -> bytes:
      """The HMAC key is the UTF-8 bytes of lowercase_hex(sha256(raw_secret))."""
      return hashlib.sha256(raw_secret.encode("utf-8")).hexdigest().encode("utf-8")


  def verify_signature(raw_body: bytes, signature_header: str, key: bytes) -> bool:
      parts = dict(
          kv.split("=", 1) for kv in signature_header.split(",") if "=" in kv
      )
      try:
          t = int(parts["t"])
          v1 = parts["v1"]
      except (KeyError, ValueError):
          return False

      # Reject deliveries older (or newer) than the tolerance window.
      if abs(time.time() - t) > TOLERANCE_SECONDS:
          return False

      signed = f"{t}.".encode("utf-8") + raw_body
      expected = hmac.new(key, signed, hashlib.sha256).hexdigest()

      # Constant-time comparison.
      return hmac.compare_digest(expected, v1)


  # Flask endpoint - request.get_data() returns the raw body bytes untouched.
  from flask import Flask, request

  app = Flask(__name__)
  KEY = derive_key(os.environ["SSS_WEBHOOK_SECRET"])


  @app.post("/webhooks/signsealship")
  def signsealship_webhook():
      header = request.headers.get("SignSealShip-Signature", "")

      if not verify_signature(request.get_data(), header, KEY):
          return "invalid signature", 400

      event = json.loads(request.get_data())
      if event["type"] == "passport.sealed":
          pass  # event["data"]["verifyUrl"], event["data"]["sealedSha256"], ...
      elif event["type"] == "room.order_attached":
          pass  # event["data"]["roomCode"], event["data"]["orderCode"], ...
      elif event["type"] == "room.passport_sealed":
          pass  # event["data"]["roomCode"], event["data"]["passportVerifyCode"], ...

      return "", 200
  ```
</CodeGroup>

<Tip>
  Test your endpoint before going live: compute a signature locally with your secret and a sample payload, POST it to yourself, and confirm your verifier accepts it — then flip one byte of the body and confirm it rejects.
</Tip>

## Delivery semantics

* **Best-effort and non-blocking.** Deliveries never block or fail the operation that triggered them. A sealed passport is durable before its webhook fires.
* **Respond fast with a 2xx.** Acknowledge immediately and process asynchronously; slow endpoints get timed out at 10 seconds.
* **Expect redeliveries.** Deliveries can be enqueued and retried, so design your handler to be idempotent. Deduplicate on the `id` field — it is deterministic per event.
* **Ordering is not guaranteed.** Use `occurredAtUtc` / `sealedAtUtc` when sequence matters.
* **Verify every delivery.** An unsigned or badly signed request to your endpoint is not from SignSealShip. Return a 400 and ignore it.

## Related

<Columns cols={2}>
  <Card title="Webhooks API reference" icon="square-terminal" href="/api-reference/webhooks">
    Endpoint shapes for registering, listing, and deleting webhook subscriptions.
  </Card>

  <Card title="Closing Passport" icon="file-check" href="/closing-passport">
    What the `room.passport_sealed` event points at, and how to verify it.
  </Card>
</Columns>
