Webhooks

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 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.

A recipient.* event names the document and the recipient:

recipient.signed
{
  "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:

document.completed
{
  "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
  • idUUIDRequired

    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

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)
  })
}
Verify against the raw body, exactly as received. Parsing and re-encoding the JSON changes the bytes and the signature will not match.

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.