HankoSign API

The HankoSign REST API lets you send envelopes from templates, read envelope status,

pull completed field data into your systems, and download signed PDFs — over plain HTTPS

with an API key. This is the developer reference; it is versioned in the repo alongside

the code it documents.

Status: v1live. Authentication, reads and field data, template + composed sending, PDF uploads, and webhooks are all shipped. The /api/v1 contract is additive-only: new fields and endpoints may appear at any time, but an existing field never changes meaning or disappears — you can build against it safely.

Base URL: https://hankosign.com/api/v1


Getting started (in five minutes)

  1. Get a key. In HankoSign, go to Settings → API & Integrations (requires the

Business plan) and click Create key. Copy the key shown — it starts

with hk_live_ and is displayed exactly once. Store it somewhere safe; treat it

like a password. A key acts on behalf of your entire account.

  1. Authenticate. Send it as a bearer token on every request:
  2. Authorization: Bearer hk_live_...
  3. Make your first call. This returns the tenant + key the request resolved to:
  4. curl https://hankosign.com/api/v1/me \
      -H "Authorization: Bearer hk_live_..."
    {
      "tenant": { "id": "01H...", "name": "Example Co", "plan": "bus_premium", "account_type": "company" },
      "key": { "label": "Production key", "prefix": "hk_live_ab12cd34", "last4": "9x4z", "created_at": "2026-07-21T10:00:00.000Z" }
    }

To revoke a key, use Settings → API & Integrations → Revoke. Revocation is immediate.


Authentication

Every request must carry Authorization: Bearer <key>. There are no cookies, no sessions,

and no CSRF tokens on the API — it is a pure key-authenticated surface. A request fails with

401/403 if the key is missing, malformed, unknown, revoked, the account is not active,

or the account lacks API access. All API access is scoped to the key's own account; you can

never reach another account's data.


Errors

Every error returns the same JSON envelope with a matching HTTP status:

{ "error": { "type": "authentication_error", "code": "invalid_key", "message": "Missing or invalid API key." } }
typeHTTPWhen
authentication_error401Missing / malformed / unknown / revoked key
permission_error403Account inactive, or plan lacks API access
invalid_request400 / 409Bad parameter, or a state conflict (e.g. envelope not completed)
not_found404Resource doesn't exist or isn't yours (no existence oracle)
rate_limited429Daily request cap exceeded
api_error500Unexpected server error

Handle errors on error.code (a stable machine string), not on message (human-readable,

may change). Common codes: invalid_key, key_revoked, account_inactive,

api_access_required, rate_limited, invalid_cursor, envelope_not_found,

envelope_not_completed, document_not_found, document_not_ready,

certificate_not_ready, template_not_found, bulk_not_enabled, batch_not_found,

unknown_endpoint.


Rate limits

Each key is capped at 5,000 requests per day (UTC). Every response carries:

X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4987

Over the cap you get 429 rate_limited with a Retry-After header (seconds until the

window resets at the next UTC midnight).


Pagination

List endpoints take ?limit= (default 25, max 100) and return an opaque next_cursor:

{ "data": [ ... ], "next_cursor": "eyJQSyI6..." }

Pass it back as ?cursor=<next_cursor> to get the next page. When next_cursor is null,

you've reached the end. A filtered page (e.g. ?status=) can return fewer than limit

items and still have more — keep following next_cursor until it's null. Cursors are

opaque; don't construct or parse them. All timestamps are ISO-8601 UTC.


Idempotency

Write requests that create resources require an Idempotency-Key header — a unique

string you generate per logical operation (a UUID is ideal):

Idempotency-Key: 5f3a...-your-unique-key

The first request with a given key does the work and stores its response for 24 hours.

A **retry with the same key and the same request body replays that stored response

verbatim — it never sends the envelope twice. Reusing a key with a different request

body** returns 409 idempotency_error ("key already used with different parameters") —

a key is one-shot for one set of parameters. A retry while the first request is still in

flight returns 409 idempotency_conflict. Keys are scoped to your account. This makes

network retries safe: reuse the same key and you'll get exactly one envelope.


Endpoints

GET /me

Returns the account and key the request resolved to. Useful as an auth smoke test.

POST /envelopes

Create and send an envelope from a template. Requires an Idempotency-Key header

(see Idempotency). Runs the exact same send pipeline as the app, so

signer emails go out, self-fields bake, and field order freezes just as they would from

the UI.

Body:

