Skip to main content
SignSealShip delivers signed webhook events for three topics:
EventFires when
passport.sealedA Proof Passport seal completes
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
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 keyPOST /api/passport/webhooks 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:
403 Forbidden
{ "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"].
curl
curl https://signsealship.com/api/partner/webhooks \
  -H "Cookie: __Host-session=<your session cookie>"
200 OK
[
  {
    "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
url
string
required
Your webhook endpoint. Must be an absolute https:// URL.
topics
string[]
required
One or more of passport.sealed, room.order_attached, room.passport_sealed. Deliveries go only to subscriptions whose topics include the event.
curl
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"]
  }'
id
string
Subscription id (UUID) — use it to delete.
secret
string
The signing secret, prefixed sss_whsec_. Shown exactly once — SignSealShip stores only its SHA-256 hash. Save it now.
url
string
The registered endpoint.
topics
string[]
The subscribed topics.
signatureHeader
string
"SignSealShip-Signature" — the header carrying the signature on every delivery.
Errors: 400 {"error": "A https:// webhook URL is required."}, 400 when topics is empty or contains anything outside the allowed set.
To rotate a signing secret, create a new subscription and delete the old one once your endpoint accepts both.

Delete a subscription

DELETE /api/partner/webhooks/{id}
id
string
required
The subscription id. You can only delete your own partner’s subscriptions.
curl
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.
passport.sealed
{
  "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.
room.order_attached
{
  "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.
room.passport_sealed
{
  "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:
Delivery headers
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:
1

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.
2

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.
3

Reject stale timestamps

Reject deliveries whose t is more than 5 minutes from now to blunt replay.
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);
}
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", ""))
Verify against the raw request body bytes, before any JSON parsing or re-serialization — a re-serialized body will not match the signature.

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}, GET /api/verify/room/{verifyCode}, and GET /api/passport/{id} — whenever correctness matters.