Data Science Summit Brasil

The Gentleman Agreement

API reference

Getting started

The API does one thing: it puts a document in front of somebody and proves who signed it. You raise a document with one call and learn the outcome from a webhook. Everything in between — the photo ID, the camera check, the emails — happens on our side.

Gammahttps://api-gamma.thegentlemanagreement.comTest environment. Nothing here is a real signature.
Signer clienthttps://gamma.thegentlemanagreement.comWhere a signer is sent. There is no API for signing on somebody's behalf.

There is no sandbox mode on a production key. Use gamma, whose data is disposable and whose evidence bucket is not under a compliance lock.

Accounts and approval

Anyone can create an account at the console and start raising documents immediately. Nothing reaches the outside world until a person here has approved the account.

This is not a formality. An unapproved account that could email strangers would be a way to send mail from our domain, with our sending reputation, to any address it chose — a phishing kit with a signature ceremony attached. So the gate is on delivery and visibility, not on the API.

What a pending account can and cannot do

While pendingOnce approved
Create documents through the APISame
Buy credits and subscribeSame
No invitation email reaches a signerInvitations are delivered
No signed or completed PDF is emailed to anyoneBoth are delivered to every party
Signers cannot see or open the document at allEveryone in the thread can
No API key is issuedKeys can be issued and revoked

Held mail is kept, not dropped. Approval releases everything written while the account was waiting, in the order it was written, with fresh download links — a presigned URL minted a week earlier would arrive expired. The signer's first sight of the document is an ordinary invitation rather than one that never came.

A signer asking for a document from an unapproved owner is told it does not exist, in the same words an imaginary document gets. Saying "pending approval" would confirm to a stranger that this address was sent something, and by whom.

Signing up

POST /owners/signupemail, organisationName, password. The organisation name is what signers read: "X has asked you to sign".
POST /owners/verify-emailemail, code. Returns a console session.
POST /owners/password/requestEmails a code to the address on the account. Answers 202 whether or not that address is a customer here.
POST /owners/password/resetCode plus a new password. Returns a console session.
POST /owners/loginEmail and password. An address that is not an owner fails exactly like a wrong password.
POST /owners/me/proofA photograph of a licence or a utility bill showing name and address. We read the text and keep that; the image is deleted after 30 days, like a signer's.
GET /owners/meStatus, plan, credit balance, and whether proof has been sent.
PUT /owners/mename and webhookUrl. The URL must be https: the events carry hashes of signed documents. Setting the first URL issues webhookSecret, returned here and by GET /owners/me.
POST /owners/me/webhook-secretRotates webhookSecret. Takes effect at once, including for retries already queued.
POST /owners/me/email/requestStarts an address change. The code goes to the new address, because the current one is already proved by the session.
POST /owners/me/email/confirmCompletes it. The password moves with the account; the old address stops finding it.
POST /owners/me/passwordNeeds the current password, so a borrowed session cannot lock somebody out of their own account.
GET /owners/me/documentsEverything you raised, each party, and who has signed. A document that stopped early carries closedAt and closedReason.
POST /owners/me/documents/{documentId}/voidWithdraws a document from the console. Same rules as the API route.
POST /owners/me/api-keysIssues a key, once approved. Shown once — only a hash is stored.
DELETE /owners/me/api-keys/{keyId}Revokes it immediately.
POST /owners/me/billing/checkoutStripe checkout for credits or a plan. Allowed while pending.

Authentication

Five kinds of caller, five credentials, and they are not interchangeable.

CallerCredentialReaches
You, the document ownerx-api-keyRaising documents, templates, billing
A signerAuthorization: Bearer, minted after an identity checkTheir own documents only
An account holderAuthorization: Bearer, console sessionTheir own account, keys, documents and billing
Somebody reading laterAuthorization: Bearer, reader token from a passwordDocuments they already signed — never signing
An operatorAuthorization: Bearer, operator tokenThe review queues, for accounts and for identity checks

An API key is unique to one account, is shown once when issued, and is stored as a hash — a lost key is replaced, never recovered. Revoking one takes effect on the next request.

An API key identifies an owner and nothing else. It cannot sign, cannot read a signer's other documents, and cannot see an identity photograph — those are deleted on a schedule we do not let callers change.

Raising a document

POST /documents

Send one of three sources. Exactly one.

