Skip to content

Webhooks

Receive signed HTTP notifications when envelopes are sent, viewed, signed, declined or voided — and verify them with your endpoint secret.

On this page

Webhooks push changes to your server the moment they happen: an envelope is sent, a recipient opens or signs it, someone declines, the envelope completes. Each delivery is an HTTPS POST with a JSON body, signed with your endpoint’s secret.

Set up an endpoint

  1. Create an endpoint in Developer → Webhooks or with POST/v1/webhooks: a public HTTPS URL and the events you want. Private, loopback and link-local addresses are refused, and redirects are not followed. An account can have up to 20 endpoints.
  2. Save the signing secret (whsec_…). The API returns it only on create and on POST/v1/webhooks/{id}/rotate-secret.
  3. Send a signed ping with POST/v1/webhooks/{id}/test and check the delivery in GET/v1/webhooks/{id}/deliveries.

Events

Webhook events
EventSent when
envelope.sentThe envelope was sent and the first recipients were invited.
envelope.completedEvery recipient finished; the signed PDF and certificate are ready to download.
envelope.declinedA recipient declined to sign, which closes the envelope.
envelope.voidedThe sender voided the envelope.
recipient.sentA recipient was invited (it is their turn in the routing order).
recipient.viewedA recipient opened the envelope.
recipient.completedA recipient signed, approved or viewed as required.
recipient.declinedA recipient declined to sign.
recipient.delegatedA recipient handed their part to someone else (see data.delegated_to).
pingYou send a test event. Not subscribable; always delivered to the endpoint you test.

GET/v1/webhook-events returns the same list, so your integration can discover new events.

Payload

Request headers
POST /webhooks/signkoi HTTP/1.1
Content-Type: application/json
User-Agent: Signkoi-Webhooks/1.0
Signkoi-Event: recipient.completed
Signkoi-Delivery: a2c5e8f1-6b3d-4d9a-8c7e-1f4b0a9d3e62
Signkoi-Signature: t=1794652960,v1=5f2b9c0e8a7d6f4b3c1e0d9a8b7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d
Body · recipient.completed
{
  "id": "evt_01jcz8q6x4m7r2t9v3w5y8b1d4",
  "event": "recipient.completed",
  "api_version": "2026-11-14",
  "created_at": "2026-11-15T14:02:40+00:00",
  "data": {
    "document": {
      "id": "9b2f4c1e-7a3d-4e8b-9c51-2d6f8a0b3e47",
      "title": "Consulting agreement — Acme Ltd",
      "status": "awaiting_signature",
      "metadata": {
        "crm_deal_id": "D-10492"
      }
    },
    "recipient": {
      "id": "c4e1a8f2-3b7d-4f19-a6c2-8e5d0b9f1a73",
      "name": "Maya Patel",
      "email": "maya@example.com",
      "role": "signer",
      "status": "completed"
    },
    "envelope": {
      "object": "envelope",
      "id": "9b2f4c1e-7a3d-4e8b-9c51-2d6f8a0b3e47",
      "title": "Consulting agreement — Acme Ltd",
      "status": "sent",
      "message": {
        "subject": "Please sign: Consulting agreement",
        "body": "Hi Maya, here is the agreement we discussed."
      },
      "metadata": {
        "crm_deal_id": "D-10492"
      },
      "template_id": null,
      "source": "api",
      "page_count": 3,
      "reminders": {
        "delay_days": 2,
        "frequency_days": 3
      },
      "recipients": [
        {
          "object": "recipient",
          "id": "c4e1a8f2-3b7d-4f19-a6c2-8e5d0b9f1a73",
          "role": "signer",
          "role_name": null,
          "name": "Maya Patel",
          "email": "maya@example.com",
          "routing_order": 1,
          "status": "completed",
          "language": "en",
          "verification": "email_otp",
          "phone": null,
          "sent_at": "2026-11-14T09:30:12Z",
          "viewed_at": "2026-11-15T13:58:01Z",
          "completed_at": "2026-11-15T14:02:40Z",
          "declined_at": null,
          "decline_reason": null
        },
        {
          "object": "recipient",
          "id": "e7b3d915-0c4a-4b6e-8f27-5a1c9d3e6b08",
          "role": "cc",
          "role_name": null,
          "name": "Jonas Weber",
          "email": "jonas@example.com",
          "routing_order": 2,
          "status": "sent",
          "language": "de",
          "verification": "none",
          "phone": null,
          "sent_at": "2026-11-15T14:02:41Z",
          "viewed_at": null,
          "completed_at": null,
          "declined_at": null,
          "decline_reason": null
        }
      ],
      "files": {
        "original": "https://signkoi.com/api/v1/envelopes/9b2f4c1e-7a3d-4e8b-9c51-2d6f8a0b3e47/files/original",
        "signed": null,
        "certificate": null
      },
      "created_at": "2026-11-14T09:30:05Z",
      "sent_at": "2026-11-14T09:30:12Z",
      "completed_at": null,
      "declined_at": null,
      "voided_at": null,
      "void_reason": null,
      "expires_at": "2026-12-14T09:30:12Z"
    }
  }
}
Payload fields
FieldDescription
idUnique event id (evt_…). The same event can arrive more than once — use it to ignore duplicates.
eventThe event name (also in the Signkoi-Event header).
api_versionVersion of the data.envelope shape, currently 2026-11-14.
created_atWhen the event happened (ISO 8601).
data.envelopeThe envelope exactly as GET/v1/envelopes/{id} returns it, without fields — with recipients, statuses and metadata.
data.documentCompact summary kept for older integrations: id, title, status, metadata.
data.recipientOn recipient.* events: the recipient it is about (id, name, email, role, status).
data.delegated_toOn recipient.delegated: the person who took over.