FieldRequiredNotes
template_idyesA template you own.
signersyes[{ role, name, email }]role matches a template role by name or id. Must cover every template role.
titlenoDefaults to the template name.
ccno[{ name, email }] — copied on completion.
payment_requestno{ amount, currency?, signer_email? }. amount is an integer in cents (min 50); currency one of usd/cad/eur/gbp; signer_email picks the payer when there's more than one signer. Requires the payments feature.
metadatanoUp to 20 keys; values are stored as strings and echoed back on the envelope (your correlation ids).
curl -X POST https://hankosign.com/api/v1/envelopes \
  -H "Authorization: Bearer hk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "01H...",
    "signers": [{ "role": "Employee", "name": "Ada Lovelace", "email": "ada@example.com" }],
    "metadata": { "crm_id": "lead_42" }
  }'

Returns 201 with the full envelope object (status: "sent"). Common errors:

unbound_roles (a template role has no signer), unknown_role, template_not_found,

envelope_limit_reached, payments_not_enabled.

Composing documents (multiple templates + uploaded files)

Instead of a single template_id, send a documents array to compose one envelope from

several templates and/or raw PDFs you uploaded (see Uploads).

Provide documents or template_id, never both.

documents[] entryMeaning
{ "template_id": "01H..." }A template you own — contributes its documents, fields, and roles.
{ "name": "Cover letter", "files": [{ "upload_key": "uploads/api/...", "filename": "cover.pdf" }] }A raw uploaded-PDF group. Field-less — a supplement (cover letter, exhibit) that rides along; nobody signs on it.

Rules:

  • At least one template is required — uploaded PDFs carry no signer fields, so the

signable content always comes from a template.

  • Groups keep the order you list them in. signers[] binds template roles exactly as in

the single-template case.

  • Role names are namespaced per template. If two composed templates each define a role

called Client, a bare "role": "Client" is ambiguous (400 ambiguous_role) — qualify

it as "0:Client" / "1:Client" (the number is the template's position in documents).

Same-named roles are never merged automatically. Binding the same email to two role

slots means one person fills both.

  • Composed templates must share one signing mode (else 400 signing_mode_conflict).
curl -X POST https://hankosign.com/api/v1/envelopes \
  -H "Authorization: Bearer hk_live_..." -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "documents": [
      { "template_id": "01H_LEASE" },
      { "name": "Cover letter", "files": [{ "upload_key": "uploads/api/9/01J....pdf" }] }
    ],
    "signers": [{ "role": "Tenant", "name": "Ada Lovelace", "email": "ada@example.com" }]
  }'

Additional errors for composition: mode_conflict, no_template, document_invalid,

too_many_files, too_many_pages, plus the upload errors below.

POST /uploads/presign

Get a one-time URL to upload a PDF straight to storage, then reference it in a

documents[] group. (The API request-body limit is small, so bytes never go through the

API itself — you PUT them to the presigned URL.)

The flow:

  1. POST /api/v1/uploads/presign with { "content_type": "application/pdf" }

201 { upload_id, upload_key, url, method, headers, expires_at }.

  1. PUT the raw PDF bytes to url with the returned headers (Content-Type: application/pdf).
  2. Reference { "upload_key": "..." } in a documents[] group on POST /envelopes.

Limits and behavior:

  • ≤ 25 MB per file, ≤ 10 uploaded files and ≤ 300 total pages per send.
  • Uploads are scoped to your account and expire ~24h after presigning if unused.
  • Every uploaded PDF is malware-scanned, which typically clears within a few seconds of

the PUT. If you reference it before the scan finishes you get 409 upload_scan_pending

retry the send shortly; reusing the same Idempotency-Key is safe (a scan-pending

response is not cached, so the retry re-runs once the scan clears). A file that fails the

scan is rejected (422 upload_rejected). Reference errors: unknown_upload, upload_not_uploaded.

POST /envelopes/:id/void

Void an envelope with an optional { "reason": "..." }. Only draft or sent envelopes

can be voided (else 409 envelope_not_voidable). Sent-envelope signers are notified, and

an unsigned envelope refunds its monthly send slot. Returns the updated envelope.

Bulk sending

Send one template to many recipients at once — each row becomes its own independent

envelope (its own signing link, audit trail, and certificate). Bulk sending is a Business

feature (bulk_not_enabled 403 otherwise).

POST /envelopes/bulk

Create up to 1000 envelopes from one template. Requires an Idempotency-Key header (a

retried bulk send that double-created hundreds of envelopes is the worst failure this endpoint

has). Returns 202 Accepted with the batch — the rows are then sent asynchronously;

poll GET /batches/:id for progress, or subscribe to the

batch.completed webhook.

role matches a template role by id or name (same as POST /envelopes). Per-row email

validity is checked when each row is sent and surfaces as that row's failed status — the

batch is accepted whole so one bad address doesn't reject the good rows.

