API Reference
Send a contract and a reference photo — we return a signing link. When the signer completes the biometric ceremony, we POST a signed webhook to your server.
Overview
District Seal's API lets you create legally-binding signature requests without leaving your own application. Your users never see our dashboard — they just get a link, complete a 60-second biometric ceremony, and you get a webhook.
| Base URL | https://districtseal.com/api |
|---|---|
| Auth | X-API-Key header |
| Format | multipart/form-data for uploads, JSON otherwise |
| Webhook sig | HMAC-SHA256 over {timestamp}.{body} |
Authentication
Every request must include your API key in the X-API-Key header.
X-API-Key: ds_live_snUM8m3l6UnuJP9_Meh-h916CvMxDjwo3S-ZXQXVAhc
Mint keys in your dashboard at Settings → API keys. Keys start with ds_live_ and can be revoked at any time.
Quickstart
Create a signing request, get the link back, and send it to your signer.
curl -X POST https://districtseal.com/api/transactions \
-H "X-API-Key: $DISTRICTSEAL_API_KEY" \
-F "signer_name=Alex Morgan" \
-F "signer_email=alex@example.com" \
-F "attestation_type=signature" \
-F "document=@contract.pdf" \
-F "reference=@alex-photo.jpg"
import fs from "fs";
import FormData from "form-data";
const form = new FormData();
form.append("signer_name", "Alex Morgan");
form.append("signer_email", "alex@example.com");
form.append("attestation_type", "signature");
form.append("document", fs.createReadStream("./contract.pdf"));
form.append("reference", fs.createReadStream("./alex-photo.jpg"));
const res = await fetch("https://districtseal.com/api/transactions", {
method: "POST",
headers: { "X-API-Key": process.env.DISTRICTSEAL_API_KEY },
body: form,
});
const { sign_url } = await res.json();
const full = "https://districtseal.com" + sign_url;
// → email `full` to your signer
import os, requests
r = requests.post(
"https://districtseal.com/api/transactions",
headers={"X-API-Key": os.environ["DISTRICTSEAL_API_KEY"]},
files={
"document": open("contract.pdf", "rb"),
"reference": open("alex-photo.jpg", "rb"),
},
data={
"signer_name": "Alex Morgan",
"signer_email": "alex@example.com",
"attestation_type": "signature",
},
)
r.raise_for_status()
sign_url = "https://districtseal.com" + r.json()["sign_url"]
$ch = curl_init("https://districtseal.com/api/transactions");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["X-API-Key: " . getenv("DISTRICTSEAL_API_KEY")],
CURLOPT_POSTFIELDS => [
"signer_name" => "Alex Morgan",
"signer_email" => "alex@example.com",
"attestation_type" => "signature",
"document" => new CURLFile("contract.pdf"),
"reference" => new CURLFile("alex-photo.jpg"),
],
]);
$data = json_decode(curl_exec($ch), true);
$sign_url = "https://districtseal.com" . $data["sign_url"];
Response:
{
"id": 141,
"token": "piAnqGjQwi6t5xolkJeiDKmSGMAtyKoQz6lt4vE4EOJOG2suM7SNcaYbxzj9PNd1",
"status": "created",
"sign_url": "/sign/piAnqGjQwi6t5xolkJeiDKmSGMAtyKoQz6lt4vE4EOJOG2suM7SNcaYbxzj9PNd1",
"document_sha256": "addd1804d882d360c397d3f321e0df37106d16c62b80ff501d9e38b4f6734726"
}
Create a transaction
Uploads the contract and reference photo, creates a signing request, and returns the signer's link.
Form fields
| field | type | notes |
|---|---|---|
| document | file | PDF only. Max 25 MB. |
| reference | file | JPEG / PNG of the signer. Well-lit, face clearly visible. |
| signer_name | string | Full legal name. |
| signer_email | string | Where the signing link is sent. |
| attestation_type | string | signature (default), approval, consent, authorization, witness, or 8 others. See all types. |
| signer_emails | string | Optional. Comma-separated additional signer addresses (up to 4). |
Response
{
"id": 141,
"token": "piAnqGjQwi6t5xolkJeiDKmSGMAtyKoQz6lt4vE4EOJOG2suM7SNcaYbxzj9PNd1",
"status": "created",
"sign_url": "/sign/piAnqGjQwi6t5xolkJeiDKmSGMAtyKoQz6lt4vE4EOJOG2suM7SNcaYbxzj9PNd1",
"document_sha256": "addd1804..."
}
Attestation types
signature, approval, review, consent,
acceptance, endorsement, witness, receipt,
authorization, acknowledgment, certification,
waiver, disclosure
List transactions
Returns paginated transactions for your account. Query params: page, per, q (search), status (verified / failed / progress).
curl "https://districtseal.com/api/transactions?per=25&status=verified" \
-H "X-API-Key: $DISTRICTSEAL_API_KEY"
Get a transaction
Returns the full record for a single transaction — status, verification mode, timestamps, document hash, and ledger seal.
Account info
Returns your account state: plan, credits, monthly count.
{
"id": 2,
"email": "you@example.com",
"plan": "pro",
"signature_credits": 12,
"monthly_count": 4
}
Webhooks — Setup
Register one or more URLs in your dashboard at Settings → Webhooks. Every time a transaction completes or fails, District Seal sends a signed JSON POST to each enabled URL.
X-DistrictSeal-Signature header proves the request came from us. Without verification, anyone could spoof a "signed" event.
Events
transaction.completed
Fired when the signer successfully completes the ceremony and the certificate is generated.
{
"event": "transaction.completed",
"transaction_id": 141,
"public_token": "piAnqGjQwi6t5xolkJeiDKmSGMAtyKoQz6lt4vE4EOJOG2suM7SNcaYbxzj9PNd1",
"status": "ledger_issued",
"signer_name": "Alex Morgan",
"signer_email": "alex@example.com",
"verified_at": "2026-09-24T10:41:14.251586+00:00",
"document_sha256": "addd1804d882d360c397d3f321e0df37106d16c62b80ff501d9e38b4f6734726",
"evidence_manifest_sha256": "...",
"ledger_seal": "DS-619A-285F"
}
transaction.failed
Fired when the signer fails biometric verification after all retry attempts, or when the certificate cannot be produced.
{
"event": "transaction.failed",
"transaction_id": 142,
"public_token": "...",
"status": "failed",
"signer_name": "Alex Morgan",
"signer_email": "alex@example.com",
"failure_reason": "Failed: identity, acoustic"
}
Signature verification
The signature header looks like this:
X-DistrictSeal-Signature: t=1790246479,v1=e58540e4d1302595d1a149649babb65c04825189...
To verify: concatenate {timestamp}.{raw_body}, compute HMAC-SHA256 with your endpoint secret, and compare with v1 using a timing-safe comparison.
import crypto from "crypto";
import express from "express";
app.post(
"/hooks/districtseal",
express.raw({ type: "application/json" }), // raw body — do NOT parse first
(req, res) => {
const header = req.headers["x-districtseal-signature"] || "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.DISTRICTSEAL_WEBHOOK_SECRET)
.update(`${parts.t}.`)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString());
if (event.event === "transaction.completed") {
// mark the contract signed in your own database
}
res.json({ ok: true });
}
);
import hmac, hashlib, json, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/hooks/districtseal")
async def hook(request: Request):
header = request.headers.get("x-districtseal-signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
raw = await request.body()
expected = hmac.new(
os.environ["DISTRICTSEAL_WEBHOOK_SECRET"].encode(),
f"{parts['t']}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(parts["v1"], expected):
raise HTTPException(401, "invalid signature")
event = json.loads(raw)
if event["event"] == "transaction.completed":
pass # mark contract signed
return {"ok": True}
$raw = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_DISTRICTSEAL_SIGNATURE"] ?? "";
$parts = [];
foreach (explode(",", $header) as $p) {
[$k, $v] = explode("=", $p, 2);
$parts[$k] = $v;
}
$expected = hash_hmac(
"sha256",
$parts["t"] . "." . $raw,
getenv("DISTRICTSEAL_WEBHOOK_SECRET")
);
if (!hash_equals($parts["v1"], $expected)) {
http_response_code(401); exit;
}
$event = json_decode($raw, true);
// handle $event
Errors
| status | meaning |
|---|---|
| 400 | Bad request — missing field, invalid file type |
| 401 | Missing or invalid X-API-Key |
| 402 | No credits remaining — top up in your dashboard |
| 403 | Account under review or rejected |
| 404 | Transaction not found (or not yours) |
| 413 | File too large |
| 429 | Rate limit exceeded |
| 500 | Server error — retry with backoff |
Rate limits
Requests are rate-limited per API key. Current limits:
| endpoint | limit |
|---|---|
| /api/transactions (POST) | 60 / minute |
| /api/transactions (GET) | 300 / minute |
| Webhook deliveries | No limit — one POST per event |
When you exceed the limit, we return 429 Too Many Requests with a Retry-After header.
Support
Questions? Email support@districtseal.com. For security disclosures, security@districtseal.com.