Use data.envelope for statuses

For backward compatibility, data.document.status and data.recipient.status use internal names (awaiting_signature, signed, delivered, …). Read statuses from data.envelope, which uses the public names (sent, completed, viewed, …), and match the recipient by id in data.envelope.recipients.

The test event looks like this:

Body · ping
{
  "id": "evt_01jcz9b2k7n4p6r8t0v2x4z6a8",
  "event": "ping",
  "api_version": "2026-11-14",
  "created_at": "2026-11-14T10:05:00+00:00",
  "data": {
    "webhook": { "id": "6d1e9f3a-4b2c-4a87-9e5d-0f7b3c8a2d54", "events": ["envelope.completed"] },
    "message": "Hello from Signkoi. Your webhook endpoint is reachable."
  }
}

Verifying signatures

Every delivery has a Signkoi-Signature header: t=<unix timestamp>,v1=<signature>. v1 is the lowercase hex HMAC-SHA256 of the string "{t}.{raw body}", keyed with your endpoint secret (the whole string, including whsec_). To verify:

  1. Read the raw request body, byte for byte. Re-serialising parsed JSON changes it and the check fails.
  2. Split the header on , and = to get t and v1.
  3. Reject the request if t is more than 5 minutes from your clock — this stops replays.
  4. Compute HMAC-SHA256 of t + "." + body with the secret and compare it with v1 in constant time.
  5. Only then parse the JSON and act on it. Answer 400 for a bad signature.

Verification code

import crypto from 'node:crypto';
import express from 'express';

/** True when `header` is a valid Signkoi-Signature for the raw body. */
export function verifySignkoiSignature(rawBody, header, secret, tolerance = 300) {
    if (typeof header !== 'string') return false;
    const parts = {};
    for (const pair of header.split(',')) {
        const i = pair.indexOf('=');
        if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
    }
    const { t, v1 } = parts;
    if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > tolerance) return false;

    const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
    return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}

const app = express();

// express.raw keeps the body as a Buffer — verify before parsing JSON.
app.post('/webhooks/signkoi', express.raw({ type: 'application/json' }), (req, res) => {
    if (!verifySignkoiSignature(req.body, req.get('Signkoi-Signature'), process.env.SIGNKOI_WEBHOOK_SECRET)) {
        return res.status(400).send('Invalid signature');
    }
    const event = JSON.parse(req.body.toString('utf8'));
    queue.add(event); // do the work later; answer within 10 seconds
    res.sendStatus(200);
});

Rotating the secret

POST/v1/webhooks/{id}/rotate-secret switches to a new secret immediately. Deploy the new secret to your receiver right away; deliveries signed with the old one won’t verify any more.

Retries and auto-disable

A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Anything else — another status, a redirect, a timeout or a connection error — is a failure, and Signkoi retries up to five more times:

Retry schedule
AttemptSent
1Immediately
21 minute after the first failure
35 minutes later
430 minutes later
52 hours later
66 hours later — the last attempt
  • Each delivery’s status is pending, delivered, retrying (with next_retry_at) or failed; response_status and the first 1,000 characters of your response help you debug.
  • Retries reuse the same Signkoi-Delivery id and event id, with a fresh timestamp and signature.
  • After 20 consecutive failed attempts the endpoint is switched off (enabled: false). Fix your receiver, then turn it back on with PATCH/v1/webhooks/{id} ("enabled": true resets the failure count) or on the dashboard.

Best practices

  • Answer quickly with 200 and do the work in a background job.
  • Make handlers idempotent: store processed event ids and skip repeats.
  • Don’t rely on order. Events can arrive out of sequence; when in doubt, fetch the envelope with GET/v1/envelopes/{id}.
  • Ignore event types you don’t know, so new events never break your receiver.
  • On envelope.completed, download the signed PDF and certificate from the files URLs.