Guides
Webhooks
sign.yt sends an HTTPS POST to your endpoint when something happens to a document. Every delivery is signed with your webhook secret and retried for about 14 hours if your endpoint is down.
Webhooks are available on plans with webhooks (Business and Enterprise) and can be managed only by organization admins: in the app under Webhooks, or with an admin's API key carrying webhooks:write.
Events#
| Event | When |
|---|---|
document.created | A draft was created (also for the copy made by cancel with copy_as_draft). |
document.sent | The document was sent to its recipients. |
document.viewed | A recipient opened the signing link for the first time. |
document.signed | A recipient signed; fires once per signer, together with recipient.signed. |
document.rejected | A recipient declined; the document is REJECTED. |
document.completed | Everyone signed and the sealed PDF is ready to download. |
document.cancelled | The sender cancelled (voided) the document. |
document.expired | expires_at passed before completion. |
recipient.signed | A recipient finished their part. |
recipient.rejected | A recipient declined, with their reason. |
recipient.bounced | The invitation email bounced or was reported as spam. |
recipient.identity_verification_updated | A recipient's identity check changed status (IN_REVIEW, VERIFIED, MISMATCH, FAILED). Status only, no identity data. |
flow.step | A flow notify step with channel: "webhook" ran. |
There is no document.voided event: cancelling a document emits document.cancelled. Payload schemas and examples for every event are in the webhook events reference.
Register an endpoint#
POST /webhooks with an https:// URL (8-2000 characters) and 1-20 event names. Unknown event names are rejected with 422. The response includes secret exactly once; afterwards only secret_preview (the last 4 characters) is returned.
curl -sS -X POST "https://api.sign.yt/v1/webhooks" \
-H "Authorization: Bearer $SIGNYT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/signyt", "events": ["document.completed", "document.rejected", "recipient.signed"]}'import os
from signyt import Signyt
client = Signyt(os.environ["SIGNYT_API_KEY"])
hook = client.webhooks.create(
"https://example.com/hooks/signyt",
["document.completed", "document.rejected", "recipient.signed"],
)
print(hook.id, hook.secret) # save the secret nowimport { Signyt } from "@signyt/sdk";
const client = new Signyt(process.env.SIGNYT_API_KEY!);
const hook = await client.webhooks.create({
url: "https://example.com/hooks/signyt",
events: ["document.completed", "document.rejected", "recipient.signed"],
});
console.log(hook.id, hook.secret); // save the secret now{
"id": "6b2d7e51-0c4a-4f3e-9d8b-1a2b3c4d5e6f",
"url": "https://example.com/hooks/signyt",
"events": ["document.completed", "document.rejected", "recipient.signed"],
"active": true,
"secret_preview": "...9c1e",
"created_at": "2026-10-19T08:00:00.000000+00:00",
"last_triggered_at": null,
"last_status_code": null,
"secret": "4f6e0b...9c1e"
}Manage endpoints with GET /webhooks, PATCH /webhooks/{id} (url, events, active) and DELETE /webhooks/{id}. The secret cannot be changed; to rotate it, create a second webhook, switch your handler to accept both secrets, then delete the old one.
URLs must be public: in production only https:// is accepted, and addresses that resolve to private, loopback, link-local or internal networks are rejected, both when you save the URL and again on every delivery. Redirects are not followed.
Delivery format#
POST /hooks/signyt HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: sign.yt-webhooks/1.0
X-Signyt-Event: document.completed
X-Signyt-Delivery-Id: 0d6a1c3e-8f7b-4a29-b1e4-5c6d7e8f9a0b
X-Signyt-Timestamp: 2026-10-19T09:12:45.200311+00:00
X-Signyt-Signature: sha256=2b8f0c...e41a
{"event_id":"5e0f8a1b-2c3d-4e5f-8a9b-0c1d2e3f4a5b","event":"document.completed","timestamp":"2026-10-19T09:12:44.123456+00:00","data":{"id":"3f1c2a9e-5b7d-4c11-9a3e-0d4b8e2f6a71","title":"Mutual NDA","status":"COMPLETED","organization_id":"a7d5e0c2-1f43-4b9a-8c6e-2e9f0b3d4c58"}}| Body field | Description |
|---|---|
event_id | UUID of the event. Identical across retries and manual redeliveries: use it to de-duplicate. |
event | Event name. |
timestamp | When the event happened (RFC 3339, UTC). |
data | Event payload. See the reference. |
| Header | Description |
|---|---|
X-Signyt-Signature | sha256= followed by the hex HMAC-SHA256 of the signing input. |
X-Signyt-Timestamp | When this attempt was signed (RFC 3339, UTC). Each attempt is re-signed with a fresh timestamp. |
X-Signyt-Delivery-Id | The delivery (one per subscribed endpoint). Stable across automatic retries; a manual redelivery gets a new one. |
X-Signyt-Event | Event name, same as event in the body. |
Verify signatures#
The signature is computed over the timestamp header, a dot, and the raw request body bytes:
signing_input = X-Signyt-Timestamp + "." + raw_body
signature = hex(HMAC-SHA256(webhook_secret, signing_input))
header = "sha256=" + signature- Read the raw body before any JSON parsing; re-serialized JSON will not match.
- Compute the HMAC and compare it to the header with a constant-time comparison.
- Reject the delivery if the timestamp is more than 5 minutes away from your clock (replay protection). Keep your server clock in sync with NTP.
- Only then parse the JSON and act on it.
# pip install flask
import hashlib
import hmac
import json
import os
from datetime import datetime, timezone
from flask import Flask, abort, request
SECRET = os.environ["SIGNYT_WEBHOOK_SECRET"]
app = Flask(__name__)
def verify(raw: bytes, timestamp: str, signature: str) -> bool:
expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
return False
try:
sent = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError:
return False
return abs((datetime.now(timezone.utc) - sent).total_seconds()) <= 300
@app.post("/hooks/signyt")
def signyt_webhook():
raw = request.get_data()
if not verify(raw, request.headers.get("X-Signyt-Timestamp", ""),
request.headers.get("X-Signyt-Signature", "")):
abort(400)
event = json.loads(raw)
print(event["event_id"], event["event"], event["data"].get("id"))
return "", 204// npm install express
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = process.env.SIGNYT_WEBHOOK_SECRET!;
const app = express();
function verify(raw: Buffer, timestamp: string, signature: string): boolean {
const expected = createHmac("sha256", SECRET).update(`${timestamp}.`).update(raw).digest("hex");
const got = signature.replace(/^sha256=/, "");
if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return false;
}
const sent = Date.parse(timestamp);
return Number.isFinite(sent) && Math.abs(Date.now() - sent) <= 5 * 60 * 1000;
}
// express.raw keeps the exact bytes that were signed.
app.post("/hooks/signyt", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Signyt-Timestamp") ?? "", req.get("X-Signyt-Signature") ?? "")) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event_id, event.event, event.data.id);
res.sendStatus(204);
});
app.listen(3000);<?php
$secret = getenv('SIGNYT_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SIGNYT_TIMESTAMP'] ?? '';
$signature = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_SIGNYT_SIGNATURE'] ?? '');
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
$sent = strtotime($timestamp);
if (!hash_equals($expected, $signature) || $sent === false || abs(time() - $sent) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
error_log($event['event_id'] . ' ' . $event['event']);
http_response_code(204);With the SDKs#
from signyt import SignatureVerificationError
from signyt.webhooks import construct_event
try:
# headers: any mapping (case-insensitive lookup), raw: the request body bytes
event = construct_event(raw, request.headers, os.environ["SIGNYT_WEBHOOK_SECRET"])
except SignatureVerificationError:
abort(400)import { constructWebhookEvent, SignatureVerificationError } from "@signyt/sdk";
try {
// raw: string or Uint8Array of the exact body; headers: Headers or a plain object
const event = await constructWebhookEvent(raw, headers, process.env.SIGNYT_WEBHOOK_SECRET!);
console.log(event.event, event.data);
} catch (e) {
if (e instanceof SignatureVerificationError) { /* respond 400 */ } else throw e;
}Both helpers check the signature and the 5-minute window (configurable with tolerance, in seconds). The lower-level verify_signature(payload, header, secret, timestamp) / verifyWebhookSignature(...) are also exported.
Respond quickly#
- Return any
2xxwithin 8 seconds. Anything else (non-2xx, redirect, timeout, connection error) counts as a failure. - Acknowledge first, work later: store the event and process it in a background job.
- Make processing idempotent: the same
event_idcan arrive more than once. - Events are delivered independently and can arrive out of order. Use
timestampand, when in doubt, fetch the current state withGET /documents/{id}.
Retries and dead letters#
A failed attempt is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: 6 attempts in total over roughly 14.5 hours. Each attempt is re-signed with a fresh timestamp, so late retries still pass the 5-minute check. After the last failure the delivery becomes DEAD (dead-letter) and stays in the delivery log for manual redelivery.
| Delivery status | Meaning |
|---|---|
PENDING | Queued, not attempted yet. |
RETRYING | Failed at least once; next_retry_at says when the next attempt runs. |
SUCCESS | Your endpoint answered 2xx. |
DEAD | All 6 attempts failed. |
FAILED | Stopped without retry because the webhook was deactivated or webhooks left the plan. |
Delivery log and redelivery#
GET /webhooks/{webhook_id}/deliveries lists deliveries newest first, with status, attempt, next_retry_at, response_status_code, the first 2 KB of response_body and the exact payload. It is paginated with limit and cursor.
POST /webhooks/deliveries/{delivery_id}/redeliver queues a fresh delivery of the same payload (same event_id, new delivery ID, attempt counter reset) and returns 202 Accepted.
curl -sS "https://api.sign.yt/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20" \
-H "Authorization: Bearer $SIGNYT_API_KEY" | jq '.[] | {id, event_type, status, attempt, response_status_code}'
curl -sS -X POST "https://api.sign.yt/v1/webhooks/deliveries/$DELIVERY_ID/redeliver" \
-H "Authorization: Bearer $SIGNYT_API_KEY"import os
import httpx
http = httpx.Client(
base_url="https://api.sign.yt/v1",
headers={"Authorization": f"Bearer {os.environ['SIGNYT_API_KEY']}"},
)
deliveries = http.get(f"/webhooks/{os.environ['WEBHOOK_ID']}/deliveries", params={"limit": 20}).json()
for d in deliveries:
if d["status"] == "DEAD":
http.post(f"/webhooks/deliveries/{d['id']}/redeliver").raise_for_status()const base = "https://api.sign.yt/v1";
const headers = { Authorization: `Bearer ${process.env.SIGNYT_API_KEY}` };
const res = await fetch(`${base}/webhooks/${process.env.WEBHOOK_ID}/deliveries?limit=20`, { headers });
for (const d of (await res.json()) as { id: string; status: string }[]) {
if (d.status === "DEAD") {
await fetch(`${base}/webhooks/deliveries/${d.id}/redeliver`, { method: "POST", headers });
}
}Testing locally#
- Expose your local server over HTTPS with a tunnel (for example
cloudflaredorngrok) and register the tunnel URL as a webhook. Plainhttp://and local addresses are not accepted in production. - Trigger real events by creating and sending a test document to an address you control, then use redelivery to replay an event as often as you like.
- To unit-test your handler without sign.yt, sign a sample body yourself:
SECRET="your-webhook-secret"
TS=$(date -u +%Y-%m-%dT%H:%M:%S+00:00)
BODY='{"event_id":"5e0f8a1b-2c3d-4e5f-8a9b-0c1d2e3f4a5b","event":"document.completed","timestamp":"'"$TS"'","data":{"id":"3f1c2a9e-5b7d-4c11-9a3e-0d4b8e2f6a71","title":"Mutual NDA","status":"COMPLETED","organization_id":"a7d5e0c2-1f43-4b9a-8c6e-2e9f0b3d4c58"}}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -sS -i -X POST "http://localhost:3000/hooks/signyt" \
-H "Content-Type: application/json" \
-H "X-Signyt-Event: document.completed" \
-H "X-Signyt-Delivery-Id: 00000000-0000-0000-0000-000000000001" \
-H "X-Signyt-Timestamp: $TS" \
-H "X-Signyt-Signature: sha256=$SIG" \
--data-raw "$BODY"