curl -X POST https://hankosign.com/api/v1/envelopes/bulk \
  -H "Authorization: Bearer hk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "01H...",
    "title": "Q3 onboarding",
    "envelopes": [
      { "signers": [ { "role": "Employee", "name": "Ada Lovelace", "email": "ada@example.com" } ] },
      { "signers": [ { "role": "Employee", "name": "Grace Hopper", "email": "grace@example.com" } ] }
    ]
  }'
{
  "id": "01H...",
  "object": "bulk_batch",
  "template_id": "01H...",
  "title": "Q3 onboarding",
  "status": "processing",
  "total": 2,
  "sent": 0,
  "failed": 0,
  "pending": 2,
  "created_at": "2026-08-12T14:00:00.000Z",
  "completed_at": null
}

Batch status: queuedprocessingcompleted | completed_with_errors | aborted

(aborted if the account loses sending access mid-batch).

GET /batches/:id

The batch summary plus a page of its rows. Rows paginate by row number — pass

?starting_after=<row> (and optional ?limit=, default 100, max 500) to continue;

next_cursor is the last row returned, or null at the end.

{
  "id": "01H...",
  "object": "bulk_batch",
  "template_id": "01H...",
  "title": "Q3 onboarding",
  "status": "completed_with_errors",
  "total": 2, "sent": 1, "failed": 1, "pending": 0,
  "created_at": "2026-08-12T14:00:00.000Z",
  "completed_at": "2026-08-12T14:02:00.000Z",
  "rows": [
    { "row": 1, "status": "sent", "to": ["ada@example.com"],
      "envelope_id": "01H...", "envelope_number": "ENV-000200", "error": null, "error_code": null },
    { "row": 2, "status": "failed", "to": ["grace@example.com"],
      "envelope_id": null, "envelope_number": null,
      "error": "Invalid email: grace@example", "error_code": "signer_email_invalid" }
  ],
  "next_cursor": null
}

Each created envelope carries batch_id (see GET /envelopes/:id) so you

can correlate webhooks and reads back to the batch.

GET /envelopes

Lists your envelopes, newest first. Query params: limit, cursor, status

(draft · sent · completed · voided · declined · signer_withdrawn · archived).

{
  "data": [
    {
      "id": "01H...",
      "envelope_number": "ENV-000123",
      "title": "Mutual NDA",
      "status": "completed",
      "signing_mode": "sequential",
      "created_at": "2026-07-20T14:00:00.000Z",
      "sent_at": "2026-07-20T14:05:00.000Z",
      "completed_at": "2026-07-20T15:30:00.000Z",
      "voided_at": null
    }
  ],
  "next_cursor": null
}

GET /envelopes/:id

Full envelope. documents[] are the signed document groups — group_id is what the

download endpoint takes, and has_final says whether a signed PDF exists yet.

{
  "id": "01H...",
  "envelope_number": "ENV-000123",
  "title": "Mutual NDA",
  "status": "completed",
  "signing_mode": "sequential",
  "created_at": "2026-07-20T14:00:00.000Z",
  "sent_at": "2026-07-20T14:05:00.000Z",
  "completed_at": "2026-07-20T15:30:00.000Z",
  "voided_at": null,
  "signers": [
    { "id": "01H...", "name": "Ada Lovelace", "email": "ada@example.com",
      "status": "signed", "order": 1, "in_person": false, "is_self": false,
      "signed_at": "2026-07-20T15:30:00.000Z", "declined_at": null }
  ],
  "cc": [],
  "documents": [
    { "group_id": "01H...", "name": "NDA.pdf", "page_count": 3, "has_final": true }
  ],
  "has_payment_request": false,
  "payment_status": null,
  "template_id": null,
  "batch_id": null,
  "metadata": {}
}

Signer status: pendingviewedsigned | declined. batch_id is the bulk send

that created this envelope (see Bulk sending), or null for a single send.

GET /envelopes/:id/fields

The completed field values an envelope collected — the endpoint that turns HankoSign

into a data-collection API (get notified an envelope completed, then pull the values, no

PDF parsing). Available once the envelope's status is completed; otherwise returns

409 envelope_not_completed.

{
  "data": [
    { "id": "01H...", "type": "text", "label": "Full legal name",
      "page": 1, "group_id": "01H...", "required": true,
      "value": "Ada Lovelace", "filled_at": "2026-07-20T15:29:00.000Z",
      "signer_id": "01H...", "signer_name": "Ada Lovelace", "signer_email": "ada@example.com" }
  ]
}

Static sender annotations (freetext boxes baked onto the document at send) are not

returned — only fields a signer actually completed. A field with no entered value (e.g. an

untouched optional field) returns filled_at: null — a non-null filled_at always marks a

value the signer actually entered.

GET /envelopes/:id/documents/:groupId/download

Redirects (302) to a short-lived link for the final signed PDF of one document group.

