Build against DocSign in an afternoon.
Three integration surfaces, all documented below: the Bearer REST API (signing requests + KYC), OIDC / OAuth 2.0 (Login with DocSign), and the crypto-proof flow (verifiable Ed25519 signatures). The wire format is small enough that this page is usually all you need.
Quickstart
You'll need a DocSign account and an API key. Sign up, confirm email, then go to Settings โ API keys โ Create key. The plaintext key (dsk_โฆ) is shown once.
# 1. Set up your key.
export DOCSIGN_KEY="dsk_..."
export DOCSIGN_BASE="https://docsign.example.com"
# 2. Hash the payload you want signed. SHA-256 hex.
export HASH=$(printf "Sign invoice #4711" | sha256sum | awk '{print $1}')
# 3. POST the request.
curl -sS -X POST "$DOCSIGN_BASE/api/signing-requests" \
-H "authorization: Bearer $DOCSIGN_KEY" \
-H "content-type: application/json" \
-d "{
\"targetEmail\": \"ada@example.com\",
\"payloadHash\": \"$HASH\",
\"payloadPreview\": \"Sign invoice #4711\",
\"callbackUrl\": \"https://yourapp.com/hooks/docsign\",
\"expiresInMinutes\": 60
}"The response contains a signLink (forward this to the signer), an id, and a callbackSecret you'll use to HMAC-verify the webhook.
API keys
Every backend request uses an Authorization: Bearer dsk_โฆ header. Keys carry ~192 bits of entropy and are SHA-256-hashed at rest, so a database leak doesn't yield usable keys.
- Create / revoke keys at
Settings โ API keys. - Plaintext (
dsk_โฆ) is returned exactly once on creation. - Revoked keys stop working immediately; in-flight requests succeed only if the lookup ran before revoke.
- Each key tracks
lastUsedAtso you can audit which integrations are still active.
export class DocSign {
constructor({ base, apiKey }) { this.base = base; this.apiKey = apiKey; }
async fetch(path, init = {}) {
const r = await fetch(this.base + path, {
...init,
headers: {
"content-type": "application/json",
"authorization": "Bearer " + this.apiKey,
...(init.headers ?? {}),
},
});
if (!r.ok) throw new Error(`DocSign ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
createSigningRequest(body) {
return this.fetch("/api/signing-requests", { method: "POST", body: JSON.stringify(body) });
}
}Read more: API keys + webhooks.
Signing requests
A SigningRequest tells DocSign "I want this payload signed by this email by this deadline." DocSign hosts the signer-facing page and re-runs Ed25519 verification on the server before storing the signature. The result lands in your callbackUrl (HMAC-signed) and via GET /api/signing-requests/{id} for polling.
Create
POST /api/signing-requests HTTP/1.1
authorization: Bearer dsk_...
content-type: application/json
{
"targetEmail": "ada@example.com",
"payloadHash": "9f86d081...", // hex sha256
"payloadPreview": "Sign invoice #4711", // optional, human-readable
"documentId": null, // optional, link an uploaded doc
"callbackUrl": "https://yourapp.com/hooks/docsign",
"expiresInMinutes": 60
}
200 OK
{
"id": "cmpqk1d...",
"signLink": "https://docsign.example.com/sign/cmpqk1d...?t=...",
"expiresAt": "2026-06-01T12:34:56Z",
"callbackSecret": "..." // returned exactly once
}Poll status
GET /api/signing-requests/cmpqk1d...?t=acc-token HTTP/1.1
200 OK
{
"id": "cmpqk1d...",
"status": "signed",
"targetEmail": "ada@example.com",
"payloadHash": "9f86d081...",
"expiresAt": "2026-06-01T12:34:56Z",
"createdAt": "2026-05-29T10:00:00Z"
}Read more: Signing requests.
Webhooks
When a signing request or auth-proof finishes, DocSign POSTs to your callbackUrl with the JSON payload and an x-docsign-signature: sha256=<hex> header. Compute HMAC-SHA-256(rawBody, callbackSecret) and constant-time compare.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDocSignWebhook(rawBody, headerSig, secret) {
const expected = "sha256=" + createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
if (expected.length !== headerSig.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
}import hmac, hashlib
def verify_docsign_webhook(raw_body: bytes, header_sig: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header_sig)The signing-request webhook body shape is documented inline at API keys + webhooks.
Sign in with DocSign (OIDC)
DocSign acts as a standards-compliant OpenID Connect Provider. Use any off-the-shelf OIDC client library (oidc-client-ts, openid-client, etc). PKCE-S256 is required; the implicit flow isn't offered.
- Discovery:
/.well-known/openid-configuration - JWKS:
/.well-known/jwks.json(RS256) - Authorize:
/api/oauth/authorize - Token:
/api/oauth/token - Userinfo:
/api/oauth/userinfo - Revoke:
/api/oauth/revoke(RFC 7009)
Register a client
Settings โ OAuth clients โ Register. You get a dso_โฆ client_id and a client_secret returned exactly once. Pick the scopes you need.
Scopes
| Scope | Adds |
|---|---|
| openid | Required. sub, iss, aud, iat, exp, nonce. |
email, email_verified. | |
| profile | name (from displayName). |
| identity | identity_verified, plus identity_legal_name + identity_country when KYC is approved. |
| offline_access | Issues a refresh_token. |
| signing:proof | Adds signing_proof, an Ed25519 signature you verify locally. |
Verify the id_token
import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet(
new URL("https://docsign.example.com/.well-known/jwks.json"),
);
const { payload } = await jwtVerify(idToken, jwks, {
issuer: "https://docsign.example.com",
audience: CLIENT_ID,
});
// payload.sub is stable per user; payload.email is the verified address.
// If you asked for signing:proof, payload.signing_proof is an Ed25519
// signature you can independently verify with @noble/ed25519.Full walkthrough: Sign in with DocSign.
Cryptographic-proof login
Stronger than OIDC alone: the user signs a partner-supplied nonce with their email-confirmed Ed25519 key. You verify the Ed25519 signature locally โ no need to trust DocSign's id_token signing key for the proof itself.
const partnerNonce = base64url(crypto.getRandomValues(new Uint8Array(16)));
const r = await fetch(BASE + "/api/auth-proof/challenge", {
method: "POST",
headers: { "authorization": "Bearer dsk_...", "content-type": "application/json" },
body: JSON.stringify({
clientName: "Acme Login",
partnerNonce,
callbackUrl: "https://yourapp.com/hooks/docsign-proof",
expiresInMinutes: 5,
}),
});
const { signLink, callbackSecret, payloadHashHex } = await r.json();
// Redirect the user to signLink; stash callbackSecret keyed by partnerNonce
// so you can verify the webhook later.import * as ed from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";
import { createHash, createHmac } from "node:crypto";
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
export async function onWebhook(rawBody, headerSig, callbackSecret) {
// 1. HMAC integrity check.
const expected = "sha256=" + createHmac("sha256", callbackSecret)
.update(rawBody).digest("hex");
if (expected !== headerSig) throw new Error("bad HMAC");
const body = JSON.parse(rawBody);
// 2. Reconstruct the canonical payload hash on your side.
const canonical = [
body.version, body.audience, body.clientName,
body.partnerNonce, body.serverNonce, String(body.issuedAtUnixSec),
].join("\n");
const myHash = createHash("sha256").update(canonical).digest("hex");
if (myHash !== body.payloadHashHex) throw new Error("payload hash mismatch");
// 3. Ed25519-verify the signature against the returned public key.
const ok = await ed.verifyAsync(
base64urlDecode(body.signatureB64u),
hexDecode(body.payloadHashHex),
base64urlDecode(body.publicKeyB64u),
);
if (!ok) throw new Error("Ed25519 verification failed");
// body.email + body.identityStatus tell you who the user is.
}Full walkthrough: Cryptographic-proof login.
Verifying signatures
Every signature DocSign emits is plain Ed25519 over SHA-256. Verifying it doesn't require any DocSign-side endpoint โ paste the components into /verify or run the same check in any language.
# pubkey.raw = 32-byte raw Ed25519 public key
# message.bin = the SHA-256 hash bytes that were signed
# sig.bin = the 64-byte Ed25519 signature
openssl pkeyutl -verify \
-pubin -inkey <(openssl pkey -pubin -in pubkey.pem) \
-rawin -in message.bin \
-sigfile sig.binfrom nacl.signing import VerifyKey
import base64, binascii
pub = VerifyKey(base64.urlsafe_b64decode(pub_b64u + "=="))
pub.verify(binascii.unhexlify(payload_hash_hex),
base64.urlsafe_b64decode(sig_b64u + "=="))
# raises BadSignatureError on failureBusiness verification (KYB)
The KYB stack ships eleven registry providers covering ~140 jurisdictions plus four paid-aggregator stubs. Most are session-gated (you call them from the in-app form), but the data model and the provider router are open enough that you can hit them programmatically too โ see the feature page for the architectural detail.
Provider coverage matrix
| Provider | Tier | Auth | Coverage |
|---|---|---|---|
| gleif | free | none | Any LEI-registered entity, globally |
| uk_companies_house | free | COMPANIES_HOUSE_API_KEY | GB โ full registry + officers + filings |
| us_sec_edgar | free | EDGAR_USER_AGENT | US public companies (~9k) |
| fr_insee_sirene | free | INSEE_API_KEY | FR โ Sirene v3, all SIREN/SIRET |
| br_cnpj | free | UA tag | BR โ Receita Federal CNPJ via BrasilAPI |
| be_kbo | free | none | BE โ KBO/BCE via OpenKBO mirror |
| open_corporates | free | OPENCORPORATES_API_KEY | 140+ jurisdictions aggregator |
| sumsub | paid | App token + secret | 220+ countries, full KYB workflow + sanctions + PEP |
| onfido | paid | Token (+ region) | Global Studio workflows |
| trulioo | paid | Bearer | 195+ countries |
| comply_advantage | paid | Key | Global sanctions + PEP + adverse media (not a registry) |
Router order
The router walks a per-country chain on every lookup: configured paid providers (Sumsub โ Onfido โ Trulioo) first, then the country-specialized free provider, then OpenCorporates, then GLEIF. A configured paid key wins automatically โ no code path changes. Force a specific provider with the forceProvider field on the lookup payload.
# When you pass businessId, the response also includes a field-by-field
# discrepancy report comparing the registry hit to the user's submission.
curl -X POST https://docsign.example.com/api/business-verification/lookup \
-H "cookie: docsign_session=..." \
-H "content-type: application/json" \
-d '{
"lei": "HWUPKR0MPOU8FGXBT394",
"businessId": "cmpq...",
"forceProvider": "gleif"
}'
# {
# "provider": "gleif",
# "hits": [{
# "legalName": "Apple Inc.",
# "registrationNumber": "0000320193",
# "lei": "HWUPKR0MPOU8FGXBT394",
# "status": "ACTIVE",
# "jurisdiction": "US-CA",
# "address": "ONE APPLE PARK WAY, CUPERTINO 95014, US"
# }],
# "discrepancy": {
# "items": [
# { "field": "legalName", "user": "Wrong Co", "registry": "Apple Inc.",
# "severity": "blocker", "note": "names do not match" }
# ],
# "overallSeverity": "blocker"
# }
# }Sanctions screening
Names (the business itself and every UBO) are screened against OFAC SDN, EU consolidated, UN consolidated, and UK HMT lists. The matcher uses token-set Jaccard with business-suffix noise filtering, so "Acme Corp" and "Acme Limited" still match. Bootstrap the lists once with node scripts/sanctions-bootstrap.mjs; re-run as a cron to keep them fresh. Results land in BusinessSanctionsHit with a 0..1 score and an open/resolved workflow.
Read more: Business verification (KYB).
KYB trust levels
Every verified business has a trust level: one of unverified, basic, verified, enhanced, qualified. The level is computed from eight factors (fields complete, docs uploaded, registry confirmation, no open sanctions hits, UBOs declared, directors declared, submitted for review, operator-approved) and capped at 95 โ the top rung is an admin-only deliberate decision.
| Level | Score | Meaning |
|---|---|---|
| unverified | 0โ30 | Submission incomplete or failed checks |
| basic | 31โ60 | Fields complete, no registry confirmation yet |
| verified | 61โ80 | Registry confirms identity + no sanctions hits |
| enhanced | 81โ95 | + UBO disclosure + operator-approved |
| qualified | 96โ100 | Admin override โ full human review trail |
Two ways to read the level
Bearer API. For backends that have an API key for the user โ same auth as the rest of the API.
curl https://docsign.example.com/api/v1/businesses/cmpq.../trust \
-H "authorization: Bearer dsk_..."
# {
# "id": "cmpq...", "legalName": "Apple Inc.", "country": "US",
# "level": "enhanced", "score": 85,
# "factors": [
# { "key": "fields_complete", "passed": true, "earned": 15, "max": 15 },
# { "key": "registry_confirmation", "passed": true, "earned": 20, "max": 20,
# "detail": "gleif confirmed registration number" },
# { "key": "no_sanctions_hits", "passed": true, "earned": 15, "max": 15 },
# { "key": "operator_approved", "passed": true, "earned": 10, "max": 10 }
# ],
# "override": null,
# "via": "api_key"
# }OIDC scope business:trust. For partners using Sign in with DocSign โ surfaces a per-user summary in the id_token + userinfo so you can gate without a second call.
{
"sub": "cmpqxxx",
"email": "ada@example.com",
"businesses_verified": {
"count": 2,
"highestLevel": "enhanced",
"highestScore": 85,
"businesses": [
{ "id": "cmpq...", "legalName": "Apple Inc.", "country": "US",
"level": "enhanced", "score": 85, "status": "approved" }
]
}
}Admin overrides
Operators with User.isAdmin = true see a ๐ก๏ธ Admin ยท KYB queue sidebar entry. From there they can approve/reject submissions, resolve sanctions hits, and pin a trust level explicitly (reason required). Overrides are sticky โ re-computation doesn't clobber them. Every action is audited under business.* action names.
Errors & rate limits
JSON error shape is { error, error_description?, details? }. HTTP status codes follow standard conventions plus:
- 401 โ missing / invalid Bearer key or session.
- 403 โ authenticated but not authorized for this resource.
- 423 โ account is locked after repeated failed logins.
- 429 โ rate-limited. Back off ~1s and retry.
Token-bucket rate limits live in src/lib/ratelimit.ts โ defaults are conservative. Hit 429? Wait and retry; the bucket refills.
SDKs & tooling
There's no official SDK yet โ the wire format is small enough that the snippets above usually cover you. If you build a thin wrapper, model it on scripts/e2e.mjs and scripts/oidc-login-test.mjs in the repo, which exercise every flow end-to-end.
- End-to-end test scripts under
scripts/*.mjsare the most up-to-date reference clients. - GitHub repo โ full source, MIT-style permissive licence.
- Long-form docs live in the repo under
docs/:API.md,INTEGRATION.md,ARCHITECTURE.md,SECURITY.md.
Stuck on something?
Every feature has its own walkthrough page if you want more depth on the design choices.