FieldTypeMeaning
titlestringWhat the signer sees in their list and in the subject line.
signerEmailsstring[]Everyone who must sign. Each signs separately and each is verified separately.
pdfstring, base64A finished PDF. Under 4.5 MB.
markdownstringRendered by us. Simpler than shipping a PDF, and the source is kept with the evidence.
templateIdstringAn approved template, filled with values.
requiresFreshCheckbooleanIgnore any cached verification and make this signer prove identity again today.
expiresAtstring, ISO 8601Optional deadline. Nobody may sign after it, and the document closes itself. In the future and at most 365 days away.
curl -X POST https://api-gamma.thegentlemanagreement.com/documents \
  -H "x-api-key: $TGA_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "title": "Coaching agreement 2026",
    "signerEmails": ["alex@example.com"],
    "pdf": "JVBERi0xLjQKJ...",
    "requiresFreshCheck": false
  }'
{
  "documentId": "doc_9f3c...",
  "status": "open",
  "billable": true,
  "billingReason": "standard"
}

201, and the signer has an email. billable is decided at this moment and does not change afterwards: a signer already verified this calendar month costs nothing extra, whatever happens next.

Several signers

Each party signs the version the previous one produced, so the chain of sealed copies is the record of what each person actually read. There is no "send to all at once" that hides this.

Deadlines, withdrawing, declining

A document can stop without completing in three ways. Each one is final, each one ends it for every party who has not signed, and none of them touches a signature already given: that stays sealed, stays in the evidence export, and stays billed.

RouteWhoStatus afterwards
POST /documents/{documentId}/voidYou, with x-api-keyvoid
POST /documents/{documentId}/declineA signer the document is addressed to, with a signer or reader tokendeclined
The deadline in expiresAt passesNobody — a scheduled pass noticesexpired

Both routes take an optional body, { "reason": "..." }, and answer with the document's status, closedAt and closedReason. The reason reaches you in the webhook; signers see only that the document closed.

Closing a document that is already complete or closed is a 409. So is signing one — including after its deadline, even in the minutes before the scheduled pass has caught up. A document belonging to another account is a 404, the same answer a document that does not exist gets.

One refusal ends the document for everybody. The others are not asked to prove who they are for something that can no longer complete.

Templates

A template is markdown with {{placeholders}}, versioned and approved before use. Raising from an unapproved version is refused — a document nobody signed off is not a document you want in evidence.

GET /templatesList yours.
POST /templatesCreate one.
PUT /templates/{templateId}New draft version.
POST /templates/{id}/versions/{n}/previewRender without raising anything.
POST /templates/{id}/versions/{n}/approveMake a version usable.

Webhooks

Webhooks are how you learn anything. They are queued and retried with backoff, so an endpoint that is down for an hour loses nothing.

EventWhen
document.signedA party signed. Carries both hashes.
document.sentAn invitation went out to one party. For an account still under review, this happens on approval, not before.
document.viewedA party opened the document for the first time. Sent once per party, however often they reread it.
document.completedEvery party has signed.
document.voidedYou withdrew it. Carries reason and signedBy.
document.declinedA party refused. Carries partyEmail, reason and signedBy.
document.expiredThe deadline passed before everybody signed. Carries signedBy.
verification.reviewA signer's identity needs a human. Usually clears within a business day.

Every delivery carries x-tga-signature, an HMAC-SHA256 of the raw body keyed with your account's webhookSecret, which no other owner shares. Verify it before acting, and compare in constant time. An unverified webhook is a stranger telling you a contract was signed.

Evidence

GET /documents/{documentId}/evidence

What a dispute actually needs, and what a caller cannot alter:

  • presentedSha256 — the bytes the signer read.
  • sealedSha256 — the bytes after their signature was written in.
  • The date they chose, and the true server time they confirmed it.
  • The identity decision behind the signature, and which kind of document was shown.

Never the photographs and never the video. Those are deleted on a schedule, which is the point: what survives is proof that a check happened, not the biometrics that made it.

GET /ops/owners/{ownerId}/evidence exports everything you own, including instructions for checking a hash without us. That export exists so leaving is possible.

Billing

You pay per distinct signer per calendar month, never per document and never per seat. A signer is never charged.

PlanPriceIncluded
Prepaid credits$1.00 per signer-monthFour documents for that signer
Small$5 / month10 signer-months
Busier$15 / month100 signer-months
Largest$25 / month1000 signer-months, fair use above 150 new verifications

A verification is cached for 36 months, so a returning signer costs us almost nothing and is not billed again in the same month. GET /billing/balance shows where you are.

Management

