How It Works
CertiSigma provides three-tier cryptographic attestation for any SHA-256 hash:
- T0 — ECDSA Signature — Immediate. The server signs your hash with a P-256 key upon receipt. POST /attest
- T1 — TSA Timestamp — Within minutes. Hashes are batched into a Merkle tree and submitted to an RFC 3161 Time Stamping Authority. POST /verify
- T2 — Bitcoin Anchor — Within hours. The Merkle root is anchored to the Bitcoin blockchain via OpenTimestamps, providing immutable proof. GET /attestation/{id}/evidence
The SDK handles hashing, request signing, error handling, and optional client-side encryption. You send a hash (or a file), the SDK does the rest.
Installation
Sync and async clients built on httpx. Python 3.10+.
pip install certisigma
For client-side encryption support (AES-256-GCM):
pip install certisigma[crypto]
Authentication
Pass your API key to the client constructor. The SDK sends it as a Bearer token on every request.
import os
from certisigma import CertiSigmaClient
client = CertiSigmaClient(api_key=os.environ["CERTISIGMA_API_KEY"])
cs_live_... for production, cs_demo_... for sandbox.
Never hard-code keys — use environment variables or a secrets manager.
If your admin has configured an IP Allowlist on your key, requests from non-whitelisted IPs will receive 403 Forbidden. See IP Allowlist docs.
Quick Start
import os
from certisigma import CertiSigmaClient, hash_file, hash_bytes
client = CertiSigmaClient(api_key=os.environ.get("CERTISIGMA_API_KEY"))
# 1. Compute the SHA-256 hash of your file
file_hash = hash_file("contract.pdf")
print(f"SHA-256: {file_hash}") # 64-char lowercase hex
# 2. Attest — creates a timestamped, signed proof of existence
result = client.attest(file_hash, source="my-app")
print(f"Attestation: {result.id} at {result.timestamp}")
print(f"ECDSA signature: {result.signature}")
# 3. Verify — confirm the hash was attested
check = client.verify(file_hash)
print(f"Exists: {check.exists}, Level: {check.level}")
# Or hash raw bytes directly
data_hash = hash_bytes(b"any raw content")
result2 = client.attest(data_hash)
Async Support
Use AsyncCertiSigmaClient for non-blocking I/O in asyncio applications.
import asyncio
from certisigma import AsyncCertiSigmaClient, hash_file
async def main():
# hash_file() is sync (CPU-bound) — call before entering async context
file_hash = hash_file("contract.pdf")
async with AsyncCertiSigmaClient(api_key=os.environ.get("CERTISIGMA_API_KEY")) as client:
result = await client.attest(file_hash, source="async-pipeline")
print(f"{result.id} — {result.hash_hex}")
check = await client.verify(file_hash)
print(f"Exists: {check.exists}")
asyncio.run(main())
Integration Contract
| Parameter | Value |
|---|---|
| API target | /v1/ (all endpoints also available without prefix) |
| Rate limit | 1,000 req/min per key (sliding window), Retry-After header on 429 |
| Batch max | 100 hashes per call (batch_attest, batch_verify) |
| Public endpoints | verify, batch_verify, status, get_evidence, health, /keys, match_derived_list (requires list_key), get_derived_list_signature — batch_verify(detailed=True) requires API key |
| Auth-required | attest, batch_attest, update_metadata, delete_metadata, get_metadata, put_tags, get_tags, query_tags, create_share_token, scan, get_bulk_stats, create_derived_list, list_derived_lists, get_derived_list, get_derived_list_access_log, revoke_derived_list, register_webhook, list_webhooks, delete_webhook, list_webhook_deliveries |
| RBAC scopes | attest, metadata:read, metadata:write, tags:read, tags:write, share, scan, batch, census, webhook — see RBAC & Scoped Keys |
| Attestation IDs | Sequential (att_1, att_2, …). Public by design — CertiSigma is a public attestation platform. Cryptographic proof (hash, signature, Merkle, OTS) is intentionally verifiable by any party. |
| Data isolation | Organizational metadata (source, extra_data, tags) is never exposed on public endpoints. Only the authenticated API key owner can read their own claim data. Public endpoints return only cryptographic proof. |
| Retry policy | Caller-managed. Retry on 429 and 5xx with exponential backoff + jitter |
| Idempotency | Re-attesting the same hash returns the existing attestation (no duplicate) |
| Webhook delivery | At-least-once, ordering not guaranteed, HMAC-SHA256 signed |
| Support window | Current major + previous major for at least 12 months |
| Deprecation | At least one minor release warning before removal |
Recommended Attestation Workflow
A production integration typically follows this pattern:
import json, sqlite3
from certisigma import CertiSigmaClient, hash_file
client = CertiSigmaClient(api_key=os.environ.get("CERTISIGMA_API_KEY"))
if not client._api_key:
raise ValueError("CERTISIGMA_API_KEY environment variable is required")
# 1. Hash the file locally (SHA-256, streamed in 8 KiB chunks)
hash_hex = hash_file("contract.pdf")
# 2. Attest
result = client.attest(hash_hex, source="contract-pipeline")
# 3. Save to your local database
db = sqlite3.connect("attestations.db")
db.execute("""INSERT INTO attestations
(id, hash_hex, timestamp, signature, source, file_path)
VALUES (?, ?, ?, ?, ?, ?)""",
(result.id, result.hash_hex, result.timestamp,
result.signature, "contract-pipeline", "contract.pdf"))
db.commit()
# 4. Later — download the full cryptographic evidence
evidence = client.get_evidence(result.id)
print(f"Level: {evidence.level}") # T0, T1, or T2
# 5. Save evidence as a local manifest (include T2 when available)
with open("contract.pdf.certisigma.json", "w") as f:
json.dump({
"id": result.id,
"hash": result.hash_hex,
"timestamp": result.timestamp,
"signature": result.signature,
"level": evidence.level,
"t1": evidence.t1,
"t2": evidence.t2,
}, f, indent=2)
Attest Response Fields
| Field | Type | Description |
|---|---|---|
id | str | Attestation ID (att_...) |
hash_hex | str | SHA-256 hash that was attested |
timestamp | str | ISO 8601 creation time |
signature | str | Base64 ECDSA P-256 signature |
status | str | created (new) or existing (already attested) |
etag | str | ETag for optimistic concurrency on metadata updates |
claim_id | int | Your API key's claim ID for this attestation |
source | str | Source label you provided |
extra_data | dict | Metadata you attached |
verify() Response Shape
| Field | Python type | Public (no key) | Authenticated |
|---|---|---|---|
exists | bool | Always | Always |
hash_hex | str | Always | Always |
id | Optional[str] | If exists | If exists |
timestamp | Optional[str] | If exists | If exists |
level | Optional[str] | If exists | If exists |
verified_at | Optional[str] | If exists | If exists |
source | Optional[str] | — | If caller owns a claim |
get_evidence() Response Shape
| Field | Python type | Condition |
|---|---|---|
id | Optional[str] | Always |
hash_hex | str | Always |
level | str | Always (T0, T1, or T2) |
t0 | dict | Always (keys: formatVersion, registeredAt, signature, publicKeyId) |
t1 | Optional[dict] | Present when level ≥ T1 (keys: tsa, timestamp, token) |
t2 | Optional[dict] | Present when level = T2 (keys: merkleRoot, bitcoinBlock, txId) |
hash_hex, extra_data, verified_at). Nested objects under t0, t1, t2 use camelCase (formatVersion, registeredAt, publicKeyId).
What to Save After Attestation
Always persist the attestation result in your own database. Recommended SQLite schema:
CREATE TABLE IF NOT EXISTS attestations (
id TEXT PRIMARY KEY,
hash_hex TEXT NOT NULL,
timestamp TEXT NOT NULL,
signature TEXT NOT NULL,
source TEXT,
original_file TEXT,
level TEXT DEFAULT 'T0',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
.certisigma.json file alongside each attested file.
This creates an offline-verifiable record without querying the API.
Minimum Durable Evidence Set (Probative Use)
For long-term legal or audit purposes, persist at least: id, hash_hex, timestamp, signature, level, and when available t1 (TSA + Merkle proof) and t2 (Bitcoin anchor). Save the .ots proof file for independent verification. This set allows verification even if the CertiSigma API is unavailable.
Idempotency
Attest returns status: "created" for new attestations or status: "existing" if the hash was already attested. Safe to retry: duplicate attestations are idempotent. On network timeout, re-attest with the same hash; if it already exists, you get the same attestation ID.
Batch Operations
Attest or verify up to 100 hashes in a single API call. Efficient for bulk workloads.
from certisigma import hash_file
import glob
# Compute hashes for all invoices
files = glob.glob("invoices/*.pdf")
hashes = [hash_file(f) for f in files]
# Attest up to 100 hashes in one call
batch = client.batch_attest(hashes, source="monthly-invoices")
print(f"Created: {batch.created}, Existing: {batch.existing}")
# Each item includes full claim metadata
for att in batch.attestations:
print(f" {att['id']} claim={att['claim_id']} src={att['source']}")
# Later — verify the same files haven't been tampered with
current_hashes = [hash_file(f) for f in files]
results = client.batch_verify(current_hashes)
print(f"Found: {results.found}/{results.count}")
# Detailed mode — certification level + claim metadata (requires api_key)
results = client.batch_verify(current_hashes, detailed=True)
for r in results.results:
if r["exists"]:
print(f" {r['id']} level={r['level']} src={r['source']}")
Batch attest now returns full claim metadata per item (claim_id, source, extra_data, signing_key_id, etag).
Batch verify supports detailed=True for enriched results including certification level and tier timestamps. detailed mode requires API key authentication; without a key the API returns 401.
File Attestation
Hash any local file and attest the SHA-256 in one call.
result = client.attest_file("/path/to/document.pdf", source="uploads")
print(f"Attested: {result.hash_hex}")
Hashing Utilities
Standalone SHA-256 functions to compute hashes without attestation. Useful for pre-computing hashes, local verification, batch preparation, or audit workflows.
from certisigma import hash_file, hash_bytes, hash_string
# Hash a file (streamed in 8 KiB chunks — constant memory)
file_hash = hash_file("/path/to/document.pdf")
print(f"SHA-256: {file_hash}") # 64-char hex
# Hash raw bytes
data_hash = hash_bytes(b"raw content")
# Hash a string (UTF-8, no file needed)
str_hash = hash_string("SN-2026-001234")
# Pre-compute hashes for batch attestation
hashes = [hash_file(p) for p in file_paths]
batch = client.batch_attest(hashes, source="bulk-ingest")
| Function | Input | Returns |
|---|---|---|
hash_file(path) | str | Path | 64-char hex SHA-256 |
hash_bytes(data) | bytes | bytearray | memoryview | 64-char hex SHA-256 |
hash_string(text) | str | 64-char hex SHA-256 |
String Attestation
Attest identifiers, serial numbers, or any non-file content without creating temporary files. The string is hashed client-side (UTF-8); only the SHA-256 hash reaches the API.
hash_string("SN-001") and hash_string("SN-001\n") produce different hashes.
Cross-SDK determinism is guaranteed: Python and JavaScript produce identical hashes for the same input.
# One-step: hash + attest
result = client.attest_string("SN-2026-001234", source="serial-registry")
# Verify later
check = client.verify_string("SN-2026-001234")
assert check.exists
# Also works for files
check = client.verify_file("/path/to/document.pdf")
source is never auto-populated from the string content
(unlike attest_file which uses the filename). The string might contain sensitive data —
pass source explicitly if needed.
| Method | Auth Required | Description |
|---|---|---|
attest_string(text, source=, extra_data=) | Yes | Hash string + create attestation |
verify_string(text) | No | Hash string + verify attestation |
verify_file(path) | No | Hash file + verify attestation |
Metadata and Claims
Each API key owns its own claim on an attestation. Claims hold source (an optional label)
and extra_data (a JSON object). Multiple API keys can independently claim the same hash.
source is an optional plaintext operational label (max 100 chars) — designed for system identifiers like pipeline names or app versions, not for sensitive or personal data. If you don't need it, simply omit it. It is never exposed on public endpoints; only you (the authenticated API key owner) can read it. For sensitive metadata, use extra_data with client-side encryption.
# Update claim metadata
result = client.update_metadata(
"att_1234",
source="pipeline-v2",
extra_data={"project": "alpha", "version": "2.0"}
)
# Soft-delete claim
client.delete_metadata("att_1234")
# Get full cryptographic evidence
evidence = client.get_evidence("att_1234")
print(evidence.level)
ETag with every attestation.
When updating metadata via PATCH /attestation/{id}/metadata,
send If-Match: <etag> to prevent conflicting updates. On mismatch, the API returns 412 Precondition Failed.
Client-Side Encryption (Zero Knowledge)
Encrypt metadata with AES-256-GCM before sending. When client_encrypted=true, the server stores extra_data as an opaque blob and never sees plaintext. Requires pip install certisigma[crypto].
source? source is always plaintext by design — it's an optional operational label that the backend uses for audit logging and filtering. Encrypting it would make it unusable for those purposes. It is never exposed on public endpoints; only you (the authenticated claim owner) can read it. If you don't need it, omit it entirely. Never store PII in source — use encrypted extra_data instead.
from certisigma.crypto import generate_key, encrypt_metadata, decrypt_metadata
# Generate a key (store securely — server never sees it)
key = generate_key()
# Encrypt before sending
encrypted = encrypt_metadata({"secret": "classified"}, key)
result = client.attest(hash_hex, extra_data=encrypted, client_encrypted=True)
# Decrypt after retrieving (extra_data comes from the attest response, not verify)
plaintext = decrypt_metadata(result.extra_data, key)
Available crypto functions: generate_key(), encrypt_metadata(),
decrypt_metadata(), is_encrypted(), rotate_key().
RBAC & Scoped API Keys
API keys carry granular scopes that control access to specific operations. Keys created before RBAC was introduced are automatically backfilled with all scopes for backward compatibility.
| Scope | Grants |
|---|---|
attest | Create attestations (attest, batch_attest) |
batch | Batch operations + inventory stats (batch_attest, batch_verify, get_bulk_stats) |
metadata:read | Read claim metadata (get_metadata) |
metadata:write | Update/delete metadata (implies metadata:read) |
tags:read | Read tags (get_tags) |
tags:write | Create/update/delete tags (implies tags:read) |
share | Create and manage share tokens |
scan | Leak detection (scan) — requires org_id |
census | Derived lists (create_derived_list, etc.) — requires org_id |
webhook | Register, list, and delete webhooks (register_webhook, list_webhooks, delete_webhook, list_webhook_deliveries) |
verify, status, get_evidence, and health are public and require no scope (or no API key at all).
metadata:write automatically grants metadata:read; tags:write automatically grants tags:read. You do not need to request both.
Share Tokens (Forensic Metadata Sharing)
Create time-limited, auditable, revocable tokens for sharing attestation metadata with forensic analysts, auditors, or third-party verifiers. Share tokens grant read-only access to a specific set of attestations.
token = client.create_share_token(
attestation_ids=[42, 43, 44],
expires_in=86400, # 24 hours (max 30 days)
recipient_label="forensic-analyst",
max_uses=10,
)
print(f"Share token: {token.share_token}") # shown once, save it
# List, inspect, revoke
tokens = client.list_share_tokens()
info = client.get_share_token_info(token.id)
client.revoke_share_token(token.id)
| Constraint | Value |
|---|---|
| Max expiry | 30 days |
| Max attestation IDs | 100 per token |
| Max uses | Optional (unlimited if unset) |
| Rate limit | 100 req/min (separate from API key rate limit) |
| Required scope | share |
metadata:read and tags:read only for the specified attestation IDs. All access is logged in the share access log.
Structured Tagging
Classify attestations with multi-dimensional key-value tags. Tags support server-side querying (AND semantics) and optional client-side encryption for sensitive values.
# Upsert tags (max 50 per attestation per key)
client.put_tags("att_42", tags=[
{"key": "department", "value": "hr"},
{"key": "classification", "value": "confidential"},
])
tags = client.get_tags("att_42")
# Query by tags (AND semantics, max 10 conditions)
results = client.query_tags(
filter={"and": [
{"key": "department", "value": "hr"},
{"key": "classification", "value": "confidential"},
]},
limit=100,
)
print(f"Found {results.count} matching attestations")
# Delete a tag
client.delete_tag("att_42", "classification")
| Constraint | Value |
|---|---|
| Max tags per attestation per key | 50 |
| Tag key format | ^[a-z][a-z0-9_-]{0,62}$ (lowercase, no _ prefix — reserved) |
| Max AND conditions per query | 10 |
| Client-encrypted tags | Supported (value_enc + value_nonce hex), but excluded from server-side query |
| Required scope (read) | tags:read |
| Required scope (write) | tags:write |
Census — Leak Detection & Inventory Stats
Compare suspect hashes against your organization's attested inventory for leak detection, and retrieve aggregate inventory statistics. Both endpoints require an API key with org_id configured.
from certisigma import CertiSigmaClient
client = CertiSigmaClient(api_key="cs_live_...")
# Leak detection — compare suspect hashes against org inventory
# Requires `scan` scope + org_id
suspect_hashes = ["a665a459...", "b4c9a289...", "e3b0c442..."]
result = client.scan(suspect_hashes)
print(f"Scan {result.scan_id}: {result.matched}/{result.total_scanned} matched ({result.match_rate:.1%})")
for m in result.matches:
print(f" {m.hash_hex} first_seen={m.first_seen} source={m.source}")
# Inventory statistics — aggregate view of your org's claims
# Requires `batch` scope + org_id
stats = client.get_bulk_stats()
print(f"Org {stats.org_id_hash}: {stats.total_claims} claims, {stats.unique_hashes} unique hashes")
print(f"First claim: {stats.first_claim}, Last: {stats.last_claim}")
for month, count in stats.claims_by_month.items():
print(f" {month}: {count} claims")
Scan uses a temp-table set intersection for performance on large inputs. The maximum number of hashes per scan is configured server-side (default 50K). Results include source and first_seen timestamp per match. Rate limited to 5 scans/hour per key.
Inventory stats returns total claims, unique hashes, date range, and monthly breakdown — scoped to your organization.
Census — Derived Lists
Create opaque HMAC-SHA256 derived lists for third-party hash verification without revealing your organization's hash inventory. Lists are signed by CertiSigma's ECDSA key for integrity verification. Requires census scope and org_id on the API key.
import hashlib, hmac
# 1. Create a derived list from explicit hashes
dl = client.create_derived_list(
hashes=["a665a459...", "b4c9a289..."],
label="Partner audit Q1",
expires_in_hours=720,
)
print(f"List ID: {dl.id}")
print(f"List key (save now!): {dl.list_key}")
# 2. Or create from tag filter
dl2 = client.create_derived_list(
tag_filter={"and": [{"key": "department", "value": "hr"}]},
label="HR confidential subset",
)
# 3. Third party: match files against the list
# (can be done WITHOUT an API key — only needs list_key)
from certisigma import CertiSigmaClient
public = CertiSigmaClient()
file_hash = "a665a459..."
derived = hmac.new(
bytes.fromhex(dl.list_key),
file_hash.encode(),
hashlib.sha256,
).hexdigest()
result = public.match_derived_list(dl.id, dl.list_key, [derived])
print(f"Matched: {result.matched}/{result.total}")
# 4. Verify list signature (public)
sig = public.get_derived_list_signature(dl.id)
print(f"Signed by: {sig.signing_key_id}")
# 5. Owner: list, inspect, revoke
lists = client.list_derived_lists()
detail = client.get_derived_list(dl.id)
log = client.get_derived_list_access_log(dl.id)
client.revoke_derived_list(dl.id)
| Constraint | Value |
|---|---|
| Max items per list | 100,000 (server-configured) |
| Max TTL | 2,160 hours (90 days, server-configured) |
| Max hashes per match | 50,000 (server-configured) |
| Match rate limit | 20 requests/hour per list (server-configured) |
| Match endpoint auth | Public (requires list_key) |
| Signature endpoint auth | Public |
| Required scope | census |
| Required key config | org_id must be set |
GET /keys. Lists can be time-limited and revoked at any time.
Read Metadata
Explicit metadata read without re-verifying the attestation:
meta = client.get_metadata("att_42")
print(f"Source: {meta.source}, Extra: {meta.extra_data}")
Requires metadata:read scope. Share tokens with the matching attestation ID can also call this endpoint.
Verification Examples
# Verify a single hash
check = client.verify("e3b0c44...")
if check.exists:
print(f"Attested at {check.timestamp}, Level: {check.level}")
# Verify from a saved manifest
import json
with open("contract.pdf.certisigma.json") as f:
manifest = json.load(f)
check = client.verify(manifest["hash"])
print(f"Level: {check.level}")
# Batch verify (up to 100)
results = client.batch_verify(["aabb...", "ccdd..."])
for item in results.results:
print(f"{item.hash_hex}: {item.exists}")
Public Verification (No API Key)
Verification endpoints are public — anyone can verify an attestation without an API key. This enables third-party audits, compliance checks, and independent verification without sharing credentials.
from certisigma import CertiSigmaClient, hash_file
# No api_key needed — public verification
client = CertiSigmaClient()
file_hash = hash_file("contract.pdf")
check = client.verify(file_hash)
print(f"Exists: {check.exists}, Level: {check.level}")
# Batch verify also works without a key
results = client.batch_verify([file_hash])
print(f"Found: {results.found}/{results.count}")
verify returns only cryptographic proof (hash, timestamp, level, signature) — no source or extra_data. With API key, it also returns your claim's metadata when you own a claim on that hash.
verify, batch_verify, status, get_evidence, and health work without a key. Calling attest, batch_attest, update_metadata, or delete_metadata without an api_key raises AuthenticationError immediately — the request is never sent.
status() Response Shape
Returns the current trust tier of an attestation without the full evidence payload. Useful for dashboards, progress tracking, and lightweight polling:
| Field | Type | Description |
|---|---|---|
id | str | Attestation ID (att_...) |
hash_hex | str | SHA-256 hash |
level | "T0" | "T1" | "T2" | Current trust tier |
t0_timestamp | str | ECDSA signature timestamp |
t1_timestamp | str? | TSA / Merkle batch timestamp |
t2_timestamp | str? | Bitcoin anchor timestamp |
t2_bitcoin_block | int? | Bitcoin block height |
signature_available | bool | T0 ECDSA signature present |
merkle_proof_available | bool | T1 Merkle proof present |
ots_available | bool | OTS proof downloadable |
verify vs status vs get_evidence
| Method | Input | Auth | Returns | Best for |
|---|---|---|---|---|
verify(hash) | SHA-256 hash | Optional | Existence, level, timestamp, signature | Compliance checks, "does this hash exist?" |
status(att_id) | Attestation ID | No | Trust tier, availability flags, timestamps | Dashboards, progress tracking, polling |
get_evidence(att_id) | Attestation ID | No | Full T0+T1+T2 proofs, Merkle path, OTS | Independent verification, long-term archival |
Rate Limits
The API enforces per-key rate limits using a sliding window:
| Limit | Default | Notes |
|---|---|---|
| Requests / minute | 1,000 | Configurable per key by admin |
| Monthly quota | Unlimited | Optional, per plan |
| Batch max size | 100 | Hashes per batch call |
When rate-limited, the API returns 429 Too Many Requests with these headers:
Retry-After: 60X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
The SDK automatically raises RateLimitError with a retry_after property.
import time
from certisigma import RateLimitError
def attest_with_backoff(client, hash_hex, max_retries=3):
for attempt in range(max_retries):
try:
return client.attest(hash_hex)
except RateLimitError as e:
wait = e.retry_after * (2 ** attempt)
time.sleep(wait)
raise RuntimeError("Max retries exceeded")
Public Endpoint Protection
Public endpoints (verify, status, get_evidence) do not require an API key and are not subject to per-key rate limits. They are protected at the infrastructure level:
| Layer | Scope | Limit |
|---|---|---|
| Application | Per API key | 1,000 req/min (authenticated endpoints only) |
| Infrastructure | Per client IP | Reverse proxy rate limiting on all endpoints (public and authenticated) |
Abusive traffic (automated scraping, enumeration floods) is mitigated by infrastructure-level IP rate limiting. Legitimate verification traffic at normal volumes is never throttled.
Polling vs Webhooks
Attestations progress through levels asynchronously. Two patterns to track upgrades:
Polling (simple, low-volume)
import time
def wait_for_t1(client, att_id, timeout=600):
start = time.time()
while time.time() - start < timeout:
status = client.status(att_id)
if status.level in ("T1", "T2"):
return status
time.sleep(30)
raise TimeoutError("T1 not reached")
Webhooks (production, real-time)
Register a webhook via the API (POST /webhook/register) to get notified when an attestation reaches T1 or T2.
| Event | When | Payload includes |
|---|---|---|
t1_complete | TSA timestamp obtained | attestation_id, hash_hex, tsa_timestamp |
t2_complete | Bitcoin anchor confirmed | attestation_id, hash_hex, bitcoin_block, confirmed_at |
Delivery Headers
Each webhook delivery includes these HTTP headers:
| Header | Description |
|---|---|
X-CertiSigma-Event | Event type (t1_complete or t2_complete) |
X-CertiSigma-Delivery | Unique numeric delivery ID — use for deduplication |
X-CertiSigma-Signature | sha256=<hex> HMAC-SHA256 of the raw JSON body |
Verify the X-CertiSigma-Signature header using the SDK helper (constant-time HMAC-SHA256):
from certisigma import verify_webhook_signature
is_valid = verify_webhook_signature(raw_body, signature_header, signing_secret)
# Uses hmac.compare_digest internally — timing-attack resistant
list_webhook_deliveries(webhook_id) or GET /webhook/{id}/deliveries to inspect delivery history.
Delivery Semantics
- At-least-once delivery: Your endpoint may receive the same event more than once (e.g. on network retries). Use
attestation_idfrom the payload as an idempotency key to deduplicate. - Ordering: Deliveries are sent in best-effort chronological order but ordering is not guaranteed. Do not rely on delivery order for state transitions.
- Anti-replay: Check the
created_attimestamp in the payload and reject deliveries older than your tolerance window (e.g. 5 minutes). The delivery ID (X-CertiSigma-Delivery) can also be tracked to detect replays.
Consumer Reference Pattern
import time
seen_deliveries = set() # In production, use Redis or a DB table
MAX_AGE_SECONDS = 300 # 5-minute anti-replay window
def handle_webhook(headers, body):
delivery_id = headers["X-CertiSigma-Delivery"]
if delivery_id in seen_deliveries:
return 200 # Already processed — idempotent ACK
created = body["created_at"] # ISO 8601 from payload
age = time.time() - parse_iso(created).timestamp()
if age > MAX_AGE_SECONDS:
return 200 # Stale — ACK to stop retries
if not verify_webhook_signature(raw_body, headers["X-CertiSigma-Signature"], secret):
return 401 # Bad signature
process_event(body["attestation_id"], headers["X-CertiSigma-Event"])
seen_deliveries.add(delivery_id)
return 200
attestation_id + event type identifies the event (what happened). X-CertiSigma-Delivery identifies the delivery attempt (retries share the same delivery ID). Deduplicate on delivery ID to handle retries; use attestation ID for business logic.
Secret Rotation
To rotate a webhook signing secret: register a new webhook with the same URL and events, verify it receives deliveries, then delete the old webhook. In-flight retries for the old webhook continue using the original secret.
Best practice: Use webhooks as the primary notification channel and polling as a fallback for missed events.
Webhook Management
The SDK exposes 4 methods for managing webhook subscriptions and a helper for verifying delivery signatures.
All methods require an API key with the webhook scope.
Register a Webhook
result = client.register_webhook(
url="https://my-server.com/census-hook",
events=["t1_complete", "t2_complete"],
label="census-watch-prod", # optional, for identification
)
# result.id → "wh_abc123"
# result.signing_secret → "a1b2c3..." (shown once — store securely)
# result.label → "census-watch-prod"
List Webhooks
result = client.list_webhooks()
for wh in result.webhooks:
print(wh.id, wh.url, wh.label, wh.failure_count)
Delete a Webhook
result = client.delete_webhook("wh_abc123")
# result.deleted → True
List Delivery History
result = client.list_webhook_deliveries("wh_abc123")
for d in result.deliveries:
print(d.event_type, d.status, d.response_code)
Verify Webhook Signature
from certisigma import verify_webhook_signature
# In your webhook handler (Flask, FastAPI, etc.):
is_valid = verify_webhook_signature(
payload=request.get_data(), # raw bytes
signature=request.headers["X-CertiSigma-Signature"],
secret=signing_secret, # from register_webhook().signing_secret
)
| Constraint | Value |
|---|---|
| Events | t1_complete, t2_complete |
| Signing secret | 64-char hex, shown once at registration — store securely |
| URL scheme | HTTPS required in production; HTTP allowed in demo mode |
| Label | Optional, max 200 characters |
| Delivery history | Last 100 deliveries per webhook |
Evidence & OTS Verification
Once an attestation reaches T2, the Merkle root has been anchored to the Bitcoin blockchain
via OpenTimestamps.
Use get_evidence() to retrieve the full cryptographic bundle, then use the SDK helpers
to inspect the Bitcoin block or save the raw .ots proof file.
Recommended Workflow
- Register a
t2_completewebhook (or poll withstatus()) - When notified, call
get_evidence(att_id) - Save the updated manifest including T2 data
- Optionally save the
.otsproof file for independent verification
from certisigma import CertiSigmaClient, get_blockchain_url, save_ots_proof
import json
client = CertiSigmaClient(api_key=os.environ.get("CERTISIGMA_API_KEY"))
# Retrieve the full evidence bundle
evidence = client.get_evidence("att_1234")
print(f"Level: {evidence.level}") # "T0", "T1", or "T2"
if evidence.level == "T2":
# Get the Bitcoin block explorer link
block_url = get_blockchain_url(evidence) # mempool.space/block/...
tx_url = get_blockchain_url(evidence, type="tx") # mempool.space/tx/...
print(f"Block: {block_url}")
print(f"Tx: {tx_url}")
print(f"Height: {evidence.t2['bitcoinBlockHeight']}")
# Save the raw .ots proof (for independent OTS verification)
save_ots_proof(evidence, "contract.pdf.ots")
# Update your local manifest
with open("contract.pdf.certisigma.json") as f:
manifest = json.load(f)
manifest["level"] = evidence.level
manifest["t2"] = evidence.t2
with open("contract.pdf.certisigma.json", "w") as f:
json.dump(manifest, f, indent=2)
Evidence Response Fields
| Field | Type | Description |
|---|---|---|
hash_hex | str | SHA-256 hash of the attested data |
level | str | Highest confirmed level: T0, T1, or T2 |
| t0 — ECDSA Signature (immediate) | ||
t0.signature | str | Base64-encoded ECDSA P-256 signature |
t0.algorithm | str | ECDSA-P256-SHA256 |
t0.publicKeyId | str | Signing key identifier for key pinning |
t0.registeredAt | str | ISO 8601 timestamp of registration |
| t1 — TSA Timestamp + Merkle Proof (minutes) | ||
t1.batchId | int | T1 batch identifier |
t1.merkleRoot | str | Merkle root hash of the batch |
t1.merkleProof | list | Merkle inclusion proof path (leaf → root) |
t1.tsaProvider | str | TSA provider (e.g. sectigo_qualified) |
t1.tsaTimestamp | str | RFC 3161 timestamp (ISO 8601) |
t1.tsaToken | str | Base64 TSA token for independent verification |
| t2 — Bitcoin Anchor via OpenTimestamps (hours) | ||
t2.dailyRoot | str | Daily Merkle root anchored to Bitcoin |
t2.t1ToT2Proof | list | Merkle proof linking T1 batch root to T2 daily root |
t2.bitcoinBlockHeight | int | Bitcoin block number containing the anchor |
t2.bitcoinBlockHash | str | Bitcoin block hash |
t2.bitcoinTxid | str | Bitcoin transaction ID |
t2.confirmedAt | str | ISO 8601 confirmation timestamp |
t2.otsProof | str | Raw OpenTimestamps proof (Base64-encoded binary) |
SDK Helper Functions
| Function | Input | Returns |
|---|---|---|
get_blockchain_url(evidence) | EvidenceResult | mempool.space block URL, or None |
get_blockchain_url(evidence, type="tx") | EvidenceResult | mempool.space transaction URL, or None |
save_ots_proof(evidence, path) | EvidenceResult, str | True if saved, False if T2 not ready |
Independent OTS Verification
The saved .ots file is a standard
OpenTimestamps proof.
Verify it independently — no CertiSigma involvement needed:
# Install the OpenTimestamps client
pip install opentimestamps-client
# Verify the proof against the Bitcoin blockchain
ots verify contract.pdf.ots
# Or verify online at https://opentimestamps.org
.ots proof ties directly to a Bitcoin block — immutable, decentralized, permanent.
get_blockchain_url(evidence) to get a direct link to
mempool.space, where anyone can independently
verify the Bitcoin block and transaction containing your anchor.
Error Handling
from certisigma import (
CertiSigmaError,
AuthenticationError,
RateLimitError,
QuotaExceededError,
)
try:
client.attest(hash_hex)
except AuthenticationError:
print("Invalid API key")
except RateLimitError as e:
print(f"Rate limited, retry after {e.retry_after}s")
except QuotaExceededError:
print("Monthly quota reached")
except CertiSigmaError as e:
print(f"API error {e.status_code}: {e}")
Error Envelope
All API errors return a consistent JSON envelope:
{ "error": true, "code": "ERROR_CODE", "message": "Human-readable description", "details": null }
Error Codes & Retry Guidance
| HTTP | Code | Retryable | Action |
|---|---|---|---|
| 400 / 422 | VALIDATION_ERROR | No | Fix request payload |
| 401 | AUTH_ERROR | No | Check API key |
| 403 | FORBIDDEN | No | Insufficient permissions or scope |
| 404 | NOT_FOUND | No | Resource does not exist |
| 429 | RATE_LIMIT | Yes | Back off using Retry-After header |
| 500 | INTERNAL_ERROR | Yes | Retry with exponential backoff |
429 and 5xx. Fail fast on all other 4xx errors — they indicate a client-side issue that retrying will not resolve. The SDK exposes e.status_code and e.retry_after (on RateLimitError) for implementing backoff logic.
Example Error Responses
{ "error": true, "code": "RATE_LIMIT", "message": "Rate limit exceeded", "details": null }
// Headers: Retry-After: 12, X-RateLimit-Remaining: 0
{ "error": true, "code": "AUTH_ERROR", "message": "Invalid or missing API key", "details": null }
{ "error": true, "code": "VALIDATION_ERROR", "message": "hash_hex must be exactly 64 lowercase hex characters", "details": null }
{ "error": true, "code": "INTERNAL_ERROR", "message": "An internal error occurred", "details": null }
X-Request-Id header. You can send your own X-Request-Id in the request — the API echoes it back. If omitted, the server generates one automatically. Use it for technical support/debugging — include it when contacting support. For domain audit, use attestation_id — it identifies the attested record, not the HTTP request.
Security Best Practices
- Environment variables: Store API keys in
CERTISIGMA_API_KEY, never in code. - Client-side encryption: Use
encrypt_metadata()for sensitive data inextra_data. - Key rotation: Use
rotate_key(envelope, old_key, new_key)to re-encrypt without decrypting on the server. - Data isolation: Attestation IDs are sequential and public by design. Public endpoints (
verify,get_evidence,status) return only cryptographic proof (hash, signature, Merkle, OTS). Organizational metadata (source,extra_data) is never exposed publicly — only the authenticated API key owner can read their own claim data. - IP Allowlist: Restrict API key usage to known IP ranges.
- Webhook secrets: Store the
signing_secretin a secrets manager. It is shown only once. - Audit trail: Log all attestation IDs and hashes locally for independent auditing.
When to Use What
| Use case | Recommended approach |
|---|---|
| One-off file attestation | attest_file(path) |
| Serial number / identifier attestation | attest_string(text) — hash client-side, no temp file |
| Bulk ingestion pipeline | hash_file() + batch_attest() |
| Audit / compliance check | verify() or batch_verify() (no API key needed) |
| Verify a specific file or string | verify_file(path) or verify_string(text) |
| Leak detection (exfiltration) | scan(suspect_hashes) — compare against org inventory |
| Inventory dashboard | get_bulk_stats() — total claims, monthly breakdown |
| Long-term proof export | get_evidence() + save_ots_proof() |
| Sensitive metadata | Client-side encryption with encrypt_metadata() |
| High-volume async notifications | Webhooks (t1_complete, t2_complete), not polling |
| Blockchain verification URL | get_blockchain_url(evidence) |
T0 Signature Format
Every attestation receives an immediate ECDSA-P256-SHA256 signature (T0). To verify independently:
| Payload component | Evidence JSON field | Invariant |
|---|---|---|
format_version | t0.formatVersion | Semver string, currently 2.0.0 |
hash_hex | hash_hex | Lowercase hex, exactly 64 characters (SHA-256) |
registered_at | t0.registeredAt | ISO 8601, microsecond precision, UTC offset +00:00(e.g. 2026-03-16T12:00:00.000000+00:00) |
format_version, registered_at). The evidence JSON response uses camelCase (t0.formatVersion, t0.registeredAt). When reconstructing the payload for verification, read the camelCase fields from the evidence and place them into the pipe-separated payload.
Payload construction: {format_version}|{hash_hex}|{registered_at} — pipe U+007C, no whitespace, UTF-8 encoded.
Signing: SHA-256 of the UTF-8 payload bytes, then ECDSA P-256 over the hash (prehashed). Signature is base64-encoded DER.
Independent Verification Steps
- Retrieve public keys from GET /keys.
- Match the key using
t0.publicKeyIdfrom your evidence. - Reconstruct payload:
f"{evidence.t0['formatVersion']}|{evidence.hash_hex}|{evidence.t0['registeredAt']}" - Compute SHA-256 of the UTF-8 payload bytes.
- Verify the ECDSA-P256-SHA256 signature (
t0.signature, base64-decoded DER) using the JWK public key.
Server-Side Key Rotation
GET /keys returns all active signing keys. Old attestations reference the key that was active at signing time. Always match via t0.publicKeyId; never assume a single key.
Test Vectors
Canonical test vectors from a production attestation (att_500). Use these to validate any independent verification implementation against real CertiSigma output.
T0 — ECDSA Signature Verification
| Field | Value |
|---|---|
hash_hex | 7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac |
t0.formatVersion | 2.0.0 |
t0.registeredAt | 2026-02-28T00:33:19.291619+00:00 |
t0.publicKeyId | certisigma-3cb3ab64f5cfc983 |
t0.signature | MEQCIGuKNpN1MERfn7jeJW4aRG2om7nIp2eRz/GHueM/Hi9qAiA2ik/6not2lJUWnYGpQ6x9xouOt8j8Cr42vBVSrSeN+Q== |
Step 1 — Reconstruct canonical payload
2.0.0|7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac|2026-02-28T00:33:19.291619+00:00
Step 2 — SHA-256 of canonical payload
f5203ed4089a3bd3367f9301de43db93b574e92e1d597227c738d5338cd5ae01
Step 3 — Public key (JWK from GET /keys)
{
"kty": "EC", "crv": "P-256", "alg": "ES256",
"kid": "certisigma-3cb3ab64f5cfc983",
"x": "7AKMIyF5Rm3SfCASYsJsBHvZhF9cqtjEcmbu5DL8wA8",
"y": "94l-xPf8HVkp03XELgIeDvsBDtWTL8tj7glvfjFU1Fk"
}
Step 4 — Verify ECDSA-P256-SHA256
import hashlib, base64
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes
# Evidence fields
hash_hex = "7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac"
version = "2.0.0"
registered = "2026-02-28T00:33:19.291619+00:00"
sig_b64 = "MEQCIGuKNpN1MERfn7jeJW4aRG2om7nIp2eRz/GHueM/Hi9qAiA2ik/6not2lJUWnYGpQ6x9xouOt8j8Cr42vBVSrSeN+Q=="
# Public key (base64url → bytes)
def b64url(s):
return base64.urlsafe_b64decode(s + "==")
x = b64url("7AKMIyF5Rm3SfCASYsJsBHvZhF9cqtjEcmbu5DL8wA8")
y = b64url("94l-xPf8HVkp03XELgIeDvsBDtWTL8tj7glvfjFU1Fk")
pub = ec.EllipticCurvePublicNumbers(
int.from_bytes(x, "big"),
int.from_bytes(y, "big"),
ec.SECP256R1()
).public_key()
# Reconstruct + hash + verify
payload = f"{version}|{hash_hex}|{registered}".encode("utf-8")
digest = hashlib.sha256(payload).digest()
sig_der = base64.b64decode(sig_b64)
pub.verify(sig_der, digest, ec.ECDSA(utils.Prehashed(hashes.SHA256())))
# Alternative without Prehashed: pass payload directly, let the library hash
# pub.verify(sig_der, payload, ec.ECDSA(hashes.SHA256()))
print("✅ T0 signature verified")
Prehashed. Most crypto libraries hash internally when verifying — in that case, pass the raw payload (not the digest) and let the library compute SHA-256. If you pass a pre-computed digest to a library that hashes internally, you get a double-hash and verification fails. Python cryptography: use Prehashed(SHA256()) with digest, or omit it and pass payload. WebCrypto / Node.js subtle.verify: always pass payload (WebCrypto has no prehash mode).
T1 — Merkle Inclusion Proof (RFC 6962 v2)
| Field | Value |
|---|---|
| Leaf hash | 7fd9101e...cf0aac (same as hash_hex) |
| Merkle root | 1ccb4cfd5ea666d2f7418f104cebf200ef258b784dc483698335a084cf0f1d67 |
| Merkle version | 2 (RFC 6962 domain separation: H(0x01 || left || right)) |
| Proof path | 3 steps (see below) |
Leaf: 7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac
Step 0: H(0x01 || 63792cf5...b8e026 || 7fd9101e...cf0aac) → 844370b7ddafe4f9...
Step 1: H(0x01 || 82950bd8...631955 || 844370b7...e4f9..) → 4529ddc8d19457a9...
Step 2: H(0x01 || 4529ddc8...57a9.. || a35f3d52...27f35e) → 1ccb4cfd5ea666d2...
Computed root: 1ccb4cfd5ea666d2f7418f104cebf200ef258b784dc483698335a084cf0f1d67 ✅
import hashlib
leaf = "7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac"
root = "1ccb4cfd5ea666d2f7418f104cebf200ef258b784dc483698335a084cf0f1d67"
proof = [
{"hash": "63792cf512a74247fe1ac05e25494f40143fe0f6da9b1db855e9444035b8e026", "position": "left"},
{"hash": "82950bd8a3b929aaf12905e2457c91240c9f479a64888a1bd53fbdee23631955", "position": "left"},
{"hash": "a35f3d526cbffff89d8099580271cb855109e84d357ed4cf31a46f7d0427f35e", "position": "right"},
]
current = bytes.fromhex(leaf)
for step in proof:
sibling = bytes.fromhex(step["hash"])
if step["position"] == "left":
current = hashlib.sha256(b"\x01" + sibling + current).digest()
else:
current = hashlib.sha256(b"\x01" + current + sibling).digest()
assert current.hex() == root, "Merkle proof failed"
print("✅ Merkle inclusion verified")
T2 — Bitcoin Anchor
| Field | Value |
|---|---|
| Daily root | b7e7f6bfcd2419864d5021df4a0c9345faa48b9a4de0e1f45ee6158ffb5e02e0 |
| Bitcoin block | 938792 |
| Confirmed at | 2026-03-01T03:53:18.079007+00:00 |
| OTS proof | Binary proof available via GET /attestation/att_500/ots/proof |
.ots proof file and verify with any OpenTimestamps-compatible tool (e.g. ots verify att_500.ots). The proof chains from the daily Merkle root to a Bitcoin block header, providing mathematical proof of existence before that block's timestamp.
Negative Test Vector (must fail)
Same attestation with registeredAt altered by one microsecond (291619 → 291620). Any correct implementation must reject this signature:
altered = "2.0.0|7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac|2026-02-28T00:33:19.291620+00:00"
try:
bad_digest = hashlib.sha256(altered.encode()).digest()
pub.verify(sig_der, bad_digest, ec.ECDSA(utils.Prehashed(hashes.SHA256())))
print("❌ BUG: should have failed")
except:
print("✅ Correctly rejected altered timestamp")
Machine-Readable Test Vectors
All test vectors are available as a single JSON file for automated testing:
GET /test-vectors.json — contains T0 (positive + negative), T1, and T2 data with all raw values copy-safe.
Security & Privacy Model
CertiSigma is a public attestation platform. Cryptographic proofs are intentionally public; organizational metadata is strictly private. This section formalizes the boundary.
| Category | Visibility | Details |
|---|---|---|
| Attestation IDs | Public | Sequential (att_1, att_2, …). Discoverable by design — any party can look up any ID. |
| SHA-256 hash | Public | The attested hash is visible on public endpoints. It does not reveal file contents (preimage resistance). |
| ECDSA signature (T0) | Public | Verifiable by anyone via GET /keys. |
| Merkle proof (T1) | Public | Inclusion proof is publicly verifiable. |
| OTS / Bitcoin anchor (T2) | Public | Downloadable .ots proof, verifiable against Bitcoin blockchain. |
| Timestamps | Public | registeredAt, t1_timestamp, t2_timestamp are always visible. |
source | Private | Only returned to the authenticated API key owner who created the claim. |
extra_data | Private | Only returned to the authenticated API key owner. Use encrypt_metadata() for defense in depth. |
| Tags | Private | Scoped per API key. Other keys cannot see or query your tags, even on the same attestation. Supports client-side encryption for sensitive values. |
| Claim existence | Private | Public endpoints show the attestation exists, not which organizations hold claims on it. |
Threat Model
The sequential nature of attestation IDs means an external party can enumerate all attestation IDs and retrieve their public cryptographic proofs. This is accepted by design because:
- CertiSigma is an attestation platform — public verifiability is the core value proposition.
- Public endpoints return only cryptographic proof (hash, signature, Merkle, OTS). No organizational metadata is ever exposed.
- The SHA-256 hash is a one-way function — knowing the hash does not reveal the attested content.
- For sensitive metadata, client-side encryption (AES-256-GCM) ensures the server itself never sees plaintext.
What the Server Sees
| Data | Server has access? |
|---|---|
| SHA-256 hash | Yes — required for attestation |
| Original file / content | Never — only the hash is sent |
source (optional label) | Yes, if provided — plaintext string, not encryptable. Do not include PII. |
extra_data (plaintext) | Only if the client chooses not to encrypt. The SDK supports AES-256-GCM client-side encryption — use it for any sensitive metadata. |
extra_data (client-encrypted) | Never — server stores only the AES-256-GCM ciphertext, cannot decrypt |
| API key identity | Yes — required for authenticated endpoints |
| Client IP (general API) | Seen at transport level — used for infrastructure rate limiting. Not stored in any application-level database table for normal API operations (attest, verify, metadata, tags). |
| Client IP (forensic sharing) | When a third party accesses data via share token or performs a derived list match, the IP is recorded in the access log. This log is visible only to the data owner (the API key holder who created the token/list). This enables forensic audit of who accessed shared data. |
extra_data contains PII or business-sensitive information, always use client-side encryption. This provides defense in depth: even if the server were compromised, the attacker would only see ciphertext. source is always plaintext but optional and never publicly visible — only you (the authenticated API key owner) can read it. If in doubt, omit it.
Configuration Reference
| Parameter | Default | Description |
|---|---|---|
api_key | None | Bearer token (cs_live_... or cs_demo_...). Optional for verify/health. |
base_url | https://api.certisigma.ch | API endpoint |
timeout | 30.0 | Request timeout in seconds |
Requirements
- Python 3.10+
httpx>= 0.25.0cryptography>= 44.0.0 (optional, forcertisigma.crypto)
Advanced Configuration
| Topic | Details |
|---|---|
| Proxy | httpx respects HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment variables automatically. |
| Custom TLS CA | System CA bundle by default. For custom CAs (e.g. corporate proxy), configure httpx with verify="/path/to/ca-bundle.pem". |
| Retry | Not built-in. Implement at caller level with exponential backoff on 429 / 5xx (see Error Handling above). |
| Observability | No built-in hooks. Use httpx event hooks for request/response logging. Redact the Authorization header in logs. |
Resilient Client Wrapper
Reference implementation with retry, backoff, and logging:
import time, logging, os
from certisigma import CertiSigmaClient, CertiSigmaError, RateLimitError
log = logging.getLogger("certisigma")
def resilient_call(fn, *args, retries=3, **kwargs):
"""Retry on 429/5xx with exponential backoff and jitter."""
for attempt in range(retries):
try:
result = fn(*args, **kwargs)
log.info("OK %s attempt=%d", fn.__name__, attempt + 1)
return result
except RateLimitError as e:
wait = e.retry_after or 2 ** attempt
log.warning("429 retry_after=%s attempt=%d", wait, attempt + 1)
time.sleep(wait)
except CertiSigmaError as e:
if e.status_code and e.status_code >= 500 and attempt < retries - 1:
wait = 2 ** attempt
log.warning("5xx status=%s attempt=%d", e.status_code, attempt + 1)
time.sleep(wait)
else:
raise
raise RuntimeError("Max retries exceeded")
client = CertiSigmaClient(api_key=os.environ["CERTISIGMA_API_KEY"])
result = resilient_call(client.attest, hash_hex, source="pipeline")
Sandbox vs Production
| Aspect | Sandbox (cs_demo_...) | Production (cs_live_...) |
|---|---|---|
| ECDSA signing | Ephemeral key (may rotate on restart) | Persistent key pair |
| T1 (TSA) | Free TSA provider | Qualified TSA |
| T2 (Bitcoin) | Same anchoring pipeline | Same anchoring pipeline |
| Webhook URLs | http:// allowed | https:// required |
| Rate limit | 1,000/min (default) | 1,000/min (configurable per key/org) |
| Data retention | Persistent (same DB) | Persistent |
| OpenAPI explorer | Available at /docs | Disabled |
To promote an integration from sandbox to production, replace the api_key with a cs_live_... key. No code changes required.
Package
PyPI certisigma — MIT License — Ten Sigma Sagl
Compatibility Policy
- Semver: This SDK follows Semantic Versioning 2.0.0. Patch releases contain only bug fixes; minor releases add features without breaking changes; major releases may contain breaking changes.
- API stability: The SDK targets API
/v1/. When a new API version is introduced (e.g./v2/), the previous version will remain supported for a minimum of 12 months. - SDK–API mapping: SDK v1.x targets API v1. New API features are supported in minor SDK releases.
- Support window: The latest minor release receives bug fixes and patches. The previous minor release receives critical security patches for 6 months after the next minor release.
- Deprecation: Deprecated features are marked in the changelog and emit warnings for at least one minor version before removal.
Changelog
v1.10.0
- New:
hash_string()— SHA-256 of UTF-8 strings (no temp file needed) - New:
attest_string()— hash + attest in one call (source never auto-populated) - New:
verify_string()— hash + verify for strings - New:
verify_file()— hash + verify for files (symmetry withattest_file()) - Cross-SDK determinism: Python and JavaScript produce identical hashes for the same string input
v1.6.0
- New:
create_share_token(),list_share_tokens(),get_share_token_info(),revoke_share_token()— forensic metadata sharing with time-limited, auditable tokens - New:
put_tags(),get_tags(),delete_tag(),query_tags()— structured multi-dimensional tagging with server-side AND queries (max 10 conditions) - New:
get_metadata()— explicit metadata read without re-verifying - New: RBAC scoped keys —
metadata:read,metadata:write,tags:read,tags:write,share - New:
batch_verify(detailed=True)— returns full claim metadata in batch mode (requires API key)
v1.5.0
- New: request ID header (
X-Request-ID) included in all requests
v1.4.0
- New:
get_blockchain_url(evidence)— returns a mempool.space URL for the Bitcoin block or transaction - New:
save_ots_proof(evidence, path)— saves the raw OpenTimestamps.otsproof to a local file
v1.3.0
- New:
api_keyis now optional — public endpoints (verify,batch_verify,health) work without authentication - New:
AuthenticationErrorraised immediately if auth-required method called without key (no request sent)
v1.2.0
- New: standalone
hash_file()andhash_bytes()utility functions - Fix: PyPI project URL now links to SDK documentation
v1.1.1 — Initial Public Release
- Full API coverage: attest, verify, batch, file attestation, metadata, evidence, status, health
- Sync (
CertiSigmaClient) and async (AsyncCertiSigmaClient) clients - Client-side AES-256-GCM encryption with key rotation support
- Typed error hierarchy:
AuthenticationError,RateLimitError,QuotaExceededError - Full type annotations (
py.typedmarker)
How It Works
CertiSigma provides three-tier cryptographic attestation for any SHA-256 hash:
- T0 — ECDSA Signature — Immediate. The server signs your hash with a P-256 key upon receipt. POST /attest
- T1 — TSA Timestamp — Within minutes. Hashes are batched into a Merkle tree and submitted to an RFC 3161 Time Stamping Authority. POST /verify
- T2 — Bitcoin Anchor — Within hours. The Merkle root is anchored to the Bitcoin blockchain via OpenTimestamps, providing immutable proof. GET /attestation/{id}/evidence
The SDK handles hashing, request signing, error handling, and optional client-side encryption. You send a hash (or a file), the SDK does the rest.
Installation
Zero dependencies. Works in Node.js 18+ and modern browsers (native fetch).
npm install @certisigma/sdk
Full TypeScript definitions included (src/index.d.ts, src/crypto.d.ts).
Runtime & Packaging
Node.js 18+ (native fetch, Web Crypto API). Subpath exports: @certisigma/sdk (main), @certisigma/sdk/hash, @certisigma/sdk/crypto. CommonJS (require) and ESM (import) supported. Edge runtimes (Vercel Edge, Cloudflare Workers, Deno, Bun): use native fetch; no polyfills required. For large files in Node, prefer streaming hashing or Python's attest_file(path).
Authentication
Pass your API key to the client constructor. The SDK sends it as a Bearer token on every request.
const { CertiSigmaClient } = require('@certisigma/sdk');
const client = new CertiSigmaClient({
apiKey: process.env.CERTISIGMA_API_KEY,
});
cs_live_... for production, cs_demo_... for sandbox.
Never hard-code keys — use environment variables or a secrets manager.
If your admin has configured an IP Allowlist on your key, requests from non-whitelisted IPs will receive 403 Forbidden. See IP Allowlist docs.
Quick Start
(async function main() {
const { CertiSigmaClient, hashFile, hashBytes } = require('@certisigma/sdk');
const fs = require('fs');
const client = new CertiSigmaClient({ apiKey: process.env.CERTISIGMA_API_KEY });
// 1. Compute the SHA-256 hash of your file
const fileHash = await hashFile(fs.readFileSync('contract.pdf'));
console.log(`SHA-256: ${fileHash}`); // 64-char lowercase hex
// 2. Attest — creates a timestamped, signed proof of existence
const result = await client.attest(fileHash, { source: 'my-app' });
console.log(`Attestation: ${result.id} at ${result.timestamp}`);
// 3. Verify — confirm the hash was attested
const check = await client.verify(fileHash);
console.log(`Exists: ${check.exists}, Level: ${check.level}`);
// Or hash raw bytes directly
const dataHash = await hashBytes(new TextEncoder().encode('any raw content'));
const result2 = await client.attest(dataHash);
})();
Integration Contract
| Parameter | Value |
|---|---|
| API target | /v1/ (all endpoints also available without prefix) |
| Rate limit | 1,000 req/min per key (sliding window), Retry-After header on 429 |
| Batch max | 100 hashes per call (batchAttest, batchVerify) |
| Public endpoints | verify, batchVerify, status, getEvidence, health, /keys, matchDerivedList (requires listKey), getDerivedListSignature — batchVerify({ detailed: true }) requires apiKey |
| Auth-required | attest, batchAttest, updateMetadata, deleteMetadata, getMetadata, putTags, getTags, queryTags, createShareToken, scan, getBulkStats, createDerivedList, listDerivedLists, getDerivedList, getDerivedListAccessLog, revokeDerivedList, registerWebhook, listWebhooks, deleteWebhook, listWebhookDeliveries |
| RBAC scopes | attest, metadata:read, metadata:write, tags:read, tags:write, share, scan, batch, census, webhook — see RBAC & Scoped Keys |
| Attestation IDs | Sequential (att_1, att_2, …). Public by design — CertiSigma is a public attestation platform. Cryptographic proof (hash, signature, Merkle, OTS) is intentionally verifiable by any party. |
| Data isolation | Organizational metadata (source, extraData, tags) is never exposed on public endpoints. Only the authenticated API key owner can read their own claim data. Public endpoints return only cryptographic proof. |
| Retry policy | Caller-managed. Retry on 429 and 5xx with exponential backoff + jitter |
| Idempotency | Re-attesting the same hash returns the existing attestation (no duplicate) |
| Webhook delivery | At-least-once, ordering not guaranteed, HMAC-SHA256 signed |
| Support window | Current major + previous major for at least 12 months |
| Deprecation | At least one minor release warning before removal |
Recommended Attestation Workflow
A production integration typically follows this pattern:
(async function run() {
const { CertiSigmaClient, hashFile } = require('@certisigma/sdk');
const fs = require('fs');
const client = new CertiSigmaClient({ apiKey: process.env.CERTISIGMA_API_KEY });
// 1. Hash the file locally (SHA-256 via Web Crypto API; loads full file into memory)
const hashHex = await hashFile(fs.readFileSync('contract.pdf'));
// 2. Attest
const result = await client.attest(hashHex, { source: 'contract-pipeline' });
// 3. Save to a local JSON manifest
const manifest = {
id: result.id,
hash: result.hash_hex,
timestamp: result.timestamp,
signature: result.signature,
source: 'contract-pipeline',
};
fs.writeFileSync('contract.pdf.certisigma.json', JSON.stringify(manifest, null, 2));
// 4. Later — download evidence (includes T2 when available)
const evidence = await client.getEvidence(result.id);
console.log(`Level: ${evidence.level}`);
// 5. Update manifest with evidence
manifest.level = evidence.level;
manifest.t1 = evidence.t1;
manifest.t2 = evidence.t2;
fs.writeFileSync('contract.pdf.certisigma.json', JSON.stringify(manifest, null, 2));
})();
Attest Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Attestation ID (att_...) |
hash_hex | string | SHA-256 hash that was attested |
timestamp | string | ISO 8601 creation time |
signature | string | Base64 ECDSA P-256 signature |
status | string | created (new) or existing (already attested) |
etag | string | ETag for optimistic concurrency on metadata updates |
claim_id | number | Your API key's claim ID for this attestation |
source | string | Source label you provided |
extra_data | object | Metadata you attached |
verify() Response Shape
| Field | JS type | Public (no key) | Authenticated |
|---|---|---|---|
exists | boolean | Always | Always |
hash_hex | string | Always | Always |
id | string? | If exists | If exists |
timestamp | string? | If exists | If exists |
level | string? | If exists | If exists |
verified_at | string | If exists | If exists |
source | string? | — | If caller owns a claim |
getEvidence() Response Shape
| Field | JS type | Condition |
|---|---|---|
id | string | Always |
hash_hex | string | Always |
level | string | Always (T0, T1, or T2) |
t0 | object | Always (keys: formatVersion, registeredAt, signature, publicKeyId) |
t1 | object? | Present when level ≥ T1 (keys: tsa, timestamp, token) |
t2 | object? | Present when level = T2 (keys: merkleRoot, bitcoinBlock, txId) |
hash_hex, extra_data, verified_at). Nested objects under t0, t1, t2 use camelCase (formatVersion, registeredAt, publicKeyId).
What to Save After Attestation
Always persist the attestation result locally. Example JSON manifest pattern:
{
"id": "att_a1b2c3d4e5f6",
"hash": "e3b0c44298fc1c149afbf4c8996fb924...",
"timestamp": "2025-06-15T10:30:00Z",
"signature": "MEUCIQDx...",
"source": "contract-pipeline",
"original_file": "contract.pdf",
"level": "T2",
"t1": { "batchId": 42, "merkleRoot": "c4f3b2...", "tsaTimestamp": "2025-06-15T10:35:00Z" },
"t2": {
"bitcoinBlockHeight": 850123,
"bitcoinBlockHash": "00000000000000000002a7c4...",
"bitcoinTxid": "d4e5f6a7b8c9...",
"confirmedAt": "2025-06-15T14:20:00Z"
}
}
.certisigma.json file alongside each attested file.
This creates an offline-verifiable record without querying the API.
Minimum Durable Evidence Set (Probative Use)
For long-term legal or audit purposes, persist at least: id, hash_hex, timestamp, signature, level, and when available t1 (TSA + Merkle proof) and t2 (Bitcoin anchor). Save the .ots proof file for independent verification. This set allows verification even if the CertiSigma API is unavailable.
Idempotency
Attest returns status: "created" for new attestations or status: "existing" if the hash was already attested. Safe to retry: duplicate attestations are idempotent. On network timeout, re-attest with the same hash; if it already exists, you get the same attestation ID.
Batch Operations
Attest or verify up to 100 hashes in a single API call.
(async function run() {
const { hashFile } = require('@certisigma/sdk');
const fs = require('fs');
// Compute hashes for all invoices (each file loaded fully into memory)
const files = fs.readdirSync('invoices/').filter(f => f.endsWith('.pdf'));
const hashes = await Promise.all(
files.map(f => hashFile(fs.readFileSync(`invoices/${f}`)))
);
// Attest up to 100 hashes in one call — returns full claim metadata per item
const batch = await client.batchAttest(hashes, { source: 'invoice-processor' });
console.log(`Created: ${batch.created}, Existing: ${batch.existing}`);
batch.attestations.forEach(a =>
console.log(` ${a.id} claim=${a.claim_id} src=${a.source}`)
);
// Later — verify the same files haven't been tampered with
const currentHashes = await Promise.all(
files.map(f => hashFile(fs.readFileSync(`invoices/${f}`)))
);
const results = await client.batchVerify(currentHashes);
console.log(`Found: ${results.found}/${results.count}`);
// Detailed mode — certification level + claim metadata (requires apiKey)
const detailed = await client.batchVerify(currentHashes, { detailed: true });
detailed.results.filter(r => r.exists).forEach(r =>
console.log(` ${r.id} level=${r.level} src=${r.source}`)
);
})();
Batch attest now returns full claim metadata per item (claim_id, source, extra_data, signing_key_id, etag).
Batch verify supports { detailed: true } for enriched results including certification level and tier timestamps. detailed mode requires apiKey; without a key the API returns 401.
File Attestation
Hash a file client-side (via Web Crypto) and attest the SHA-256 in one call. Works with File, Blob, ArrayBuffer, or Uint8Array.
hashFile() and attestFile() load the entire file into memory (unlike Python's streaming hash_file()). For large files, prefer Python's attest_file(path) or stream-based hashing in Node.
// Browser — from a file input (inside an async function)
const file = document.getElementById('fileInput').files[0];
const result = await client.attestFile(file, { source: 'upload-form' });
// Node.js — from a buffer (inside an async function)
const buf = fs.readFileSync('document.pdf');
const result2 = await client.attestFile(buf, { source: 'cli-tool' });
Hashing Utilities
Standalone SHA-256 functions to compute hashes without attestation.
Available from the main package or @certisigma/sdk/hash.
(async function run() {
const { hashFile, hashBytes, hashString } = require('@certisigma/sdk');
// or: const { hashFile, hashBytes, hashString } = require('@certisigma/sdk/hash');
// Hash a File, Blob, ArrayBuffer, or Uint8Array
const fileHash = await hashFile(fileInput.files[0]);
// Hash raw bytes
const dataHash = await hashBytes(new TextEncoder().encode('raw content'));
// Hash a string (UTF-8, no file needed)
const strHash = await hashString('SN-2026-001234');
// Node.js — hash a local file for batch preparation
const fs = require('fs');
const hashes = await Promise.all(
filePaths.map(p => hashBytes(fs.readFileSync(p)))
);
const batch = await client.batchAttest(hashes, { source: 'bulk-ingest' });
})();
| Function | Input | Returns |
|---|---|---|
hashFile(input) | File | Blob | ArrayBuffer | Uint8Array | Promise<string> — 64-char hex |
hashBytes(data) | Uint8Array | ArrayBuffer | Promise<string> — 64-char hex |
hashString(text) | string | Promise<string> — 64-char hex |
String Attestation
Attest identifiers, serial numbers, or any non-file content without creating temporary files.
The string is hashed client-side (UTF-8 via TextEncoder); only the SHA-256 hash reaches the API.
// One-step: hash + attest
const result = await client.attestString('SN-2026-001234', { source: 'serial-registry' });
// Verify later
const check = await client.verifyString('SN-2026-001234');
console.assert(check.exists);
// Also works for files
const fileCheck = await client.verifyFile(fileInput.files[0]);
source is never auto-populated from the string content
(unlike attestFile which uses the filename). Pass source explicitly if needed.
| Method | Auth Required | Description |
|---|---|---|
attestString(text, { source, extraData }) | Yes | Hash string + create attestation |
verifyString(text) | No | Hash string + verify attestation |
verifyFile(file) | No | Hash file + verify attestation |
Metadata and Claims
Each API key owns its own claim on an attestation. Claims hold source (an optional label)
and extraData (a JSON object). Multiple API keys can independently claim the same hash.
source is an optional plaintext operational label (max 100 chars) — designed for system identifiers like pipeline names or app versions, not for sensitive or personal data. If you don't need it, simply omit it. It is never exposed on public endpoints; only you (the authenticated API key owner) can read it. For sensitive metadata, use extraData with client-side encryption.
(async function run() {
// Update claim metadata
await client.updateMetadata('att_1234', {
source: 'pipeline-v2',
extraData: { project: 'alpha', version: '2.0' },
});
// Soft-delete claim
await client.deleteMetadata('att_1234');
// Get full cryptographic evidence
const evidence = await client.getEvidence('att_1234');
})();
ETag with every attestation.
When updating metadata via PATCH /attestation/{id}/metadata,
send If-Match: <etag> to prevent conflicting updates. On mismatch, the API returns 412 Precondition Failed.
Client-Side Encryption (Zero Knowledge)
Encrypt metadata with AES-256-GCM before sending. When clientEncrypted=true, the server stores extraData as an opaque blob and never sees plaintext. Uses the Web Crypto API.
source? source is always plaintext by design — it's an optional operational label that the backend uses for audit logging and filtering. Encrypting it would make it unusable for those purposes. It is never exposed on public endpoints; only you (the authenticated claim owner) can read it. If you don't need it, omit it entirely. Never store PII in source — use encrypted extraData instead.
(async function run() {
const { generateKey, encryptMetadata, decryptMetadata } = require('@certisigma/sdk/crypto');
// Generate a key (store securely — server never sees it)
const key = await generateKey();
// Encrypt before sending
const encrypted = await encryptMetadata({ secret: 'classified' }, key);
const result = await client.attest(hash, { extraData: encrypted, clientEncrypted: true });
// Decrypt after retrieving (extraData comes from the attest response, not verify)
const plaintext = await decryptMetadata(result.extra_data, key);
})();
Available crypto functions: generateKey(), encryptMetadata(),
decryptMetadata(), isEncrypted(), rotateKey().
RBAC & Scoped API Keys
API keys carry granular scopes that control access to specific operations. Keys created before RBAC was introduced are automatically backfilled with all scopes for backward compatibility.
| Scope | Grants |
|---|---|
attest | Create attestations (attest, batchAttest) |
batch | Batch operations + inventory stats (batchAttest, batchVerify, getBulkStats) |
metadata:read | Read claim metadata (getMetadata) |
metadata:write | Update/delete metadata (implies metadata:read) |
tags:read | Read tags (getTags) |
tags:write | Create/update/delete tags (implies tags:read) |
share | Create and manage share tokens |
scan | Leak detection (scan) — requires org_id |
census | Derived lists (createDerivedList, etc.) — requires org_id |
webhook | Register, list, and delete webhooks (registerWebhook, listWebhooks, deleteWebhook, listWebhookDeliveries) |
verify, status, getEvidence, and health are public and require no scope (or no API key at all).
metadata:write automatically grants metadata:read; tags:write automatically grants tags:read. You do not need to request both.
Share Tokens (Forensic Metadata Sharing)
Create time-limited, auditable, revocable tokens for sharing attestation metadata with forensic analysts, auditors, or third-party verifiers. Share tokens grant read-only access to a specific set of attestations.
(async function run() {
const token = await client.createShareToken([42, 43, 44], {
expiresIn: 86400, // 24 hours (max 30 days)
recipientLabel: 'forensic-analyst',
maxUses: 10,
});
console.log(`Share token: ${token.share_token}`); // shown once, save it
// List, inspect, revoke
const tokens = await client.listShareTokens();
const info = await client.getShareTokenInfo(token.id);
await client.revokeShareToken(token.id);
})();
| Constraint | Value |
|---|---|
| Max expiry | 30 days |
| Max attestation IDs | 100 per token |
| Max uses | Optional (unlimited if unset) |
| Rate limit | 100 req/min (separate from API key rate limit) |
| Required scope | share |
metadata:read and tags:read only for the specified attestation IDs. All access is logged in the share access log.
Structured Tagging
Classify attestations with multi-dimensional key-value tags. Tags support server-side querying (AND semantics) and optional client-side encryption for sensitive values.
(async function run() {
// Upsert tags (max 50 per attestation per key)
await client.putTags('att_42', [
{ key: 'department', value: 'hr' },
{ key: 'classification', value: 'confidential' },
]);
const tags = await client.getTags('att_42');
// Query by tags (AND semantics, max 10 conditions)
const results = await client.queryTags(
{ and: [
{ key: 'department', value: 'hr' },
{ key: 'classification', value: 'confidential' },
]},
{ limit: 100 },
);
console.log(`Found ${results.count} matching attestations`);
// Delete a tag
await client.deleteTag('att_42', 'classification');
})();
| Constraint | Value |
|---|---|
| Max tags per attestation per key | 50 |
| Tag key format | ^[a-z][a-z0-9_-]{0,62}$ (lowercase, no _ prefix — reserved) |
| Max AND conditions per query | 10 |
| Client-encrypted tags | Supported (valueEnc + valueNonce hex), but excluded from server-side query |
| Required scope (read) | tags:read |
| Required scope (write) | tags:write |
Census — Leak Detection & Inventory Stats
Compare suspect hashes against your organization's attested inventory for leak detection, and retrieve aggregate inventory statistics. Both endpoints require an API key with org_id configured.
const { CertiSigmaClient } = require('@certisigma/sdk');
const client = new CertiSigmaClient({ apiKey: process.env.CERTISIGMA_API_KEY });
// Leak detection — compare suspect hashes against org inventory
// Requires `scan` scope + org_id
const suspectHashes = ['a665a459...', 'b4c9a289...', 'e3b0c442...'];
const result = await client.scan(suspectHashes);
console.log(`Scan ${result.scan_id}: ${result.matched}/${result.total_scanned} matched`);
result.matches.forEach(m =>
console.log(` ${m.hash_hex} first_seen=${m.first_seen} source=${m.source}`)
);
// Inventory statistics — aggregate view of your org's claims
// Requires `batch` scope + org_id
const stats = await client.getBulkStats();
console.log(`Org ${stats.org_id_hash}: ${stats.total_claims} claims, ${stats.unique_hashes} unique`);
Object.entries(stats.claims_by_month).forEach(([month, count]) =>
console.log(` ${month}: ${count} claims`)
);
Scan uses a temp-table set intersection for performance on large inputs. The maximum number of hashes per scan is configured server-side (default 50K). Results include source and first_seen timestamp per match. Rate limited to 5 scans/hour per key.
Inventory stats returns total claims, unique hashes, date range, and monthly breakdown — scoped to your organization.
Census — Derived Lists
Create opaque HMAC-SHA256 derived lists for third-party hash verification without revealing your organization's hash inventory. Lists are signed by CertiSigma's ECDSA key for integrity verification. Requires census scope and org_id on the API key.
(async function run() {
// 1. Create a derived list from explicit hashes
const dl = await client.createDerivedList({
hashes: ['a665a459...', 'b4c9a289...'],
label: 'Partner audit Q1',
expiresInHours: 720,
});
console.log(`List ID: ${dl.id}`);
console.log(`List key (save now!): ${dl.list_key}`);
// 2. Or create from tag filter
const dl2 = await client.createDerivedList({
tagFilter: { and: [{ key: 'department', value: 'hr' }] },
label: 'HR confidential subset',
});
// 3. Third party: match files against the list
// (can be done WITHOUT an API key — only needs list_key)
const { CertiSigmaClient } = require('@certisigma/sdk');
const { createHmac } = require('node:crypto');
const pub = new CertiSigmaClient();
const fileHash = 'a665a459...';
const derived = createHmac('sha256', Buffer.from(dl.list_key, 'hex'))
.update(fileHash).digest('hex');
const result = await pub.matchDerivedList(dl.id, dl.list_key, [derived]);
console.log(`Matched: ${result.matched}/${result.total}`);
// 4. Verify list signature (public)
const sig = await pub.getDerivedListSignature(dl.id);
console.log(`Signed by: ${sig.signing_key_id}`);
// 5. Owner: list, inspect, revoke
const lists = await client.listDerivedLists();
const detail = await client.getDerivedList(dl.id);
const log = await client.getDerivedListAccessLog(dl.id);
await client.revokeDerivedList(dl.id);
})();
| Constraint | Value |
|---|---|
| Max items per list | 100,000 (server-configured) |
| Max TTL | 2,160 hours (90 days, server-configured) |
| Max hashes per match | 50,000 (server-configured) |
| Match rate limit | 20 requests/hour per list (server-configured) |
| Match endpoint auth | Public (requires list_key) |
| Signature endpoint auth | Public |
| Required scope | census |
| Required key config | org_id must be set |
GET /keys. Lists can be time-limited and revoked at any time.
Read Metadata
Explicit metadata read without re-verifying the attestation:
(async function run() {
const meta = await client.getMetadata('att_42');
console.log(`Source: ${meta.source}, Extra: ${JSON.stringify(meta.extra_data)}`);
})();
Requires metadata:read scope. Share tokens with the matching attestation ID can also call this endpoint.
Verification Examples
(async function run() {
// Verify a single hash
const check = await client.verify('e3b0c44...');
if (check.exists) {
console.log(`Attested at ${check.timestamp}, Level: ${check.level}`);
}
// Verify from a saved manifest
const manifest = JSON.parse(fs.readFileSync('contract.pdf.certisigma.json', 'utf8'));
const check2 = await client.verify(manifest.hash);
console.log(`Level: ${check2.level}`);
// Batch verify (up to 100)
const results = await client.batchVerify(['aabb...', 'ccdd...']);
results.results.forEach(r => console.log(`${r.hash_hex}: ${r.exists}`));
})();
Public Verification (No API Key)
Verification endpoints are public — anyone can verify an attestation without an API key. This enables third-party audits, compliance checks, and independent verification without sharing credentials.
(async function run() {
const { CertiSigmaClient, hashFile } = require('@certisigma/sdk');
const fs = require('fs');
// No apiKey needed — public verification
const client = new CertiSigmaClient();
const fileHash = await hashFile(fs.readFileSync('contract.pdf'));
const check = await client.verify(fileHash);
console.log(`Exists: ${check.exists}, Level: ${check.level}`);
// Batch verify also works without a key
const results = await client.batchVerify([fileHash]);
console.log(`Found: ${results.found}/${results.count}`);
})();
verify returns only cryptographic proof (hash, timestamp, level, signature) — no source or extra_data. With API key, it also returns your claim's metadata when you own a claim on that hash.
verify, batchVerify, status, getEvidence, and health work without a key. Calling attest, batchAttest, updateMetadata, or deleteMetadata without an apiKey throws AuthenticationError immediately — the request is never sent.
status() Response Shape
Returns the current trust tier of an attestation without the full evidence payload. Useful for dashboards, progress tracking, and lightweight polling:
| Field | Type | Description |
|---|---|---|
id | string | Attestation ID (att_...) |
hash_hex | string | SHA-256 hash |
level | "T0" | "T1" | "T2" | Current trust tier |
t0_timestamp | string | ECDSA signature timestamp |
t1_timestamp | string? | TSA / Merkle batch timestamp |
t2_timestamp | string? | Bitcoin anchor timestamp |
t2_bitcoin_block | number? | Bitcoin block height |
signature_available | boolean | T0 ECDSA signature present |
merkle_proof_available | boolean | T1 Merkle proof present |
ots_available | boolean | OTS proof downloadable |
verify vs status vs getEvidence
| Method | Input | Auth | Returns | Best for |
|---|---|---|---|---|
verify(hash) | SHA-256 hash | Optional | Existence, level, timestamp, signature | Compliance checks, "does this hash exist?" |
status(attId) | Attestation ID | No | Trust tier, availability flags, timestamps | Dashboards, progress tracking, polling |
getEvidence(attId) | Attestation ID | No | Full T0+T1+T2 proofs, Merkle path, OTS | Independent verification, long-term archival |
Rate Limits
The API enforces per-key rate limits using a sliding window:
| Limit | Default | Notes |
|---|---|---|
| Requests / minute | 1,000 | Configurable per key by admin |
| Monthly quota | Unlimited | Optional, per plan |
| Batch max size | 100 | Hashes per batch call |
When rate-limited, the API returns 429 Too Many Requests with these headers:
Retry-After: 60X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
The SDK automatically throws RateLimitError with a retryAfter property.
const { RateLimitError } = require('@certisigma/sdk');
async function attestWithBackoff(client, hashHex, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await client.attest(hashHex);
} catch (err) {
if (err instanceof RateLimitError) {
const wait = err.retryAfter * 1000 * (2 ** i);
await new Promise(r => setTimeout(r, wait));
} else throw err;
}
}
throw new Error('Max retries exceeded');
}
Public Endpoint Protection
Public endpoints (verify, status, getEvidence) do not require an API key and are not subject to per-key rate limits. They are protected at the infrastructure level:
| Layer | Scope | Limit |
|---|---|---|
| Application | Per API key | 1,000 req/min (authenticated endpoints only) |
| Infrastructure | Per client IP | Reverse proxy rate limiting on all endpoints (public and authenticated) |
Abusive traffic (automated scraping, enumeration floods) is mitigated by infrastructure-level IP rate limiting. Legitimate verification traffic at normal volumes is never throttled.
Polling vs Webhooks
Attestations progress through levels asynchronously. Two patterns to track upgrades:
Polling (simple, low-volume)
async function waitForT1(client, attId, timeoutMs = 600000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const s = await client.status(attId);
if (s.level === 'T1' || s.level === 'T2') return s;
await new Promise(r => setTimeout(r, 30000));
}
throw new Error('T1 not reached within timeout');
}
Webhooks (production, real-time)
Register a webhook via the API (POST /webhook/register) to get notified when an attestation reaches T1 or T2.
| Event | When | Payload includes |
|---|---|---|
t1_complete | TSA timestamp obtained | attestation_id, hash_hex, tsa_timestamp |
t2_complete | Bitcoin anchor confirmed | attestation_id, hash_hex, bitcoin_block, confirmed_at |
Delivery Headers
Each webhook delivery includes these HTTP headers:
| Header | Description |
|---|---|
X-CertiSigma-Event | Event type (t1_complete or t2_complete) |
X-CertiSigma-Delivery | Unique numeric delivery ID — use for deduplication |
X-CertiSigma-Signature | sha256=<hex> HMAC-SHA256 of the raw JSON body |
Verify the X-CertiSigma-Signature header using the SDK helper (constant-time HMAC-SHA256):
const { verifyWebhookSignature } = require('@certisigma/sdk');
const isValid = verifyWebhookSignature(rawBody, signatureHeader, signingSecret);
// Uses crypto.timingSafeEqual internally — timing-attack resistant
listWebhookDeliveries(webhookId) or GET /webhook/{id}/deliveries to inspect delivery history.
Delivery Semantics
- At-least-once delivery: Your endpoint may receive the same event more than once (e.g. on network retries). Use
attestation_idfrom the payload as an idempotency key to deduplicate. - Ordering: Deliveries are sent in best-effort chronological order but ordering is not guaranteed. Do not rely on delivery order for state transitions.
- Anti-replay: Check the
created_attimestamp in the payload and reject deliveries older than your tolerance window (e.g. 5 minutes). The delivery ID (X-CertiSigma-Delivery) can also be tracked to detect replays.
Consumer Reference Pattern
const seenDeliveries = new Set(); // In production, use Redis or a DB table
const MAX_AGE_MS = 300000; // 5-minute anti-replay window
function handleWebhook(headers, body, rawBody) {
const deliveryId = headers['x-certisigma-delivery'];
if (seenDeliveries.has(deliveryId)) return 200; // Idempotent ACK
const age = Date.now() - new Date(body.created_at).getTime();
if (age > MAX_AGE_MS) return 200; // Stale — ACK to stop retries
if (!verifyWebhookSignature(rawBody, headers['x-certisigma-signature'], secret))
return 401;
processEvent(body.attestation_id, headers['x-certisigma-event']);
seenDeliveries.add(deliveryId);
return 200;
}
attestation_id + event type identifies the event (what happened). X-CertiSigma-Delivery identifies the delivery attempt (retries share the same delivery ID). Deduplicate on delivery ID to handle retries; use attestation ID for business logic.
Secret Rotation
To rotate a webhook signing secret: register a new webhook with the same URL and events, verify it receives deliveries, then delete the old webhook. In-flight retries for the old webhook continue using the original secret.
Best practice: Use webhooks as the primary notification channel and polling as a fallback for missed events.
Webhook Management
The SDK exposes 4 methods for managing webhook subscriptions and a helper for verifying delivery signatures.
All methods require an API key with the webhook scope.
Register a Webhook
const result = await client.registerWebhook(
'https://my-server.com/census-hook',
['t1_complete', 't2_complete'],
{ label: 'census-watch-prod' }, // optional, for identification
);
// result.id → "wh_abc123"
// result.signing_secret → "a1b2c3..." (shown once — store securely)
// result.label → "census-watch-prod"
List Webhooks
const result = await client.listWebhooks();
result.webhooks.forEach(wh => console.log(wh.id, wh.url, wh.label, wh.failure_count));
Delete a Webhook
const result = await client.deleteWebhook('wh_abc123');
// result.deleted → true
List Delivery History
const result = await client.listWebhookDeliveries('wh_abc123');
result.deliveries.forEach(d => console.log(d.event_type, d.status, d.response_code));
Verify Webhook Signature
const { verifyWebhookSignature } = require('@certisigma/sdk');
// In your webhook handler (Express, Fastify, etc.):
const isValid = verifyWebhookSignature(
req.rawBody, // Buffer
req.headers['x-certisigma-signature'], // "sha256={hex}"
signingSecret, // from registerWebhook().signing_secret
);
| Constraint | Value |
|---|---|
| Events | t1_complete, t2_complete |
| Signing secret | 64-char hex, shown once at registration — store securely |
| URL scheme | HTTPS required in production; HTTP allowed in demo mode |
| Label | Optional, max 200 characters |
| Delivery history | Last 100 deliveries per webhook |
Evidence & OTS Verification
Once an attestation reaches T2, the Merkle root has been anchored to the Bitcoin blockchain
via OpenTimestamps.
Use getEvidence() to retrieve the full cryptographic bundle, then use the SDK helpers
to inspect the Bitcoin block or save the raw .ots proof file.
Recommended Workflow
- Register a
t2_completewebhook (or poll withstatus()) - When notified, call
getEvidence(attId) - Save the updated manifest including T2 data
- Optionally save the
.otsproof file for independent verification
(async function run() {
const { CertiSigmaClient, getBlockchainUrl, saveOtsProof } = require('@certisigma/sdk');
const fs = require('fs');
const client = new CertiSigmaClient({ apiKey: process.env.CERTISIGMA_API_KEY });
// Retrieve the full evidence bundle
const evidence = await client.getEvidence('att_1234');
console.log(`Level: ${evidence.level}`); // "T0", "T1", or "T2"
if (evidence.level === 'T2') {
// Get the Bitcoin block explorer link
const blockUrl = getBlockchainUrl(evidence); // mempool.space/block/...
const txUrl = getBlockchainUrl(evidence, 'tx'); // mempool.space/tx/...
console.log(`Block: ${blockUrl}`);
console.log(`Tx: ${txUrl}`);
console.log(`Height: ${evidence.t2.bitcoinBlockHeight}`);
// Save the raw .ots proof (for independent OTS verification)
saveOtsProof(evidence, 'contract.pdf.ots');
// Update your local manifest
const manifest = JSON.parse(fs.readFileSync('contract.pdf.certisigma.json', 'utf8'));
manifest.level = evidence.level;
manifest.t2 = evidence.t2;
fs.writeFileSync('contract.pdf.certisigma.json', JSON.stringify(manifest, null, 2));
}
})();
Evidence Response Fields
| Field | Type | Description |
|---|---|---|
hash_hex | string | SHA-256 hash of the attested data |
level | string | Highest confirmed level: T0, T1, or T2 |
| t0 — ECDSA Signature (immediate) | ||
t0.signature | string | Base64-encoded ECDSA P-256 signature |
t0.algorithm | string | ECDSA-P256-SHA256 |
t0.publicKeyId | string | Signing key identifier for key pinning |
t0.registeredAt | string | ISO 8601 timestamp of registration |
| t1 — TSA Timestamp + Merkle Proof (minutes) | ||
t1.batchId | number | T1 batch identifier |
t1.merkleRoot | string | Merkle root hash of the batch |
t1.merkleProof | Array | Merkle inclusion proof path (leaf → root) |
t1.tsaProvider | string | TSA provider (e.g. sectigo_qualified) |
t1.tsaTimestamp | string | RFC 3161 timestamp (ISO 8601) |
t1.tsaToken | string | Base64 TSA token for independent verification |
| t2 — Bitcoin Anchor via OpenTimestamps (hours) | ||
t2.dailyRoot | string | Daily Merkle root anchored to Bitcoin |
t2.t1ToT2Proof | Array | Merkle proof linking T1 batch root to T2 daily root |
t2.bitcoinBlockHeight | number | Bitcoin block number containing the anchor |
t2.bitcoinBlockHash | string | Bitcoin block hash |
t2.bitcoinTxid | string | Bitcoin transaction ID |
t2.confirmedAt | string | ISO 8601 confirmation timestamp |
t2.otsProof | string | Raw OpenTimestamps proof (Base64-encoded binary) |
SDK Helper Functions
| Function | Input | Returns |
|---|---|---|
getBlockchainUrl(evidence) | EvidenceResult | mempool.space block URL, or null |
getBlockchainUrl(evidence, 'tx') | EvidenceResult | mempool.space transaction URL, or null |
saveOtsProof(evidence, path) | EvidenceResult, string | true if saved, false if T2 not ready |
Independent OTS Verification
The saved .ots file is a standard
OpenTimestamps proof.
Verify it independently — no CertiSigma involvement needed:
# Install the OpenTimestamps client
pip install opentimestamps-client
# Verify the proof against the Bitcoin blockchain
ots verify contract.pdf.ots
# Or verify online at https://opentimestamps.org
.ots proof ties directly to a Bitcoin block — immutable, decentralized, permanent.
getBlockchainUrl(evidence) to get a direct link to
mempool.space, where anyone can independently
verify the Bitcoin block and transaction containing your anchor.
Error Handling
const { AuthenticationError, RateLimitError, QuotaExceededError } = require('@certisigma/sdk');
// Inside an async function
try {
await client.attest(hash);
} catch (err) {
if (err instanceof AuthenticationError) {
console.error('Invalid API key');
} else if (err instanceof RateLimitError) {
console.error(`Rate limited, retry after ${err.retryAfter}s`);
} else if (err instanceof QuotaExceededError) {
console.error('Monthly quota reached');
}
}
Error Envelope
All API errors return a consistent JSON envelope:
{ "error": true, "code": "ERROR_CODE", "message": "Human-readable description", "details": null }
Error Codes & Retry Guidance
| HTTP | Code | Retryable | Action |
|---|---|---|---|
| 400 / 422 | VALIDATION_ERROR | No | Fix request payload |
| 401 | AUTH_ERROR | No | Check API key |
| 403 | FORBIDDEN | No | Insufficient permissions or scope |
| 404 | NOT_FOUND | No | Resource does not exist |
| 429 | RATE_LIMIT | Yes | Back off using Retry-After header |
| 500 | INTERNAL_ERROR | Yes | Retry with exponential backoff |
429 and 5xx. Fail fast on all other 4xx errors — they indicate a client-side issue that retrying will not resolve. The SDK exposes err.statusCode and err.retryAfter (on RateLimitError) for implementing backoff logic.
Example Error Responses
{ "error": true, "code": "RATE_LIMIT", "message": "Rate limit exceeded", "details": null }
// Headers: Retry-After: 12, X-RateLimit-Remaining: 0
{ "error": true, "code": "AUTH_ERROR", "message": "Invalid or missing API key", "details": null }
{ "error": true, "code": "VALIDATION_ERROR", "message": "hash_hex must be exactly 64 lowercase hex characters", "details": null }
{ "error": true, "code": "INTERNAL_ERROR", "message": "An internal error occurred", "details": null }
X-Request-Id header. You can send your own X-Request-Id in the request — the API echoes it back. If omitted, the server generates one automatically. Use it for technical support/debugging — include it when contacting support. For domain audit, use attestation_id — it identifies the attested record, not the HTTP request.
Security Best Practices
- Environment variables: Store API keys in
CERTISIGMA_API_KEY, never in source code. - Client-side encryption: Use
encryptMetadata()for sensitive data inextraData. - Key rotation: Use
rotateKey(envelope, oldKey, newKey)to re-encrypt without decrypting on the server. - Data isolation: Attestation IDs are sequential and public by design. Public endpoints (
verify,getEvidence,status) return only cryptographic proof (hash, signature, Merkle, OTS). Organizational metadata (source,extraData) is never exposed publicly — only the authenticated API key owner can read their own claim data. - IP Allowlist: Restrict API key usage to known IP ranges.
- Webhook secrets: Store the
signing_secretin a secrets manager. It is shown only once. - Audit trail: Log all attestation IDs and hashes locally for independent auditing.
When to Use What
| Use case | Recommended approach |
|---|---|
| One-off file attestation | attestFile(path) |
| Serial number / identifier attestation | attestString(text) — hash client-side, no temp file |
| Bulk ingestion pipeline | hashFile() + batchAttest() |
| Audit / compliance check | verify() or batchVerify() (no API key needed) |
| Verify a specific file or string | verifyFile(file) or verifyString(text) |
| Leak detection (exfiltration) | scan(suspectHashes) — compare against org inventory |
| Inventory dashboard | getBulkStats() — total claims, monthly breakdown |
| Long-term proof export | getEvidence() + saveOtsProof() |
| Sensitive metadata | Client-side encryption with encryptMetadata() |
| High-volume async notifications | Webhooks (t1_complete, t2_complete), not polling |
| Blockchain verification URL | getBlockchainUrl(evidence) |
T0 Signature Format
Every attestation receives an immediate ECDSA-P256-SHA256 signature (T0). To verify independently:
| Payload component | Evidence JSON field | Invariant |
|---|---|---|
format_version | t0.formatVersion | Semver string, currently 2.0.0 |
hash_hex | hash_hex | Lowercase hex, exactly 64 characters (SHA-256) |
registered_at | t0.registeredAt | ISO 8601, microsecond precision, UTC offset +00:00(e.g. 2026-03-16T12:00:00.000000+00:00) |
format_version, registered_at). The evidence JSON response uses camelCase (t0.formatVersion, t0.registeredAt). When reconstructing the payload for verification, read the camelCase fields from the evidence and place them into the pipe-separated payload.
Payload construction: {format_version}|{hash_hex}|{registered_at} — pipe U+007C, no whitespace, UTF-8 encoded.
Signing: SHA-256 of the UTF-8 payload bytes, then ECDSA P-256 over the hash (prehashed). Signature is base64-encoded DER.
Independent Verification Steps
- Retrieve public keys from GET /keys.
- Match the key using
t0.publicKeyIdfrom your evidence. - Reconstruct payload:
`${evidence.t0.formatVersion}|${evidence.hash_hex}|${evidence.t0.registeredAt}` - Compute SHA-256 of the UTF-8 payload bytes.
- Verify the ECDSA-P256-SHA256 signature (
t0.signature, base64-decoded DER) using the JWK public key.
Server-Side Key Rotation
GET /keys returns all active signing keys. Old attestations reference the key that was active at signing time. Always match via t0.publicKeyId; never assume a single key.
Test Vectors
Canonical test vectors from a production attestation (att_500). Use these to validate any independent verification implementation against real CertiSigma output.
T0 — ECDSA Signature Verification
| Field | Value |
|---|---|
hash_hex | 7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac |
t0.formatVersion | 2.0.0 |
t0.registeredAt | 2026-02-28T00:33:19.291619+00:00 |
t0.publicKeyId | certisigma-3cb3ab64f5cfc983 |
t0.signature | MEQCIGuKNpN1MERfn7jeJW4aRG2om7nIp2eRz/GHueM/Hi9qAiA2ik/6not2lJUWnYGpQ6x9xouOt8j8Cr42vBVSrSeN+Q== |
Step 1 — Reconstruct canonical payload
2.0.0|7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac|2026-02-28T00:33:19.291619+00:00
Step 2 — SHA-256 of canonical payload
f5203ed4089a3bd3367f9301de43db93b574e92e1d597227c738d5338cd5ae01
Step 3 — Public key (JWK from GET /keys)
{
"kty": "EC", "crv": "P-256", "alg": "ES256",
"kid": "certisigma-3cb3ab64f5cfc983",
"x": "7AKMIyF5Rm3SfCASYsJsBHvZhF9cqtjEcmbu5DL8wA8",
"y": "94l-xPf8HVkp03XELgIeDvsBDtWTL8tj7glvfjFU1Fk"
}
Step 4 — Verify ECDSA-P256-SHA256
const crypto = require('crypto');
// Evidence fields
const hashHex = '7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac';
const version = '2.0.0';
const registered = '2026-02-28T00:33:19.291619+00:00';
const sigB64 = 'MEQCIGuKNpN1MERfn7jeJW4aRG2om7nIp2eRz/GHueM/Hi9qAiA2ik/6not2lJUWnYGpQ6x9xouOt8j8Cr42vBVSrSeN+Q==';
// Public key JWK
const jwk = {
kty: 'EC', crv: 'P-256', alg: 'ES256',
x: '7AKMIyF5Rm3SfCASYsJsBHvZhF9cqtjEcmbu5DL8wA8',
y: '94l-xPf8HVkp03XELgIeDvsBDtWTL8tj7glvfjFU1Fk',
};
(async () => {
// Import public key
const key = await crypto.subtle.importKey(
'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']
);
// Reconstruct canonical payload (WebCrypto hashes internally — do NOT pre-hash)
const payload = new TextEncoder().encode(`${version}|${hashHex}|${registered}`);
// DER → raw r||s (WebCrypto expects IEEE P1363, not DER)
const derBuf = Buffer.from(sigB64, 'base64');
const rLen = derBuf[3];
const r = derBuf.subarray(4, 4 + rLen);
const sStart = 4 + rLen + 2;
const s = derBuf.subarray(sStart);
const raw = Buffer.alloc(64);
r.copy(raw, 32 - r.length);
s.copy(raw, 64 - s.length);
// Pass payload (not digest!) — WebCrypto computes SHA-256 internally
const ok = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, key, raw, payload
);
console.log(ok ? '✅ T0 signature verified' : '❌ verification failed');
})();
subtle.verify always hashes internally — pass the raw UTF-8 payload, not the digest. If you pass a pre-computed SHA-256 digest, WebCrypto double-hashes it and verification fails. The SHA-256 shown in Step 2 is provided for auditing the canonical payload, not as input to signature verification.
T1 — Merkle Inclusion Proof (RFC 6962 v2)
| Field | Value |
|---|---|
| Leaf hash | 7fd9101e...cf0aac (same as hash_hex) |
| Merkle root | 1ccb4cfd5ea666d2f7418f104cebf200ef258b784dc483698335a084cf0f1d67 |
| Merkle version | 2 (RFC 6962 domain separation: H(0x01 || left || right)) |
| Proof path | 3 steps (see below) |
Leaf: 7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac
Step 0: H(0x01 || 63792cf5...b8e026 || 7fd9101e...cf0aac) → 844370b7ddafe4f9...
Step 1: H(0x01 || 82950bd8...631955 || 844370b7...e4f9..) → 4529ddc8d19457a9...
Step 2: H(0x01 || 4529ddc8...57a9.. || a35f3d52...27f35e) → 1ccb4cfd5ea666d2...
Computed root: 1ccb4cfd5ea666d2f7418f104cebf200ef258b784dc483698335a084cf0f1d67 ✅
const crypto = require('crypto');
const leaf = '7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac';
const root = '1ccb4cfd5ea666d2f7418f104cebf200ef258b784dc483698335a084cf0f1d67';
const proof = [
{ hash: '63792cf512a74247fe1ac05e25494f40143fe0f6da9b1db855e9444035b8e026', position: 'left' },
{ hash: '82950bd8a3b929aaf12905e2457c91240c9f479a64888a1bd53fbdee23631955', position: 'left' },
{ hash: 'a35f3d526cbffff89d8099580271cb855109e84d357ed4cf31a46f7d0427f35e', position: 'right' },
];
let current = Buffer.from(leaf, 'hex');
for (const step of proof) {
const sibling = Buffer.from(step.hash, 'hex');
const prefix = Buffer.from([0x01]);
current = step.position === 'left'
? crypto.createHash('sha256').update(Buffer.concat([prefix, sibling, current])).digest()
: crypto.createHash('sha256').update(Buffer.concat([prefix, current, sibling])).digest();
}
console.log(current.toString('hex') === root
? '✅ Merkle inclusion verified'
: '❌ Merkle proof failed');
T2 — Bitcoin Anchor
| Field | Value |
|---|---|
| Daily root | b7e7f6bfcd2419864d5021df4a0c9345faa48b9a4de0e1f45ee6158ffb5e02e0 |
| Bitcoin block | 938792 |
| Confirmed at | 2026-03-01T03:53:18.079007+00:00 |
| OTS proof | Binary proof available via GET /attestation/att_500/ots/proof |
.ots proof file and verify with any OpenTimestamps-compatible tool (e.g. ots verify att_500.ots). The proof chains from the daily Merkle root to a Bitcoin block header, providing mathematical proof of existence before that block's timestamp.
Negative Test Vector (must fail)
Same attestation with registeredAt altered by one microsecond (291619 → 291620). Any correct implementation must reject this signature:
const altered = new TextEncoder().encode(
'2.0.0|7fd9101e1dce01c87570d767b4b87a6e608250253c9d8c77fdd1b15731cf0aac|2026-02-28T00:33:19.291620+00:00'
);
const bad = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, key, raw, altered
);
console.log(bad ? '❌ BUG: should have failed' : '✅ Correctly rejected altered timestamp');
Machine-Readable Test Vectors
All test vectors are available as a single JSON file for automated testing:
GET /test-vectors.json — contains T0 (positive + negative), T1, and T2 data with all raw values copy-safe.
Security & Privacy Model
CertiSigma is a public attestation platform. Cryptographic proofs are intentionally public; organizational metadata is strictly private. This section formalizes the boundary.
| Category | Visibility | Details |
|---|---|---|
| Attestation IDs | Public | Sequential (att_1, att_2, …). Discoverable by design — any party can look up any ID. |
| SHA-256 hash | Public | The attested hash is visible on public endpoints. It does not reveal file contents (preimage resistance). |
| ECDSA signature (T0) | Public | Verifiable by anyone via GET /keys. |
| Merkle proof (T1) | Public | Inclusion proof is publicly verifiable. |
| OTS / Bitcoin anchor (T2) | Public | Downloadable .ots proof, verifiable against Bitcoin blockchain. |
| Timestamps | Public | registeredAt, t1_timestamp, t2_timestamp are always visible. |
source | Private | Only returned to the authenticated API key owner who created the claim. |
extraData | Private | Only returned to the authenticated API key owner. Use encryptMetadata() for defense in depth. |
| Tags | Private | Scoped per API key. Other keys cannot see or query your tags, even on the same attestation. Supports client-side encryption for sensitive values. |
| Claim existence | Private | Public endpoints show the attestation exists, not which organizations hold claims on it. |
Threat Model
The sequential nature of attestation IDs means an external party can enumerate all attestation IDs and retrieve their public cryptographic proofs. This is accepted by design because:
- CertiSigma is an attestation platform — public verifiability is the core value proposition.
- Public endpoints return only cryptographic proof (hash, signature, Merkle, OTS). No organizational metadata is ever exposed.
- The SHA-256 hash is a one-way function — knowing the hash does not reveal the attested content.
- For sensitive metadata, client-side encryption (AES-256-GCM) ensures the server itself never sees plaintext.
What the Server Sees
| Data | Server has access? |
|---|---|
| SHA-256 hash | Yes — required for attestation |
| Original file / content | Never — only the hash is sent |
source (optional label) | Yes, if provided — plaintext string, not encryptable. Do not include PII. |
extraData (plaintext) | Only if the client chooses not to encrypt. The SDK supports AES-256-GCM client-side encryption — use it for any sensitive metadata. |
extraData (client-encrypted) | Never — server stores only the AES-256-GCM ciphertext, cannot decrypt |
| API key identity | Yes — required for authenticated endpoints |
| Client IP (general API) | Seen at transport level — used for infrastructure rate limiting. Not stored in any application-level database table for normal API operations (attest, verify, metadata, tags). |
| Client IP (forensic sharing) | When a third party accesses data via share token or performs a derived list match, the IP is recorded in the access log. This log is visible only to the data owner (the API key holder who created the token/list). This enables forensic audit of who accessed shared data. |
extraData contains PII or business-sensitive information, always use client-side encryption. This provides defense in depth: even if the server were compromised, the attacker would only see ciphertext. source is always plaintext but optional and never publicly visible — only you (the authenticated API key owner) can read it. If in doubt, omit it.
Configuration Reference
| Parameter | Default | Description |
|---|---|---|
apiKey | undefined | Bearer token (cs_live_... or cs_demo_...). Optional for verify/health. |
baseUrl | https://api.certisigma.ch | API endpoint |
timeout | 30000 | Request timeout in ms |
Requirements
- Node.js 18+ or any modern browser with
fetchsupport - Zero external dependencies
Advanced Configuration
| Topic | Details |
|---|---|
| Proxy | Platform-dependent. In Node.js, set HTTP_PROXY / HTTPS_PROXY or pass a custom fetch with proxy support (e.g. undici). |
| Custom fetch | Pass a custom fetch implementation to the constructor for proxies, logging, or test mocking. |
| Custom TLS CA | System defaults. For custom CAs, set NODE_EXTRA_CA_CERTS=/path/to/ca.pem environment variable. |
| Retry | Not built-in. Implement at caller level with exponential backoff on 429 / 5xx (see Error Handling above). |
| Observability | No built-in hooks. Wrap fetch for request/response logging. Redact the API key from logged headers. |
Resilient Client Wrapper
Reference implementation with retry, backoff, and logging:
const { CertiSigmaClient, RateLimitError, CertiSigmaError } = require('@certisigma/sdk');
async function resilientCall(fn, { retries = 3 } = {}) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const result = await fn();
console.log(`OK attempt=${attempt + 1}`);
return result;
} catch (err) {
if (err instanceof RateLimitError) {
const wait = (err.retryAfter || 2 ** attempt) * 1000;
console.warn(`429 retryAfter=${err.retryAfter} attempt=${attempt + 1}`);
await new Promise(r => setTimeout(r, wait));
} else if (err instanceof CertiSigmaError && err.statusCode >= 500 && attempt < retries - 1) {
const wait = 2 ** attempt * 1000;
console.warn(`5xx status=${err.statusCode} attempt=${attempt + 1}`);
await new Promise(r => setTimeout(r, wait));
} else {
throw err;
}
}
}
throw new Error('Max retries exceeded');
}
const client = new CertiSigmaClient({ apiKey: process.env.CERTISIGMA_API_KEY });
const result = await resilientCall(() => client.attest(hash, { source: 'pipeline' }));
Sandbox vs Production
| Aspect | Sandbox (cs_demo_...) | Production (cs_live_...) |
|---|---|---|
| ECDSA signing | Ephemeral key (may rotate on restart) | Persistent key pair |
| T1 (TSA) | Free TSA provider | Qualified TSA |
| T2 (Bitcoin) | Same anchoring pipeline | Same anchoring pipeline |
| Webhook URLs | http:// allowed | https:// required |
| Rate limit | 1,000/min (default) | 1,000/min (configurable per key/org) |
| Data retention | Persistent (same DB) | Persistent |
| OpenAPI explorer | Available at /docs | Disabled |
To promote an integration from sandbox to production, replace the apiKey with a cs_live_... key. No code changes required.
Package
npm @certisigma/sdk — MIT License — Ten Sigma Sagl
Compatibility Policy
- Semver: This SDK follows Semantic Versioning 2.0.0. Patch releases contain only bug fixes; minor releases add features without breaking changes; major releases may contain breaking changes.
- API stability: The SDK targets API
/v1/. When a new API version is introduced (e.g./v2/), the previous version will remain supported for a minimum of 12 months. - SDK–API mapping: SDK v1.x targets API v1. New API features are supported in minor SDK releases.
- Support window: The latest minor release receives bug fixes and patches. The previous minor release receives critical security patches for 6 months after the next minor release.
- Deprecation: Deprecated features are marked in the changelog and emit warnings for at least one minor version before removal.
Changelog
v1.10.0
- New:
hashString()— SHA-256 of UTF-8 strings (no temp file needed) - New:
attestString()— hash + attest in one call (source never auto-populated) - New:
verifyString()— hash + verify for strings - New:
verifyFile()— hash + verify for File/Blob/ArrayBuffer/Uint8Array - Cross-SDK determinism: Python and JavaScript produce identical hashes for the same string input
v1.6.0
- New:
createShareToken(),listShareTokens(),getShareTokenInfo(),revokeShareToken()— forensic metadata sharing with time-limited, auditable tokens - New:
putTags(),getTags(),deleteTag(),queryTags()— structured multi-dimensional tagging with server-side AND queries (max 10 conditions) - New:
getMetadata()— explicit metadata read without re-verifying - New: RBAC scoped keys —
metadata:read,metadata:write,tags:read,tags:write,share - New:
batchVerify({ detailed: true })— returns full claim metadata in batch mode (requires API key)
v1.5.0
- New: request ID header (
X-Request-ID) included in all requests
v1.4.0
- New:
getBlockchainUrl(evidence)— returns a mempool.space URL for the Bitcoin block or transaction - New:
saveOtsProof(evidence, path)— saves the raw OpenTimestamps.otsproof to a local file
v1.3.0
- New:
apiKeyis now optional — public endpoints (verify,batchVerify,health) work without authentication - New:
AuthenticationErrorthrown immediately if auth-required method called without key (no request sent)
v1.2.0
- New: standalone
hashFile()andhashBytes()utility functions - New:
@certisigma/sdk/hashsubpath export
v1.1.1 — Initial Public Release
- Full API coverage: attest, verify, batch, file attestation, metadata, evidence, status, health
- Client-side AES-256-GCM encryption via Web Crypto API with key rotation
- Typed error hierarchy:
AuthenticationError,RateLimitError,QuotaExceededError - Full TypeScript definitions (
index.d.ts,crypto.d.ts) - Works in Node.js 18+ and modern browsers — zero dependencies