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 URLhttps://districtseal.com/api
AuthX-API-Key header
Formatmultipart/form-data for uploads, JSON otherwise
Webhook sigHMAC-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.

Never expose your API key in client-side code. Always call District Seal from your own backend. If you need to sign requests from the browser, proxy through your server.

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

POST /api/transactions

Uploads the contract and reference photo, creates a signing request, and returns the signer's link.

Form fields

fieldtypenotes
documentfilePDF only. Max 25 MB.
referencefileJPEG / PNG of the signer. Well-lit, face clearly visible.
signer_namestringFull legal name.
signer_emailstringWhere the signing link is sent.
attestation_typestringsignature (default), approval, consent, authorization, witness, or 8 others. See all types.
signer_emailsstringOptional. 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

GET /api/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

GET /api/transactions/{id}

Returns the full record for a single transaction — status, verification mode, timestamps, document hash, and ledger seal.

Account info

GET /v1/me

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.

Verify every webhook. The 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

statusmeaning
400Bad request — missing field, invalid file type
401Missing or invalid X-API-Key
402No credits remaining — top up in your dashboard
403Account under review or rejected
404Transaction not found (or not yours)
413File too large
429Rate limit exceeded
500Server error — retry with backoff

Rate limits

Requests are rate-limited per API key. Current limits:

endpointlimit
/api/transactions (POST)60 / minute
/api/transactions (GET)300 / minute
Webhook deliveriesNo 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.