Overview
GetSigned fires webhooks when significant events happen on your envelopes. Your backend receives
an HTTP POST with a JSON payload. You control what happens next — GetSigned doesn’t know or care.
curl -X POST https://api.getsigned.ca/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/getsigned",
"events": ["envelope.completed", "envelope.declined", "envelope.voided", "envelope.expired"],
"secret": "your-webhook-signing-secret"
}'
secret is used to sign the payload (HMAC-SHA256). Store it in your secrets manager.
Event types
| Event | When it fires |
|---|
envelope.completed | All signers have signed. Sealed PDF is ready. |
envelope.declined | A signer declined to sign. |
envelope.voided | You voided the envelope. |
envelope.expired | The signing deadline passed. |
signer.viewed | A signer opened their signing link. |
signer.signed | An individual signer completed signing. |
signer.otp_failed | A signer failed OTP verification (3 failed attempts). |
{
"event": "envelope.completed",
"id": "evt_01HX...",
"createdAt": "2026-06-19T14:32:00Z",
"data": {
"envelopeId": "env_01HX...",
"tenantId": "acme-corp",
"subject": "Service Agreement",
"completedAt": "2026-06-19T14:32:00Z",
"documentUrl": "https://api.getsigned.ca/v1/envelopes/env_01HX.../document",
"signers": [
{
"id": "sgn_01HX...",
"name": "Jane Smith",
"email": "jane@example.com",
"signedAt": "2026-06-19T14:31:45Z"
}
]
}
}
Verifying the webhook signature
Every webhook request includes a X-GetSigned-Signature header containing an HMAC-SHA256
signature of the raw request body, computed using your webhook secret.
Always verify this signature before processing the payload. This prevents attackers from
spoofing events.
import hmac
import hashlib
def verify_signature(payload_bytes: bytes, header: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
payload_bytes,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", header)
Use a constant-time comparison (hmac.compare_digest, timingSafeEqual,
FixedTimeEquals). A naive == comparison leaks timing information that can be exploited.
Responding to webhooks
Your endpoint must return 2xx within 10 seconds. If it times out or returns a non-2xx status,
GetSigned will retry.
Do not do heavy work inside the webhook handler. Acknowledge receipt immediately and process
asynchronously:
@app.post("/webhooks/getsigned")
async def handle_webhook(request: Request):
body = await request.body()
sig = request.headers.get("X-GetSigned-Signature")
if not verify_signature(body, sig, WEBHOOK_SECRET):
raise HTTPException(status_code=401)
payload = json.loads(body)
await queue.enqueue("process_getsigned_event", payload) # async queue
return {"received": True} # 200 immediately
Retry schedule
If your endpoint doesn’t return 2xx within 10 seconds, GetSigned retries with exponential backoff:
| Attempt | Delay |
|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
After 5 failed attempts, the delivery is marked as failed. You can view failed deliveries and
trigger a manual resend in the Console under Webhooks → Deliveries.
Idempotency
Webhooks may be delivered more than once (network retries can cause duplicates even after a
successful delivery). Use the id field (evt_01HX...) to deduplicate:
if await redis.exists(f"webhook:{payload['id']}"):
return {"received": True} # already processed
await redis.setex(f"webhook:{payload['id']}", 86400, "1")
await process(payload)
Testing webhooks locally
Use ngrok or Cloudflare Tunnel to
expose your local server:
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
# Register your ngrok URL as the webhook endpoint
curl -X POST https://api.getsigned.ca/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-d '{"url": "https://abc123.ngrok.io/webhooks/getsigned", "events": ["envelope.completed"]}'