TrustSeal integration guide for banks

TrustSeal lets your customers sign documents remotely after proving two things: they are a live person, and they are the owner of the national ID you name. You make two API calls and handle one webhook; TrustSeal does the identity checks and the signing. A typical integration takes about one developer-day.

Demo environment: {{ORIGIN}} · Sample bank app: {{BANK}} · API reference: {{ORIGIN}}/api/docs

How it works

  1. Your server gets an access token with its client credentials.
  2. Your server creates a signing session: the PDF(s), the customer's national ID number, a reason and a return_url.
  3. TrustSeal returns a one-time signing_url. You send it to the customer by SMS, push notification, email, or open it inside your app.
  4. The customer reviews the document, photographs their ID card, passes a head-turn liveness check and a face match, and signs.
  5. TrustSeal rejects anyone whose card does not carry the ID number you named, even if they are live and match their own card.
  6. TrustSeal signs every document as PAdES B-LTA with a one-time certificate, redirects the customer to your return_url, and sends you a signed session.signed webhook.
  7. You download the signed PDF and the evidence pack.

1. Get credentials

A TrustSeal operator creates your tenant in the Ops console (Tenants → Onboard a new bank) or on the server with make tenant NAME="Your Bank" WEBHOOK=https://your-bank.example/webhooks/trustseal. You receive:

ItemUseKeep secret?
client_idIdentifies your serverNo
client_secretExchanged for access tokensYes, server side only
webhook_secretVerifies webhook signaturesYes

The demo tenant used by the Nile Bank app is nilebank-demo / nilebank-demo-secret.

2. Authenticate (OAuth 2.0 client credentials)

curl -s {{ORIGIN}}/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=nilebank-demo \
  -d client_secret=nilebank-demo-secret
{ "access_token": "eyJhbGciOiJFZERTQSIs…", "token_type": "Bearer", "expires_in": 900,
  "scope": "sessions:write sessions:read documents:read" }

Tokens last 15 minutes. Cache one and refresh it shortly before it expires.

3. Create a signing session

curl -s {{ORIGIN}}/api/v1/partner/sessions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: LOAN-2026-88231" \
  -d '{
    "external_ref": "LOAN-2026-88231",
    "signer": { "nid": "29001010100015", "name": "Ahmed Mohamed Ali", "phone": "+201000000000" },
    "documents": [{ "filename": "loan-agreement.pdf", "content_base64": "JVBERi0xLjQK…" }],
    "signing_reason": "Personal loan agreement LOAN-2026-88231",
    "return_url": "https://your-bank.example/loans/88231/done",
    "expires_in_hours": 72
  }'
FieldRequiredNotes
documents[]Yes1 to 5 PDFs, base64, up to 50 MB each
signer.nidStrongly recommended14 digits. Hashed with a secret pepper on arrival; the raw number is never stored. Without it, anyone holding the link could sign
signer.name, signer.phoneNoShown to the customer; phone is for your own delivery
external_refNoYour reference; echoed in webhooks
signing_reasonNoShown to the customer and written into the signature
return_urlNoWhere the customer goes after signing
expires_in_hoursNo1 to 720, default 72
Idempotency-Key headerRecommendedRetrying with the same key returns the same session instead of creating a second one

Response (201):

{
  "session_id": "ses_5a1f…", "status": "CREATED", "external_ref": "LOAN-2026-88231",
  "signing_url": "{{ORIGIN}}/s/?id=ses_5a1f…&k=…",
  "expires_at": "2026-11-16T10:00:00+00:00",
  "documents": [{ "id": "doc_…", "filename": "loan-agreement.pdf", "sha256": "…" }],
  "signed_documents": []
}

4. Deliver the link

  • SMS / email / push: send signing_url as is. It is a bearer link tied to one session; never post it publicly.
  • QR code: for a customer at a branch or on a laptop, show it as a QR code to open on a phone.
  • Inside your app: open it in a WebView or an iframe with camera permission: <iframe src="…signing_url…" allow="camera" style="width:100%;height:100%">.

The camera works only over HTTPS (or localhost).

5. Receive webhooks

TrustSeal POSTs JSON to your webhook URL on each change:

EventWhen
session.openedCustomer opened the link
session.verifiedLiveness + face match passed and the ID matches your customer
session.signedAll documents signed (includes signed_document_ids)
session.failed3 attempts with a different person's ID
session.expired / session.cancelledExpiry reached / you cancelled
{ "id": "evt_…", "type": "session.signed", "created_at": "2026-11-13T10:21:07+00:00",
  "data": { "session_id": "ses_…", "external_ref": "LOAN-2026-88231", "status": "SIGNED",
            "signed_document_ids": ["doc_…"] } }

