Webhooks
On this page
API docs
Get started
Guides
Endpoints
Account
Templates
Sending
Documents
Files
OpenAPI
Reference
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 withrecipient.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 nodocument.
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.
A recipient.* event names the document and the recipient:
{
"type": "recipient.signed",
"id": "7d6b1f0a-3c4e-4a55-9e21-0b8f6c2d1a90",
"created_at": "2026-11-02T15:04:05.123Z",
"schema_version": 1,
"document": {
"id": "3f1c2a7e-8b4d-4e21-9a6f-5d0c7e1b2a34",
"title": "Consulting agreement",
"status": "sent",
"external_id": "crm-8841"
},
"recipient": {
"id": "5b7e9d13-2c84-4f06-b1a9-6d40e8c2f715",
"role": "signer",
"name": "Alex Morgan",
"email": "alex@example.com"
}
}A document.* event names only the document:
{
"type": "document.completed",
"id": "7d6b1f0a-3c4e-4a55-9e21-0b8f6c2d1a90",
"created_at": "2026-11-02T15:04:05.123Z",
"schema_version": 1,
"document": {
"id": "3f1c2a7e-8b4d-4e21-9a6f-5d0c7e1b2a34",
"title": "Consulting agreement",
"status": "completed",
"external_id": "crm-8841"
}
}Payload fields
idUUIDRequiredThe event ID. Equals the
webhook-idheader and stays the same on every retry, so dedupe on it.created_attimestampRequiredWhen the event happened, in UTC.
schema_version1Requiredtype"recipient.signed"RequireddocumentobjectRequired4 child fields
idUUIDRequiredtitlestringRequiredstatusstringRequiredThe document's status when the event fired.
external_idstring or nullRequired
recipientobjectRequired4 child fields
idUUIDRequiredrolestringRequiredOne of
signer,ccnamestring or nullRequiredemailstring or nullRequired
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.
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)
})
}A complete endpoint
Put it together: read the raw body, verify it, skip events you have already handled, act on the ones you care about, and answer 200 within 10 seconds. Keep the signing secret from API settings in an environment variable. If the work is slow, queue it and answer first.
import express from "express"
import { verifyInklyWebhook } from "./verify-inkly-webhook.js"
const app = express()
// express.raw keeps the body exactly as it arrived, which the signature needs.
app.post("/webhooks/inkly", express.raw({ type: "application/json" }), async (req, res) => {
const rawBody = req.body.toString("utf8")
if (!verifyInklyWebhook(rawBody, req.headers, process.env.INKLY_WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
const event = JSON.parse(rawBody)
// Your own store of event IDs you have handled, because a delivery can repeat.
if (await alreadyHandled(event.id)) return res.sendStatus(200)
switch (event.type) {
case "recipient.signed":
await markSignerDone(event.document.id, event.recipient.id)
break
case "document.completed":
await downloadSignedPdf(event.document.id)
break
}
await rememberHandled(event.id)
res.sendStatus(200)
})
app.listen(3000)To try it, deploy the endpoint, add its URL on API settings, and click Send test event. A ping event arrives, with no document.
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.