Agreements
Run e-signature workflows. Send a document to one or more signers, track signatures as they land, and download a tamper-evident PAdES-LTV covenant when everyone has signed.
When a recipient needs to sign something (an employment contract, a loan agreement, a consent form), you use an agreement rather than plain content. An agreement wraps a document, a set of participants, and the signatures collected against it, and produces a tamper-evident covenant PDF when complete.
Agreements need agreement.write to create and revoke, agreement.read to read
and download.
Lifecycle
An agreement moves through five states:
status | Meaning |
|---|---|
draft | Created in authoring mode. You can place signing fields, then send it. No signatures collected yet, no expiry clock. |
active | Sent and awaiting signatures. The clock to expires_at is running. |
completed | Every signer has signed. The covenant is available. |
revoked | You cancelled it before completion. |
expired | The expires_at deadline passed before all signers signed. |
There are two ways to create one. Send immediately (mode omitted or
"active") when you have no fields to place. Author as a draft
(mode: "draft") when you want to place signature or date fields on the page
first, then call send.
Each transition fires a webhook:
agreement.created, agreement.sent (on the draft-then-send path),
agreement.signed (once per signature), agreement.completed,
agreement.revoked, and agreement.expired, so you can drive your own workflow
off the events instead of polling.
Create an agreement
Post the document and the participants. The document is a single
content part: a name, a MIME
media_type, and base64 data. Each participant needs at minimum an email
and a role:
curl https://api.keepable.co/sender/tenants/ten_01HXP/agreements \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"subject": "Employment contract",
"document": {
"name": "contract.pdf",
"media_type": "application/pdf",
"data": "JVBERi0xLjcK..."
},
"participants": [
{ "nin": "12345678901", "name": "Ada Eze", "email": "ada@example.ng", "role": "signer" }
]
}'const res = await fetch(
"https://api.keepable.co/sender/tenants/ten_01HXP/agreements",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Keepable-Version": "2026-05-24",
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
subject: "Employment contract",
document: {
name: "contract.pdf",
media_type: "application/pdf",
data: contractBase64,
},
participants: [
{ nin: "12345678901", name: "Ada Eze", email: "ada@example.ng", role: "signer" },
],
}),
},
);
const { agreement_id, status, expires_at } = await res.json();{ "agreement_id": "agr_01HXP", "status": "active", "expires_at": "2026-06-23T09:00:00Z" }The create response is a lazy view: just the id, status, and expiry. Fetch the full agreement (below) to see participants and signatures.
Draft mode and field placement
Pass mode: "draft" on create to author before you send. A draft collects no
signatures and has no expiry clock. You then place signing fields on the
document and call send to make it active.
A field is assigned to one participant (by index) and positioned with
page-normalized coordinates, fractions in [0, 1] with a top-left origin, so
they land identically in the editor, the signing overlay, and the sealed
covenant. type is one of signature, initial, date, text, or
checkbox.
curl -X PUT https://api.keepable.co/sender/tenants/ten_01HXP/agreements/agr_01HXP/fields \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Content-Type: application/json" \
-d '{
"fields": [
{ "type": "signature", "page_index": 0, "x": 0.12, "y": 0.80, "w": 0.30, "h": 0.06, "assignee_seq": 0 },
{ "type": "date", "page_index": 0, "x": 0.55, "y": 0.80, "w": 0.20, "h": 0.04, "assignee_seq": 0 }
]
}'PUT replaces the full set of fields each call. Fields you can also set:
required (default true) and a label. Then send it:
curl -X POST https://api.keepable.co/sender/tenants/ten_01HXP/agreements/agr_01HXP/send \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)"send sets expires_at, drops the signer invites, fires agreement.sent, and
returns the now-active agreement. You can only place fields while the
agreement is a draft; editing fields after send returns
409 Conflict.
Participant roles
role | Meaning |
|---|---|
signer | Must apply a signature for the agreement to complete. |
witness | Applies a visible mark "in the presence of" the signers. Gates the seal, but is not itself a required signatory. |
delegate | Receives and can act on the agreement on someone's behalf, without ever signing. |
role is required per participant; email addresses the invite, and nin and
name are optional but recommended, since a NIN ties the signature to a verified
identity.
nin, name, and email are inbound only. Keepable resolves them, folds
them into the sealed covenant, and then drops them: a read never echoes a raw
NIN, name, or email. A fetched participant or signature carries only the
keyed-HMAC nin_hash pseudonym.
Track signatures
Fetch an agreement to see who has signed. The full view includes the
participants and a signatures array that grows as people sign:
curl https://api.keepable.co/sender/tenants/ten_01HXP/agreements/agr_01HXP \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24"{
"agreement_id": "agr_01HXP",
"status": "completed",
"participants": [
{ "nin_hash": "b1946ac9…", "role": "signer" }
],
"signatures": [
{
"nin_hash": "b1946ac9…",
"signed_at": "2026-05-25T10:00:00Z",
"signature_id": "sig_01HXP",
"assurance_tier": "webauthn_bound"
}
]
}Every signature carries an assurance_tier that tells you how strongly it
is bound to the signer (ADR 0065):
assurance_tier | What backs it |
|---|---|
platform_attested | Session, a visible mark, and the Keepable seal (L0). |
webauthn_bound | Also a per-document WebAuthn assertion from the signer's own passkey, user-verification required and independently checkable (L1). |
An L1 signature also carries an assertion object: a self-verifying WebAuthn
evidence bundle you can re-check offline against the document hash, or via the
public signature verifier. It is omitted for L0.
In practice you would not poll this: you would listen for agreement.signed and
agreement.completed webhooks and fetch the full agreement only when an event
tells you something changed.
You can also list every agreement for a tenant with pagination:
curl "https://api.keepable.co/sender/tenants/ten_01HXP/agreements?limit=50" \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24"Download the covenant
Once an agreement is completed, download its covenant, the signed document
as a PAdES-LTV PDF. PAdES-LTV ("Long-Term Validation") embeds the signature
validation material in the PDF itself, so the signature stays verifiable years
later without contacting Keepable.
curl https://api.keepable.co/sender/tenants/ten_01HXP/agreements/agr_01HXP/covenant \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24"{ "name": "covenant.pdf", "data": "JVBERi0xLjcK...", "sha256": "9f86d0818..." }Verify the sha256. Decode data from base64 and check its SHA-256
against the sha256 field before you archive or display the covenant. It is a
cheap guard that the bytes arrived intact.
Revoke
Cancel an active agreement before it completes, for example if the terms
changed. Revocation is terminal:
curl -X POST https://api.keepable.co/sender/tenants/ten_01HXP/agreements/agr_01HXP/revoke \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)"A successful revoke returns 204 No Content and fires agreement.revoked. You
cannot revoke an agreement that has already completed or expired.
Sender attestation keys
Register a public key so you can sign the content you deliver. A sender signature proves a document is exactly what you sent, not just that Keepable sealed it.
Consent requests
Ask an identity-addressed recipient to grant or decline something specific, get the decision back over a webhook, and keep a verifiable receipt on both sides.