Skip to main content
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

TopicFires when
passport.sealedA document you submitted to the Proof Passport API is sealed
room.order_attachedAn order is attached to one of your Closing Rooms
room.passport_sealedA 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 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:
Register a passport webhook
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" }'
Response
{
  "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 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.
passport.sealed
{
  "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"
  }
}
room.order_attached
{
  "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"
  }
}
room.passport_sealed
{
  "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:
Headers
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.
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.

Complete verification recipe

Both examples read the raw body, verify the signature and timestamp, and only then parse the event.
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);
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
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.

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.

Webhooks API reference

Endpoint shapes for registering, listing, and deleting webhook subscriptions.

Closing Passport

What the room.passport_sealed event points at, and how to verify it.