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:v1— live. Authentication, reads and field data, template + composed sending, PDF uploads, and webhooks are all shipped. The/api/v1contract 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)
- 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.
- Authenticate. Send it as a bearer token on every request:
- Make your first call. This returns the tenant + key the request resolved to:
Authorization: Bearer hk_live_...
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." } }
type | HTTP | When |
|---|---|---|
authentication_error | 401 | Missing / malformed / unknown / revoked key |
permission_error | 403 | Account inactive, or plan lacks API access |
invalid_request | 400 / 409 | Bad parameter, or a state conflict (e.g. envelope not completed) |
not_found | 404 | Resource doesn't exist or isn't yours (no existence oracle) |
rate_limited | 429 | Daily request cap exceeded |
api_error | 500 | Unexpected 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:
| Field | Required | Notes |
|---|---|---|
template_id | yes | A template you own. |
signers | yes | [{ role, name, email }] — role matches a template role by name or id. Must cover every template role. |
title | no | Defaults to the template name. |
cc | no | [{ name, email }] — copied on completion. |
payment_request | no | { 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. |
metadata | no | Up 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[] entry | Meaning |
|---|---|
{ "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:
POST /api/v1/uploads/presignwith{ "content_type": "application/pdf" }→
201 { upload_id, upload_key, url, method, headers, expires_at }.
PUTthe raw PDF bytes tourlwith the returnedheaders(Content-Type: application/pdf).- Reference
{ "upload_key": "..." }in adocuments[]group onPOST /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: queued → processing → completed | 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: pending → viewed → signed | 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
| Event | Fires when |
|---|---|
envelope.sent | An envelope is sent to signers |
signer.completed | A signer finishes signing |
envelope.completed | All signers are done and the final PDF + certificate exist |
envelope.declined | A signer declines |
envelope.voided | The sender voids an envelope |
payment.processed | A post-sign payment is confirmed |
attachment.received | A signer uploads a requested supporting document |
batch.completed | A 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.