API clients follow redirects automatically. 409 document_not_ready if signing isn't

finished.

GET /envelopes/:id/certificate

Same, for the Certificate of Completion. 409 certificate_not_ready until the envelope

finishes.

GET /templates

Flat list of your templates (id, name, folder_path).

GET /templates/:id — the field map

The table of fields to integrate a template: the roles[] a send call must bind, and

per field the type, label, assigned role, whether it's required, and its page.

This is what tells you what a send needs and what signers will fill in.

{
  "id": "01H...",
  "name": "Employee NDA",
  "folder_path": "HR / Onboarding",
  "roles": [ { "id": "01H...", "name": "Employee", "order": 0 } ],
  "fields": [
    { "id": "01H...", "type": "text", "label": "Full name", "role": "Employee",
      "role_id": "01H...", "required": true, "page": 1, "group_id": "01H...", "options": null }
  ]
}

You can also export this exact JSON, or a CSV, straight from a template's detail page in

the app (Export field map → JSON / CSV) — handy for handing to a developer without

writing any code first.


Field types and the field map

A field's type is one of: text, date, date_entry, checkbox, radio,

dropdown, number, signature, initials, title, company_name, full_name. For

radio/dropdown, options lists the choices. freetext (baked sender annotations) is

never exposed as signer data.

A privacy note, by design. HankoSign has no sensitive-data field types — there are no SSN, bank-account, or card-number fields on the platform, so the field-values endpoint cannot become a sensitive-data extraction vector. What you get back is exactly the ordinary form data your signers chose to complete.

Webhooks

Instead of polling, register an endpoint and HankoSign will POST a signed event to it

when things happen. Manage endpoints in Settings → API & Integrations → Webhooks, or

via POST /webhook_endpoints (the signing secret, whsec_…, is returned once on create).

Events

EventFires when
envelope.sentAn envelope is sent to signers
signer.completedA signer finishes signing
envelope.completedAll signers are done and the final PDF + certificate exist
envelope.declinedA signer declines
envelope.voidedThe sender voids an envelope
payment.processedA post-sign payment is confirmed
attachment.receivedA signer uploads a requested supporting document
batch.completedA bulk send reaches a terminal state; data.batch is the batch summary

An endpoint subscribes to specific events, or to all of them. Most events carry

data.envelope; batch.completed carries data.batch instead.

Events mark state transitions, so each fires exactly once for a given transition.

In particular, envelope.sent fires once per envelope — resending the invitation email

(a reminder or resend) changes no state and emits no webhook event.

Payload

Every delivery is a POST with Content-Type: application/json and two headers:

Hankosign-Event: envelope.completed
Hankosign-Signature: t=1721563200,v1=<hex hmac>

The body carries ids + status only — never document bytes or field values (fetch those

after the event with GET /envelopes/:id/fields):

{
  "id": "evt_01H...",
  "type": "envelope.completed",
  "created": "2026-07-21T12:00:00.000Z",
  "data": {
    "envelope": { "id": "01H...", "status": "completed", "signers": [ ... ], "...": "..." },
    "signer_id": "01H..."
  }
}

data.envelope is the same object as GET /envelopes/:id.

Verifying the signature

Hankosign-Signature is Stripe's scheme — t=<unix>,v1=<hmac> where the HMAC-SHA256 is

computed over "{t}.{raw_body}" with your endpoint's whsec_ secret. Verify against the

raw request body, and reject a stale t.

Node

const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(p.v1), Buffer.from(expected))
    && Math.abs(Date.now() / 1000 - Number(p.t)) < 300;
}

Python

import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    p = dict(kv.split("=") for kv in header.split(","))
    expected = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(p["v1"], expected) and abs(time.time() - int(p["t"])) < 300

Delivery, retries, and failures

Respond 2xx quickly and process asynchronously — deliveries have a 10-second timeout.

A non-2xx (or timeout) is **redelivered on the queue's visibility schedule — roughly every

15 minutes**, up to the queue's maximum receive count, after which the event is moved to a

dead-letter queue. (This is fixed-interval redelivery, not exponential backoff.) After 20

consecutive failures an endpoint is auto-disabled and we email your account

administrators; re-enable it in Settings → API & Integrations → Webhooks once your server

is healthy (re-enabling resets the failure counter). Events may arrive more than once — make your handler idempotent on id.

A delivery that support redelivers for you carries a new id and is rebuilt from the

object's current state at redelivery time — so a redelivered payload can differ from the

original if the envelope changed in between. Treat every delivery as the latest snapshot.


Coming soon

  • Signable uploaded PDFs — today an uploaded PDF is a field-less supplement; placing

signer fields on a raw upload (via explicit coordinates or auto-detected form fields) is

planned for when an integrator needs it. For now, put signable content in a template.