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
- Your server gets an access token with its client credentials.
- Your server creates a signing session: the PDF(s), the customer's national ID number, a reason and a
return_url. - TrustSeal returns a one-time
signing_url. You send it to the customer by SMS, push notification, email, or open it inside your app. - The customer reviews the document, photographs their ID card, passes a head-turn liveness check and a face match, and signs.
- TrustSeal rejects anyone whose card does not carry the ID number you named, even if they are live and match their own card.
- TrustSeal signs every document as PAdES B-LTA with a one-time certificate, redirects the customer to your
return_url, and sends you a signedsession.signedwebhook. - 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:
| Item | Use | Keep secret? |
|---|---|---|
client_id | Identifies your server | No |
client_secret | Exchanged for access tokens | Yes, server side only |
webhook_secret | Verifies webhook signatures | Yes |
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
}'
| Field | Required | Notes |
|---|---|---|
documents[] | Yes | 1 to 5 PDFs, base64, up to 50 MB each |
signer.nid | Strongly recommended | 14 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.phone | No | Shown to the customer; phone is for your own delivery |
external_ref | No | Your reference; echoed in webhooks |
signing_reason | No | Shown to the customer and written into the signature |
return_url | No | Where the customer goes after signing |
expires_in_hours | No | 1 to 720, default 72 |
Idempotency-Key header | Recommended | Retrying 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_urlas 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:
| Event | When |
|---|---|
session.opened | Customer opened the link |
session.verified | Liveness + face match passed and the ID matches your customer |
session.signed | All documents signed (includes signed_document_ids) |
session.failed | 3 attempts with a different person's ID |
session.expired / session.cancelled | Expiry 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
| Call | Returns |
|---|---|
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}/evidence | ZIP evidence pack |
POST /api/v1/partner/sessions/{id}/cancel | Cancels a session that is not finished |
GET /api/v1/partner/usage | Session 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
| Status | Meaning | Final? |
|---|---|---|
CREATED | Session exists, link not opened | No |
OPENED | Customer opened the link | No |
VERIFIED | Identity checks passed, waiting for the signature | No |
SIGNED | Documents signed | Yes |
FAILED | Too many wrong-person attempts | Yes |
EXPIRED | Not finished before expires_at | Yes |
CANCELLED | Cancelled by the bank | Yes |
Errors
| HTTP | Meaning | What to do |
|---|---|---|
| 400 / 422 | Invalid input (bad NID format, no documents) | Fix the request; detail says what |
| 401 | Missing, invalid or expired access token | Get a new token |
| 404 | Session or document not found for your tenant | Check the id |
| 409 | Wrong state (cancel a signed session, evidence before signing) | Read the session status |
| 415 | File is not a usable PDF (encrypted, corrupt, over 50 MB) | Send a plain PDF |
| 429 | Rate limit | Back off and retry after Retry-After |
Security checklist for your integration
- Keep
client_secretandwebhook_secreton the server, in a secrets store. - Always send
signer.nidso only your customer can sign. - Verify every webhook signature and timestamp; de-duplicate by
X-TrustSeal-Delivery. - Use
Idempotency-Keyon session creation. - Treat
signing_urlas 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
- Open Demo ID card and create a SPECIMEN card with your selfie; note its ID number.
- Open the Nile Bank app at
{{BANK}}, approve a loan for that ID number. - 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.
- Watch the bank page: status, webhooks and every API call update live.
- Try a different person's card: the session blocks it and counts the attempt.
- 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
| Endpoint | Purpose |
|---|---|
POST /api/v1/ocr/scan-nid | Read an ID card image (used by the hosted page) |
POST /api/v1/biometric/verify | Liveness + face match; returns verification_id |
POST /api/v1/verification/token | One-time signing token (15 min) |
POST /api/v1/sign | Sign a PDF with a token (direct, non-bank use) |
POST /api/v1/validate | Validate any signed PDF |
GET /pki/root.crt, /pki/issuing.crt, /pki/tsa.crt | Trust anchors |
GET /pki/issuing.crl, POST /pki/ocsp | Revocation status |
POST /pki/tsa | RFC 3161 timestamps |
GET /.well-known/jwks.json | Public 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.