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.
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:
{ "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."}
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.
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 scheme mirrors the widely used t=,v1= format, so existing verification code ports directly:
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.
Build the signed payload. Concatenate the t value, a literal ., and the raw request body bytes: {t}.{rawBody}.
Compute HMAC-SHA256 over that payload with the derived key, hex-encoded lowercase.
Compare against v1 with a constant-time comparison.
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.
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 hashlibimport hmacimport jsonimport osimport timeTOLERANCE_SECONDS = 5 * 60def 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, requestapp = 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.
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.