Send documents for signature from your own software, follow each one from viewed to signed, and pull the signed PDF back into your app. Included with Pro and Teams.
There is no test mode. Every document you create is real and legally binding, counts toward your allowance, and sends completion copies. delivery: "link" only skips the invitation email, so it is not a sandbox. To try things out, send to your own email address.
Create a key
Requests need a Pro or Teams workspace. Go to API settings and click Create key. Inkly shows the key once, so store it somewhere safe.
Send your key in the Authorization header as Bearer ink_live_…. A key in a URL is refused with api_key_in_query, because URLs end up in logs.
A key acts as the person who created it. The primary owner and Teams admins manage keys, and a workspace can have 10 live keys. The API sends no CORS headers, so call it from your server and never from a browser.
A Read key has
templates:readdocuments:read
A Read and send key has
templates:readdocuments:readdocuments:send
Requests and responses
The base URL is https://www.inkly.so/api/v1. Bodies are JSON, sent with Content-Type: application/json, and can be up to 64 KB.
Unknown fields in a request body or list query are refused with unknown_field, so a typo cannot pass silently. Responses can gain new fields without notice, so ignore the ones you do not recognize.
Lists return newest first. When next_cursor is not null, pass it as cursor to get the next page. limit is 1 to 100, and defaults to 25.
Every response from the API carries an Inkly-Request-Id header. Include it if you contact support.
Idempotency
The two send endpoints require an Idempotency-Key header: any unique value you choose, 1 to 255 printable ASCII characters. If a request times out, retry it with the same key and you get the first result rather than a second document.
A replayed response carries Idempotent-Replayed: true, including a replayed error.
5xx and 429 outcomes are not stored, so the same key can retry them.
Keys are unique per workspace and endpoint, shared across that workspace's API keys, and kept for 24 hours.
The same key with a different body returns idempotency_key_reused.
Other endpoints refuse the header with idempotency_not_supported.
Limits
A workspace can create 250 documents a month through the API on Pro and 1,000 on Teams, resetting on the 1st at 00:00 UTC. Past that, sends return 429 monthly_allowance_exceeded. A plan change applies straight away to what the workspace has already used this month. Only documents created through the API count: reads, signing links, reminders, and voids do not, and documents sent from the Inkly app stay unlimited.
Requests per IP address
300 a minute
Requests per key
120 a minute
Requests per workspace
300 a minute, 20,000 a day
Document sends per workspace
20 a minute
Signing links per workspace
300 an hour
Signed PDF downloads
100 an hour
Certificate downloads
30 an hour
Attachment downloads per key
100 an hour
PDF upload
20 MB, 500 pages, 1 hour to use
One document
25 recipients, 5 of them CCs, 500 fields
A 429 always carries Retry-After, in seconds. It usually also carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. Other responses do not carry these headers, except that 409 upload_in_progress also carries Retry-After.
The per-IP limit is enforced by the network in front of the API. Its 429 is plain text, with no JSON error body and none of these headers, and no Inkly-Request-Id. Wait up to a minute before retrying, and handle a 429 whose body is not JSON.
Errors
Errors use the HTTP status and this body. Branch on code, which is stable. message can change.
Error body
{
"error": {
"code": "recipient_invalid_email",
"message": "The document could not be sent: recipient invalid email.",
"param": "recipients.0.email",
"request_id": "req_4f9a1c7e2b8d4a60"
}
}
Any endpoint can return these:
invalid_api_key401Provide a valid API key in the Authorization header.
api_key_in_query400Send API keys in the Authorization header, never in a URL.
api_disabled403 or 503API access is disabled for this workspace (403) or for everyone for now (503).
plan_required403API access requires Pro or Teams.
scope_required403This key does not have the required scope.
rate_limited429Too many requests. Try again after the indicated reset time.
idempotency_not_supported400This endpoint does not support Idempotency-Key.
internal_error500The request could not be completed. Try again.
Every error code (63)
400 Bad request
api_key_in_query400Send API keys in the Authorization header, never in a URL.
dropdown_options_invalid400A dropdown's options are missing, duplicated, or not valid.
field_out_of_bounds400A field falls outside the page.
idempotency_key_required400Provide an Idempotency-Key header.
idempotency_not_supported400This endpoint does not support Idempotency-Key.
invalid_request400The request is invalid.
invite_message_too_long400The invitation message is too long.
prefilled_field_invalid_label400A prefilled field's label is not valid.
prefilled_field_invalid_value400A prefilled value contains text that cannot be used.
prefilled_value_conflict400Prefilled fields with the same label must have the same value.
recipient_invalid_email400A recipient's email address is not valid.
recipient_missing_email400A recipient has no email address.
unknown_field400This field is not supported.
unknown_prefilled_field400This prefilled field is not in the template.
401 and 403 Authentication and access
invalid_api_key401Provide a valid API key in the Authorization header.
api_disabled403 or 503API access is disabled.
plan_required403API access requires Pro or Teams.
scope_required403This key does not have the required scope.
sending_paused403Sending is paused for this workspace. Contact support.
storage_limit_reached403This workspace has reached its document storage limit. Upgrade your plan or email hello@inkly.so for more storage, then retry with a new Idempotency-Key.
void_not_allowed403Only the document's creator or a workspace admin can void it.
404 and 410 Not found
attachment_not_found404Attachment not found.
document_not_found404Document not found.
template_not_found404Template not found.
upload_not_found404Upload not found.
upload_expired410This upload has expired. Create a new upload and try again.
409 Conflict
certificate_not_ready409The certificate is not ready. Retry shortly.
document_changed409The document or template changed while it was being sent. Retry with a new Idempotency-Key.
document_not_completed409This document is not completed.
document_not_voidable409Only sent documents can be voided.
idempotency_key_reused409This idempotency key was used with a different request.
idempotency_request_in_progress409A request with this idempotency key is still running.
reminder_cooldown409A reminder is already in progress or cooling down.
reminder_not_available409Reminders are not available for this document.
signed_pdf_not_ready409The signed PDF is not ready. Retry shortly.
signing_link_unavailable409This signing link is not available.
upload_already_used409This upload has already been used to send a document.
upload_in_progress409Another request is processing this upload. Retry shortly.
upload_incomplete409The PDF has not been uploaded yet. PUT the file to upload_url, then retry.
413 and 422 Content that cannot be used
body_too_large413The JSON body must be at most 64 KB.
cc_recipient_cannot_have_fields422A CC recipient cannot be assigned fields.
cc_recipient_duplicate_email422The same CC email address appears more than once.
cc_recipient_limit_exceeded422A document can have at most 5 CC recipients.
document_has_no_fields422The document has no fields.
document_has_no_recipients422The document has no recipients.
document_has_no_signers422The document has no signers.
google_drive_not_connected422The template saves to Google Drive, but Drive is not connected.
in_person_not_supported422Templates with in-person signers cannot be sent through the API.
invalid_pdf422The uploaded file is not a valid PDF.
marker_invalid422A field marker is malformed, wraps onto another line, starts in the middle of other text, does not fit on its page, or takes the document over 500 fields.
marker_not_found422use_markers is true, but the PDF has no field markers Inkly can read. A scanned PDF has no text, so send fields with coordinates instead.
marker_page_unsupported422A page with a field marker uses text or layout Inkly cannot place a field on exactly. Fix the PDF, or send fields with coordinates instead.
marker_pdf_too_complex422The PDF is too large or complex for marker detection. Send fields with coordinates instead.
marker_unknown_recipient422A field marker names a signer that is not a signer ref in this request.
pdf_too_many_pages422The PDF has more than 500 pages.
prefilled_value_missing422A required prefilled field has no value.
recipient_limit_exceeded422A document can have at most 25 recipients.
selection_group_incomplete422A checkbox or radio group on the template is incomplete.
signer_has_no_fields422A signer has no field to fill in.
429 Too many requests
monthly_allowance_exceeded429The monthly API document allowance has been reached.
rate_limited429Too many requests. Try again after the indicated reset time.
500 and 503 Server errors
internal_error500The request could not be completed. Try again.
temporarily_unavailable503The service is temporarily unavailable. Try again.
Account
Check which workspace a key belongs to and how much of the monthly allowance is left.
Get the current key
get/me
Returns the key, its workspace, and the current monthly allowance. Any valid key can call this, so it is a good first request.
Send a document from a template or from a PDF you upload. Both create a real, legally binding document.
Send a document from a template
post/documents
Creates and sends a real document from a template. recipients must fill every role on the template exactly once. Templates with in-person signers are refused. Each document counts toward the monthly allowance.
Needs a key with documents:send. Requires an Idempotency-Key header.
Request body
template_idUUIDRequired
The template to send.
titlestring
The document title. Defaults to the template's title.
1 to 120 characters
external_idstring
Your own reference for this document, in printable ASCII. It is not required to be unique.
Up to 255 characters
recipientsarray of objectsRequired
One entry for every template role, each role exactly once.
1 to 25 items
4 child fields
namestring
The person's name.
Up to 200 characters
emailemailRequired
The person's email address.
role_idUUIDRequired
The template role this person fills. Get it from GET /templates/{id}.
deliverystring
email sends the invitation and reminders. link sends neither and gives you signing_url to share yourself. It is not a sandbox: the document is real and legally binding.
One of email, link
Default email
ccarray of objects
Extra people who get a copy once the document is complete.
Up to 5 items
2 child fields
namestring
The person's name.
Up to 200 characters
emailemailRequired
The person's email address.
prefilled_valuesobject
Values for the template's prefilled fields, keyed by field label.
messagestring
A personal note in the invitation.
Up to 2000 characters
automatic_remindersboolean
Send automatic reminders until the document is signed.
How this person hears from Inkly. Always email for CC recipients.
One of email, link
statusstringRequired
One of pending, viewed, signed, declined
signing_urlURL or nullRequired
Set only when delivery is link and it is this signer's turn. With sequential signing, later signers get null: when GET /documents/{id} shows is_signing_turn is true, call POST /documents/{id}/recipients/{recipient_id}/signing-link. Treat it like a password: anyone holding it can sign.
role_idUUID or nullRequired
The template role. null for people added through cc.
Reserves a one-hour upload for a PDF of up to 20 MB and 500 pages. Then send the file with PUT to upload_url, setting Content-Type: application/pdf and no API key, and pass upload_id to POST /documents/from-upload.
Needs a key with documents:send.
Request body
file_namestringRequired
Must end in .pdf.
byte_sizenumberRequired
The exact size of the file you will upload, in bytes.
Creates and sends a real document from a PDF you uploaded, placing fields by page and position. x, y, width, and height are fractions of the page measured from its top-left corner. Or set use_markers to true and write fields into the PDF as text such as {{signature;signer=client}}: Inkly creates a field at each marker, never changes the PDF, and refuses the whole send if a marker is wrong or its position cannot be read exactly. See Field markers for the syntax and how to hide a marker. Prefilled fields must be text. Each document counts toward the monthly allowance.
Needs a key with documents:send. Requires an Idempotency-Key header.
Request body
upload_idUUIDRequired
From POST /uploads, after the PDF has been uploaded.
titlestring
The document title. Defaults to the file name.
1 to 120 characters
external_idstring
Your own reference for this document, in printable ASCII. It is not required to be unique.
Up to 255 characters
signing_orderstring
sequential invites signers one at a time, in the order listed.
One of parallel, sequential
Default parallel
messagestring
A personal note in the invitation.
Up to 2000 characters
automatic_remindersboolean
Send automatic reminders until the document is signed.
Default false
recipientsarray of one of the types belowRequired
Up to 25 people, at most 5 of them CCs.
1 to 25 items
2 types
Signer
namestring
The person's name.
Up to 200 characters
emailemailRequired
The person's email address.
refstringRequired
Your own short name for this person, unique in this request. Fields point at it.
1 to 40 characters
role"signer"
Default signer
deliverystring
email sends the invitation and reminders. link sends neither and gives you signing_url to share yourself. It is not a sandbox: the document is real and legally binding.
One of email, link
Default email
CC
namestring
The person's name.
Up to 200 characters
emailemailRequired
The person's email address.
refstringRequired
Your own short name for this person, unique in this request. Fields point at it.
1 to 40 characters
role"cc"Required
use_markersboolean
Create fields from {{...}} markers in the PDF. fields then becomes optional, and any you send are added to the detected ones.
Default false
fieldsarray of objects
Each signer needs at least one field they fill in. Required unless use_markers is true.
Up to 500 items
12 child fields
recipientstringRequired
The ref of the signer who fills this field.
1 to 40 characters
typestringRequired
One of signature, initials, name, email, date, text, number, checkbox, dropdown
pagenumberRequired
The page number, starting at 1.
xnumberRequired
Distance from the page's left edge, as a fraction of its width.
0 to 1
ynumberRequired
Distance from the page's top edge, as a fraction of its height.
0 to 1
widthnumberRequired
A fraction of the page width. x + width can be at most 1.
heightnumberRequired
A fraction of the page height. y + height can be at most 1.
labelstring
The label the signer sees.
requiredboolean
Default true
optionsarray of strings
The choices, for a dropdown.
prefilledboolean
You fill this field in now, and it locks. Text fields only.
How this person hears from Inkly. Always email for CC recipients.
One of email, link
statusstringRequired
One of pending, viewed, signed, declined
signing_urlURL or nullRequired
Set only when delivery is link and it is this signer's turn. With sequential signing, later signers get null: when GET /documents/{id} shows is_signing_turn is true, call POST /documents/{id}/recipients/{recipient_id}/signing-link. Treat it like a password: anyone holding it can sign.
sending_paused403Sending is paused for this workspace. Contact support.
storage_limit_reached403This workspace has reached its document storage limit. Upgrade your plan or email hello@inkly.so for more storage, then retry with a new Idempotency-Key.
invalid_pdf422The uploaded file is not a valid PDF.
marker_invalid422A field marker is malformed, wraps onto another line, starts in the middle of other text, does not fit on its page, or takes the document over 500 fields.
marker_not_found422use_markers is true, but the PDF has no field markers Inkly can read. A scanned PDF has no text, so send fields with coordinates instead.
marker_page_unsupported422A page with a field marker uses text or layout Inkly cannot place a field on exactly. Fix the PDF, or send fields with coordinates instead.
marker_pdf_too_complex422The PDF is too large or complex for marker detection. Send fields with coordinates instead.
marker_unknown_recipient422A field marker names a signer that is not a signer ref in this request.
Returns a recipient's signing link, for a document that is still out for signature. With sequential signing, a signer's link is available only on their turn; before that this returns signing_link_unavailable. Anyone holding the link can sign, so never log it or put it in a public page.
Needs a key with documents:send. Send no body, or an empty {}.
Path parameters
idUUIDRequired
The document ID.
recipient_idUUIDRequired
The recipient ID, from the document's recipients.
Example request
curl -X POST "https://www.inkly.so/api/v1/documents/3f1c2a7e-8b4d-4e21-9a6f-5d0c7e1b2a34/recipients/5b7e9d13-2c84-4f06-b1a9-6d40e8c2f715/signing-link" \
-H "Authorization: Bearer $INKLY_API_KEY"
Instead of measuring coordinates, write each field into the document as a marker and send the PDF to POST /documents/from-upload with use_markers set to true. Inkly creates a field at each marker. Inkly never changes your PDF, and if a marker is wrong or its position cannot be read exactly, the whole send is refused and nothing goes out.
You can combine markers with fields, up to 500 fields in total. Dropdown and prefilled fields still go in fields. Without use_markers the PDF is not scanned for markers.
typeFirst, and case-insensitive: signature, initials, name, email, date, text, number, or checkbox. A dropdown needs options, so send it in fields.
signerRequired. The exact ref of a recipient with the signer role. A ref containing ; = { or } cannot be used in a marker.
requiredOptional: true or false. Defaults to true.
width, heightOptional: a decimal above 0 and up to 1, as a fraction of the page. Defaults are in the next section.
Option names are lowercase, each can appear once, and unknown options are refused. Spaces around ; and = are ignored. A marker can be up to 200 characters and must fit on one line.
Hiding a marker
Make the marker white and set it at 1 or 2 points, so its size differs from the text around it. A marker the same size as the text beside it may be merged into that text by the tool that exports the PDF, and is then refused. We tested this with pdf-lib, Chrome print to PDF, and Google Docs. Word has not been tested directly.
Any hidden text works, such as text render mode 3 or zero opacity, which also suits colored backgrounds.
Marker color is not checked, so a marker left black will be visible to the signer. White text also stays in the PDF, where it can be found by text search or copying.
Where the field goes
The field starts at the marker's left edge, and its bottom sits on the marker's baseline, so it grows up from a signature line. A box that would run off the page is moved back onto it. Default sizes, in PDF points:
signature180 × 40
initials72 × 28
name, email, text180 × 20
date, number110 × 20
checkbox14 × 14
Inkly does not detect a field overlapping other text, so leave blank space around each marker and check the result by sending to your own email address.
What cannot be read
Inkly can only act on markers the PDF exposes as text, so a marker in a scan or an image, or one lost by the exporting tool, is not found. A marker must be plain ASCII and kept apart from right-to-left text such as Hebrew or Arabic.
A page containing a marker is refused as marker_page_unsupported if it has any of these anywhere on the page, even far from the marker, because Inkly cannot place a field on it exactly:
Page rotation, a user unit other than 1, or a visible area that does not start at the page's origin
Rotated, skewed, or mirrored marker text
Soft hyphens, zero-width or other invisible formatting characters, or accents drawn as separate glyphs
Character spacing or kerning adjustments
Type3 or vertical fonts, zero-size text, a broken font, or a font the PDF does not include other than the standard 14
Recovering from a refusal
marker_not_foundNo marker could be read. A scanned PDF has no text, so a scan needs coordinates. Fix the PDF and upload it again, or send fields with a new key on the same upload.
marker_invalidA marker is malformed, wraps onto another line, starts in the middle of other text, does not fit on its page, or takes the document over 500 fields. Fix the PDF, upload it again, and use a new key.
marker_unknown_recipientA marker names a signer that is not a signer ref in the request. Fix the request and use a new key on the same upload.
marker_page_unsupportedA page with a marker cannot be converted exactly. Fix the PDF and upload it again, or send fields with a new key on the same upload.
marker_pdf_too_complexThe PDF is over 5 million characters, 200,000 text items, or 256 MB of decoded streams. Send a simpler PDF, or send fields with a new key on the same upload.
A refused send is saved against its Idempotency-Key, so retrying that key returns the same refusal. An uploaded PDF cannot be replaced, so a corrected PDF always needs a new upload. A temporarily_unavailable response is not saved: retry the same request with the same key. Error responses never include marker text, and param gives only the location, such as markers.page.3.
Webhooks
Webhooks tell your server as soon as something happens to a document, so you do not have to poll. Add up to 5 endpoints on API settings. Each URL must use HTTPS on port 443 with a hostname rather than an IP address, and can be up to 2,048 characters.
Events
document.sentA document went out for signature, from the API or from the app. A public signing link copy emits this only once its visitor signs, together with recipient.signed.
recipient.viewedA signer opened the document for the first time.
recipient.signedA signer finished signing. Other signers may still be outstanding.
recipient.declinedA signer declined, which ends the document. Fetch the document for the reason.
document.completedEveryone signed and the signed PDF is saved, so you can download it straight away.
document.voidedThe document was voided.
pingA test event from Send test event on an endpoint's page in API settings. It has no document.
Payload
Payloads are thin: enough to identify what changed, never field values, signing links, or file contents. Fetch the document with the API when you need more.
The event ID. Equals the webhook-id header and stays the same on every retry, so dedupe on it.
created_attimestampRequired
When the event happened, in UTC.
schema_version1Required
type"recipient.signed"Required
documentobjectRequired
4 child fields
idUUIDRequired
titlestringRequired
statusstringRequired
The document's status when the event fired.
external_idstring or nullRequired
recipientobjectRequired
4 child fields
idUUIDRequired
rolestringRequired
One of signer, cc
namestring or nullRequired
emailstring or nullRequired
recipient appears only on recipient.* events.
Verifying signatures
Inkly signs every delivery in the Standard Webhooks format, so any library for that format works too. Each request has three headers: webhook-id, webhook-timestamp, and webhook-signature.
The signature is v1, followed by the base64 HMAC-SHA256 of id.timestamp.body, using the base64-decoded part of your secret after whsec_ as the key.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto"
const MAX_AGE_SECONDS = 5 * 60
// Pass the raw request body as a string, before any JSON parsing.
export function verifyInklyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"]
const timestamp = headers["webhook-timestamp"]
const signatures = headers["webhook-signature"]
if (!id || !timestamp || !signatures) return false
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!(age <= MAX_AGE_SECONDS)) return false
const key = Buffer.from(secret.slice("whsec_".length), "base64")
const expected = Buffer.from(
"v1," + createHmac("sha256", key).update(id + "." + timestamp + "." + rawBody).digest("base64"),
)
// During a secret roll the header carries two signatures separated by a space.
return signatures.split(" ").some((signature) => {
const candidate = Buffer.from(signature)
return candidate.length === expected.length && timingSafeEqual(candidate, expected)
})
}
Verify against the raw body, exactly as received. Parsing and re-encoding the JSON changes the bytes and the signature will not match.
Retries and timing
Any 2xx response within 10 seconds counts as delivered. Redirects are not followed. After a failure, Inkly retries a document or recipient event after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours, and 24 hours: 8 retries over about 3 days. Send test event makes one attempt, and Resend makes one more. Neither retries if it fails.
An endpoint that has been failing for more than 3 days, with at least 5 failed attempts, is turned off automatically and the people who manage keys get one email. API settings shows each endpoint's deliveries for 30 days, with Resend and Send test event. Changing an endpoint's URL cancels its pending deliveries.
Handling duplicates and order
Delivery is at least once and order is not guaranteed, so duplicates are normal and recipient.signed can arrive after document.completed. Store each webhook-id you have processed and skip repeats. It equals the payload's id and stays the same on every retry, while webhook-timestamp changes.
Rolling a secret
When you roll a secret, the old one keeps signing for 24 hours. During that time webhook-signature holds two signatures separated by a space, and a delivery is valid if either matches, so you can update your server without dropping events.