Developer docs

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.

Get going

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.

Create your first signing request
# 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.

Authentication

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 lastUsedAt so you can audit which integrations are still active.
A minimal client wrapper
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.

The bread & butter

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
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/{id}?t={accessToken}
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.

HMAC-SHA-256

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.

Verify in Node
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));
}
Verify in Python
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.

Standards-based

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

ScopeAdds
openidRequired. sub, iss, aud, iat, exp, nonce.
emailemail, email_verified.
profilename (from displayName).
identityidentity_verified, plus identity_legal_name + identity_country when KYC is approved.
offline_accessIssues a refresh_token.
signing:proofAdds signing_proof, an Ed25519 signature you verify locally.

Verify the id_token

Node + jose
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.

DocSign-native

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.

Partner-side: mint a challenge
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.
Partner-side: verify the webhook (Node)
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.

No DocSign trust required

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.

OpenSSL one-liner (raw bytes)
# 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.bin
Python
from 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 failure
11 providers, 140+ jurisdictions

Business 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

ProviderTierAuthCoverage
gleiffreenoneAny LEI-registered entity, globally
uk_companies_housefreeCOMPANIES_HOUSE_API_KEYGB โ€” full registry + officers + filings
us_sec_edgarfreeEDGAR_USER_AGENTUS public companies (~9k)
fr_insee_sirenefreeINSEE_API_KEYFR โ€” Sirene v3, all SIREN/SIRET
br_cnpjfreeUA tagBR โ€” Receita Federal CNPJ via BrasilAPI
be_kbofreenoneBE โ€” KBO/BCE via OpenKBO mirror
open_corporatesfreeOPENCORPORATES_API_KEY140+ jurisdictions aggregator
sumsubpaidApp token + secret220+ countries, full KYB workflow + sanctions + PEP
onfidopaidToken (+ region)Global Studio workflows
trulioopaidBearer195+ countries
comply_advantagepaidKeyGlobal 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.

Lookup + discrepancy report
# 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).

What partners actually consume

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.

LevelScoreMeaning
unverified0โ€“30Submission incomplete or failed checks
basic31โ€“60Fields complete, no registry confirmation yet
verified61โ€“80Registry confirms identity + no sanctions hits
enhanced81โ€“95+ UBO disclosure + operator-approved
qualified96โ€“100Admin 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.

GET /api/v1/businesses/{id}/trust
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.

businesses_verified in the id_token
{
  "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.

Operational

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.

Today & tomorrow

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/*.mjs are 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.