sign.ytDocs

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#

EventWhen
document.createdA draft was created (also for the copy made by cancel with copy_as_draft).
document.sentThe document was sent to its recipients.
document.viewedA recipient opened the signing link for the first time.
document.signedA recipient signed; fires once per signer, together with recipient.signed.
document.rejectedA recipient declined; the document is REJECTED.
document.completedEveryone signed and the sealed PDF is ready to download.
document.cancelledThe sender cancelled (voided) the document.
document.expiredexpires_at passed before completion.
recipient.signedA recipient finished their part.
recipient.rejectedA recipient declined, with their reason.
recipient.bouncedThe invitation email bounced or was reported as spam.
recipient.identity_verification_updatedA recipient's identity check changed status (IN_REVIEW, VERIFIED, MISMATCH, FAILED). Status only, no identity data.
flow.stepA 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"]}'
{
  "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 fieldDescription
event_idUUID of the event. Identical across retries and manual redeliveries: use it to de-duplicate.
eventEvent name.
timestampWhen the event happened (RFC 3339, UTC).
dataEvent payload. See the reference.
HeaderDescription
X-Signyt-Signaturesha256= followed by the hex HMAC-SHA256 of the signing input.
X-Signyt-TimestampWhen this attempt was signed (RFC 3339, UTC). Each attempt is re-signed with a fresh timestamp.
X-Signyt-Delivery-IdThe delivery (one per subscribed endpoint). Stable across automatic retries; a manual redelivery gets a new one.
X-Signyt-EventEvent 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
  1. Read the raw body before any JSON parsing; re-serialized JSON will not match.
  2. Compute the HMAC and compare it to the header with a constant-time comparison.
  3. 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.
  4. 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

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)

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 2xx within 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_id can arrive more than once.
  • Events are delivered independently and can arrive out of order. Use timestamp and, when in doubt, fetch the current state with GET /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 statusMeaning
PENDINGQueued, not attempted yet.
RETRYINGFailed at least once; next_retry_at says when the next attempt runs.
SUCCESSYour endpoint answered 2xx.
DEADAll 6 attempts failed.
FAILEDStopped 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"

Testing locally#

  • Expose your local server over HTTPS with a tunnel (for example cloudflared or ngrok) and register the tunnel URL as a webhook. Plain http:// 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"