Keepable docs
Sender API

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:

statusMeaning
draftCreated in authoring mode. You can place signing fields, then send it. No signatures collected yet, no expiry clock.
activeSent and awaiting signatures. The clock to expires_at is running.
completedEvery signer has signed. The covenant is available.
revokedYou cancelled it before completion.
expiredThe 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();
201 Created
{ "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.

Replace the placed fields of a draft
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:

Send the draft for signing
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

roleMeaning
signerMust apply a signature for the agreement to complete.
witnessApplies a visible mark "in the presence of" the signers. Gates the seal, but is not itself a required signatory.
delegateReceives 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"
200 OK
{
  "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_tierWhat backs it
platform_attestedSession, a visible mark, and the Keepable seal (L0).
webauthn_boundAlso 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"
200 OK
{ "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.