Headers: X-TrustSeal-Event, X-TrustSeal-Delivery (unique id, use it to ignore duplicates) and X-TrustSeal-Signature: t=<unix>,v1=<hex>, where v1 = HMAC-SHA256(webhook_secret, "<t>." + raw_body).

Always verify the signature and reject events older than 5 minutes. Answer 2xx within 5 seconds; failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h.

Python:

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - t) < 300

Node.js:

const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(',').map(s => s.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${p.t}.`).update(rawBody).digest('hex');
  return expected.length === p.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1)) && Math.abs(Date.now() / 1000 - p.t) < 300;
}

Webhooks are a signal, not the source of truth. If one is missed, poll the session.

6. Get the results

CallReturns
GET /api/v1/partner/sessions/{id}Status, timestamps, liveness and match scores, signed document ids
GET /api/v1/partner/documents/{doc_id}The signed PDF
GET /api/v1/partner/sessions/{id}/evidenceZIP evidence pack
POST /api/v1/partner/sessions/{id}/cancelCancels a session that is not finished
GET /api/v1/partner/usageSession counts by status, for billing

The evidence pack contains the original and signed PDFs, a validation report, the hash-chained audit trail of the session (times, scores, decisions, image hashes), and the certificate chain. Biometric images are never stored and are not in the pack. Keep it with the contract for disputes.

Registered customers (no integration change)

Customers who registered with TrustSeal after an earlier ID + face check see a second button on the signing page: I am registered with TrustSeal: selfie only. They pass the same head-turn liveness check, and their live face is compared with the encrypted template from their registration. Because your session names the customer (signer.nid), they don't type an ID number, and a registered person who is not your customer is blocked exactly like a wrong ID card.

Nothing changes for you: same API, same webhooks, same evidence pack. The audit trail in the evidence pack shows which path was used: citizen.authenticated (registered, selfie only) instead of nid.scanned + biometric.verified (ID card).

Session lifecycle

StatusMeaningFinal?
CREATEDSession exists, link not openedNo
OPENEDCustomer opened the linkNo
VERIFIEDIdentity checks passed, waiting for the signatureNo
SIGNEDDocuments signedYes
FAILEDToo many wrong-person attemptsYes
EXPIREDNot finished before expires_atYes
CANCELLEDCancelled by the bankYes

Errors

HTTPMeaningWhat to do
400 / 422Invalid input (bad NID format, no documents)Fix the request; detail says what
401Missing, invalid or expired access tokenGet a new token
404Session or document not found for your tenantCheck the id
409Wrong state (cancel a signed session, evidence before signing)Read the session status
415File is not a usable PDF (encrypted, corrupt, over 50 MB)Send a plain PDF
429Rate limitBack off and retry after Retry-After

Security checklist for your integration

  • Keep client_secret and webhook_secret on the server, in a secrets store.
  • Always send signer.nid so only your customer can sign.
  • Verify every webhook signature and timestamp; de-duplicate by X-TrustSeal-Delivery.
  • Use Idempotency-Key on session creation.
  • Treat signing_url as a password: deliver it only to the customer.
  • Check the final status with GET /sessions/{id} before booking the transaction.

Try it in the demo

  1. Open Demo ID card and create a SPECIMEN card with your selfie; note its ID number.
  2. Open the Nile Bank app at {{BANK}}, approve a loan for that ID number.
  3. Open the signing link on a phone (QR code), choose Use my demo ID card or photograph the card on screen, do the head-turn check, sign.
  4. Watch the bank page: status, webhooks and every API call update live.
  5. Try a different person's card: the session blocks it and counts the attempt.
  6. Download the signed PDF and validate it at {{ORIGIN}}/validate/; tick simulate tampering to see it fail; revoke the certificate in the Ops console and validate again.

If the room's lighting or camera is a problem, an operator can switch the Ops console to demo mode, which simulates the biometric checks and labels every result as simulated.

Reference: other endpoints

EndpointPurpose
POST /api/v1/ocr/scan-nidRead an ID card image (used by the hosted page)
POST /api/v1/biometric/verifyLiveness + face match; returns verification_id
POST /api/v1/verification/tokenOne-time signing token (15 min)
POST /api/v1/signSign a PDF with a token (direct, non-bank use)
POST /api/v1/validateValidate any signed PDF
GET /pki/root.crt, /pki/issuing.crt, /pki/tsa.crtTrust anchors
GET /pki/issuing.crl, POST /pki/ocspRevocation status
POST /pki/tsaRFC 3161 timestamps
GET /.well-known/jwks.jsonPublic key for TrustSeal-issued tokens

What changes in production

The API you integrate against stays the same. Behind it, Phase C replaces the demo components: keys move into a FIPS 140-2 Level 3 HSM, the CA becomes a licensed certification service, liveness uses an ISO 30107-3 certified engine, identity is also checked against the national registry, and mTLS plus IP allow-lists protect the bank API.