squiggli Get a free API key
Guides

Webhooks

Have squiggli tell your server when documents are sent, viewed, signed, completed, declined, voided or expire.

Register a URL

bash
curl https://squiggli.billy-965.workers.dev/v1/webhooks \
  -H "Authorization: Bearer $SQUIGGLI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/squiggli", "events": ["envelope.completed", "envelope.declined"] }'

The response includes a secret (whsec_...), shown once: store it. Leave out events to get all of them. A webhook made with an API key only gets envelopes made with that key; add "all_envelopes": true for every envelope in the business. Webhooks made in the web app get everything. URLs must be public https.

Events

EventWhendata.signer
envelope.sentThe envelope goes out.No
envelope.viewedA signer opens it for the first time.Yes
envelope.signedA signer signs (not necessarily the last).Yes
envelope.completedEveryone has signed. Download links are included.No
envelope.declinedA signer declines.Yes
envelope.voidedThe sender cancels it.No
envelope.expiredThe deadline passes.No

The payload

json
{
  "id": "evt_mw6c1k2z...",
  "type": "envelope.completed",
  "created_at": "2026-11-09T09:15:02.000Z",
  "data": {
    "envelope": {
      "id": "env_mw6bdnhw48572v054y",
      "title": "Harbour Lane lease, unit 4",
      "status": "signed",
      "metadata": { "job_id": "J-1042" },
      "progress": { "signers": 1, "signed": 1 },
      "signers": [{ "id": "sgn_...", "name": "Ava Chen", "status": "signed", "signed_at": "2026-11-09T09:15:01.000Z" }],
      "files": {
        "signed_pdf_download": "https://squiggli.billy-965.workers.dev/d/eyJ...",
        "certificate_download": "https://squiggli.billy-965.workers.dev/d/eyJ..."
      }
    }
  }
}

The _download links work without a key for 7 days.

Check the signature

Every request has Squiggli-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" with your secret. Check it over the raw body, before parsing, and refuse anything older than 5 minutes.

Node (Express)

js
import crypto from 'node:crypto'

app.post('/webhooks/squiggli', express.raw({ type: 'application/json' }), (req, res) => {
  const [t, v1] = (req.header('Squiggli-Signature') ?? '').split(',').map((p) => p.split('=')[1])
  const expected = crypto.createHmac('sha256', process.env.SQUIGGLI_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
  if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(400)
  const event = JSON.parse(req.body.toString())
  // dedupe on event.id, then handle event.type
  res.sendStatus(200)
})

Workers, Deno, Bun and Next.js

js
async function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${parts.t}.${rawBody}`))
  const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
  return hex === parts.v1
}

Python

python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts["v1"])

PHP

php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_SQUIGGLI_SIGNATURE']), $p);
$ok = abs(time() - (int)$p['t']) < 300 && hash_equals(hash_hmac('sha256', $p['t'] . '.' . $raw, $secret), $p['v1']);

Retries

Answer with any 2xx within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (about 21 hours in all), then given up. Retries keep the same Squiggli-Event-Id header and id, so dedupe on it. Events can arrive out of order: if order matters, fetch the envelope.