The management view at /ops.html is for the people running the platform. It shows revenue by month, signatures billed against total, where the money comes from, and every account with what its proof of identity said.

POST /ops/loginEmail and password, checked by Cognito. Answers with the next step, never with a token.
POST /ops/login/codeThe six-digit code from an authenticator app. A token is minted only here.
POST /ops/login/enrolFirst sign-in: registers the authenticator and turns the requirement on.
POST /ops/login/passwordReplaces the temporary password an administrator created the account with.
GET /ops/reports/revenueTwelve months of revenue and signature counts, read from the billing ledger.
GET /ops/ownersAccounts waiting for review.
GET /ops/owners/allEvery account, with what its proof of identity said.
POST /ops/owners/{ownerId}/decisionApprove or reject. Approving releases every message held while the account waited.
POST /ops/owners/{ownerId}/blockBlock or unblock a live account.
PUT /ops/brandWhich of the four logomarks the site wears.
PUT /ops/site-textThe marketing copy on the front page. A patch: a field left out keeps its words, and a field emptied returns to the copy the site shipped with.

Blocking is not deletion. It stops new mail, hides the account's documents from signers and revokes its API keys immediately. It does not touch anything already signed: a signature is evidence, and evidence does not stop being true because we fell out with the person who asked for it. Unblocking restores the account to approved; the revoked keys stay revoked, and reissuing one is a deliberate act.

How an operator signs in

A Cognito user pool with mandatory TOTP. Cognito checks the password, Cognito checks the code, and only when Cognito is satisfied does this API mint a token of its own — so nothing here stores or verifies an operator password.

The pool is admin-create-only. There is no sign-up, no self-service recovery and no invitation link: an operator exists because somebody with AWS credentials created them. A new account's first sign-in enrols an authenticator before it is allowed any further, because a pool that requires MFA and an account without it is not a way in.

There is no fallback to a password held by this platform. An earlier build had one, gated by an allowlist and a bootstrap secret, and it is gone rather than kept beside this: a fallback to the weaker thing is the weaker thing.

Languages

Every page is written in English and can be read in any language an operator has switched on. A visitor picks a flag at the top of any page; the choice is kept in their browser and, once they sign in, with their account, so it follows them to the next device.

GET /i18n/languagesThe languages on offer, each with its own name, a flag country and a text direction. Public.
GET /i18n/{lang}Every translation for one language, keyed by the English it translates. Public, and only for a language that is switched on.
GET /site-text?lang={lang}The front page, terms and privacy notice in that language, with English for anything not yet translated.
GET /preferences/languageThe signed-in person's language, or null. Any owner, signer or operator token.
PUT /preferences/languagelanguage. Refused unless it is on offer.

Translations are written by operators only, from the management console. They are plain text and are drawn as plain text: anything containing < or > is refused rather than published.

Errors and limits

Errors are {"error": "a sentence"}. The status carries the meaning.

StatusMeans
400The request is wrong. Repeating it unchanged will not help.
401No credential, or one we do not recognise.
403A valid credential of the wrong kind.
409Already done. Signing is idempotent on x-idempotency-key.
429Rate limited. Identity calls cost us real money, so they are limited per email, per IP and per day.
502Something we depend on failed. You did nothing wrong; retry.

A request body is capped at about 4.5 MB, which is an API Gateway limit rather than a policy.

What the signer does

Nothing to install, and no account.

  1. Photograph a driver's licence or passport.
  2. A short camera check, matched against the photo on the ID.
  3. A six-digit code to their email, which is what ties the identity to the address you gave us.
  4. Read, type their name, confirm.

No camera on their computer? The page offers a QR code that carries the whole check to their phone and hands the thread back when it is done. There is no weaker path: if the identity check cannot run, no signature is collected.

After signing, a signer can set a password and come back to read what they signed. That password mints a reader token and nothing more: it opens documents already signed and is refused by the signing route. A password proves somebody knows a secret, which is exactly the evidence this product exists to replace — so signing always means an ID and a live face, every time.

A signer's own profile is at GET /auth/profile: their address, when their verification expires, and whether they have set a password. POST /auth/profile/password changes it — somebody who has just proved an ID and a live face may set one outright, while somebody who arrived with a password must know the current one.

A signer's address is not editable. Documents were addressed to it by whoever asked for the signature, and the signatures on them record it; moving them would rewrite what the evidence says. A new address is simply a new address — the identity check still counts for three years, so there is nothing to repeat.

Signers must be 18 or over. A parent or guardian signs for a minor, and the record says so.