"""HE Signs server SDK. Python 3.10+, standard library only. Keep credentials on your server. Persist the idempotency key before creating an agreement. Webhook consumers must durably deduplicate the verified event ID. """ import hashlib import hmac import json import re import time from urllib.error import HTTPError from urllib.parse import quote, urlsplit, urlencode from urllib.request import Request, build_opener, HTTPRedirectHandler class HESignsError(Exception): def __init__(self, message, *, status, code=None, request_id=None, retry_after=None): super().__init__(message) self.status = status self.code = code self.request_id = request_id self.retry_after = retry_after class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): # Do not forward an API credential to a redirect destination. return None class HESigns: def __init__(self, api_key, base_url="https://hesigns.homeequal.ai", *, timeout=30): url = urlsplit(base_url) if (url.scheme not in ("https", "http") or url.username or url.password or url.path or url.query or url.fragment or not url.hostname or url.scheme == "http" and url.hostname not in ("localhost", "127.0.0.1")): raise ValueError("Use an HTTPS service origin, or HTTP on loopback for local testing") if not isinstance(timeout, (int, float)) or timeout <= 0 or timeout > 120: raise ValueError("Timeout must be between 0 and 120 seconds") if not isinstance(api_key, str) or not re.fullmatch(r"hes_[A-Za-z0-9_-]{43}", api_key): raise ValueError("Use a server-side HE Signs API credential") self._key = api_key self._origin = base_url self._timeout = timeout self._opener = build_opener(_NoRedirect()) def request(self, method, path, *, body=None, idempotency_key=None): if not isinstance(path, str) or not path.startswith("/v2/") or ".." in path: raise ValueError("Use a v2 API path") headers = {"X-API-Key": self._key, "Accept": "application/json"} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body, ensure_ascii=False).encode("utf-8") if idempotency_key: headers["Idempotency-Key"] = idempotency_key req = Request(self._origin + path, data=data, headers=headers, method=method) try: with self._opener.open(req, timeout=self._timeout) as response: raw = response.read() return json.loads(raw) if "application/json" in response.headers.get("Content-Type", "") else raw except HTTPError as error: try: info = json.loads(error.read()).get("error", {}) except (ValueError, AttributeError): info = {} raise HESignsError(info.get("message", "HE Signs request failed"), status=error.code, code=info.get("code"), request_id=error.headers.get("X-Request-ID"), retry_after=error.headers.get("Retry-After")) from None def create_envelope(self, body, idempotency_key): if not idempotency_key: raise ValueError("Persist an idempotency key before creating an agreement") return self.request("POST", "/v2/envelopes", body=body, idempotency_key=idempotency_key) def list_envelopes(self, *, limit=100, cursor=None): query = {"limit": limit} if cursor: query["cursor"] = cursor return self.request("GET", "/v2/envelopes?" + urlencode(query)) def envelopes(self, *, limit=100): cursor = None while True: page = self.list_envelopes(limit=limit, cursor=cursor) yield from page["data"] cursor = page.get("next_cursor") if not cursor: return def assemble_documents(self, documents): return self.request("POST", "/v2/envelopes/assemble", body={"documents": documents}) def create_bulk(self, body, idempotency_key): return self.request("POST", "/v2/bulk", body=body, idempotency_key=idempotency_key) def create_export(self, body, idempotency_key): return self.request("POST", "/v2/exports/jobs", body=body, idempotency_key=idempotency_key) def get_batch(self, kind, batch_id, *, after=None): if kind not in ("bulk", "export"): raise ValueError("Use bulk or export") path = ("/v2/bulk/" if kind == "bulk" else "/v2/exports/jobs/") + quote(batch_id, safe="") return self.request("GET", path + ("?" + urlencode({"after": after}) if after is not None else "")) def export_parts(self, batch_id): after = None while True: page = self.get_batch("export", batch_id, after=after) yield from page["data"] after = page.get("next_cursor") if after is None: return def download_export(self, batch_id, directory): from pathlib import Path from uuid import uuid4 root = Path(directory).resolve() root.mkdir(parents=True, exist_ok=True) manifest, saved, pending = [], 0, 0 for item in self.export_parts(batch_id): manifest.append(item) if item["state"] != "completed": pending += 1 continue result = item["result"] filename = result["filename"] if not re.fullmatch(r"hs_[a-f0-9-]{36}\.zip", filename): raise ValueError("Invalid export filename") target = root / filename data = target.read_bytes() if target.exists() else self.request("GET", "/v2/exports/jobs/" + quote(batch_id, safe="") + "/parts/" + str(item["item"])) if hashlib.sha256(data).hexdigest() != result["sha256"]: raise ValueError("Export checksum mismatch: " + filename) if not target.exists(): with target.open("xb") as out: out.write(data) saved += 1 with (root / ("manifest-" + str(uuid4()) + ".json")).open("x", encoding="utf-8") as out: json.dump({"batch": self.get_batch("export", batch_id)["batch"], "parts": manifest}, out, ensure_ascii=False, indent=2) return {"saved": saved, "pending": pending} def get_envelope(self, envelope_id): return self.request("GET", "/v2/envelopes/" + quote(envelope_id, safe="")) def invite(self, envelope_id, signer=0): if not isinstance(signer, int) or signer < 0: raise ValueError("Use a nonnegative signer index") return self.request("POST", "/v2/envelopes/" + quote(envelope_id, safe="") + "/send/" + str(signer), body={}) def embedded_session(self, envelope_id, parent_origin, signer=0): return self.request("POST", "/v2/envelopes/" + quote(envelope_id, safe="") + "/embedded-sessions", body={"signer": signer, "parent_origin": parent_origin}) def proof_kit(self, envelope_id): return self.request("GET", "/v2/envelopes/" + quote(envelope_id, safe="") + "/proof-kit.zip") def events(self, after="0"): return self.request("GET", "/v2/events?after=" + quote(str(after), safe="")) def verify_webhook(secret, raw_body, timestamp, signature, *, now=None, tolerance_seconds=300): """Verify the exact received bytes, then return the parsed event. This validates authenticity and freshness, not uniqueness: atomically save event['id'] with the consumer's state change before acknowledging delivery. """ now = time.time() if now is None else now if (not re.fullmatch(r"\d{10,}", str(timestamp)) or abs(now - int(timestamp)) > tolerance_seconds): raise ValueError("Webhook timestamp outside acceptance window") if not isinstance(raw_body, bytes): raise TypeError("Pass the unmodified request body as bytes") expected = "v1=" + hmac.new(secret.encode("utf-8"), str(timestamp).encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest() if not isinstance(signature, str) or not re.fullmatch(r"v1=[a-f0-9]{64}", signature) or not hmac.compare_digest(expected, signature): raise ValueError("Invalid webhook signature") event = json.loads(raw_body) if not isinstance(event, dict) or not isinstance(event.get("id"), str) or not isinstance(event.get("type"), str): raise ValueError("Invalid webhook event") return event