Webhooks — Real-time notifications of each signature's status.
Receive events on your server the moment they occur. When something changes — the document is opened, identity is verified, signing completes — Wthaiq sends a POST signed to your address within seconds, so your system reacts immediately without having to poll the API repeatedly.
signature_request.completed
t=1754500000,v1=6ff7d3f2b1a0c9e4d5f8a1b0c3a
Four steps from the event to your server's response
The Webhooks system at Wthaiq is simple and robust: you register a single address that receives the events you care about, and we take care of sending them signed whenever something changes. No polling and no repeated querying — the event reaches you as soon as it occurs.
Create a receiving endpoint
Register the address HTTPS via POST /v1/webhook_endpoints and specify the subscribed events in enabled_events. The response returns the object webhook_endpoint includes the secret whsec_ used later in verification.
| The field | Type | Description |
|---|---|---|
| url | string Required | An HTTPS URL that receives POST requests. It must be publicly reachable and respond quickly. |
| enabled_events | string[] Required | The list of event types sent to this address. Include only what you need. |
curl -X POST https://wthaiq.com/api/v1/webhook_endpoints \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Wthaiq-Version: 2026-07-01" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.acme.com/hooks/wthaiq",
"enabled_events": ["signature_request.completed", "signer.signed"]
}'{
"id": "we_1a",
"object": "webhook_endpoint",
"url": "https://api.acme.com/hooks/wthaiq",
"enabled_events": ["signature_request.completed", "signer.signed"],
"status": "enabled",
"secret": "whsec_7bK2c8Vd1QpN9sR4tHmZ0xY", // Shown once — store it securely
"created_at": 1754000000
}whsec_ is shown only once, at creation. Store it in a secure environment variable, as you will need it for every verification. Each endpoint has its own separate secret.Receive a POST request
When any subscribed event occurs, Wthaiq sends a POST to your address. Its JSON body is an event object that wraps the affected object under data.object, together with the signature header Wthaiq-Signature.
POST /hooks/wthaiq HTTP/1.1
Host: api.acme.com
Content-Type: application/json
Wthaiq-Version: 2026-07-01
Wthaiq-Signature: t=1754500000,v1=6ff7d3f2b1a0c9e4d5f8a1b0c3a...
{"id":"evt_2M8kQ1","object":"event","type":"signature_request.completed", ... }Verify the signature
Before trusting any payload, recompute the signature HMAC SHA-256 over the raw body and compare it with the value of v1 in the header, and reject it if the timestamp is outside the tolerance window. Full details and the complete implementation are in the section Signature verification below.
Respond with 2xx quickly
After verification, return a status code in the range 2xx (such as 200) in under 5 seconds. Any code outside that range is treated as a delivery failure, so the request is retried. Defer heavy work — databases, email, file generation — to an asynchronous job after the response is sent.
All event types and when they fire
Events fall into groups: events at the signature request level signature_request.*, and events for each signer signer.*, alongside the document sealing and public verification events. Subscribe only to what you need through enabled_events.
| The event type | When it fires |
|---|---|
| signature_request.* — at the signature request level | |
| signature_request.created | A new signature request was created as a draft in your account before being sent. |
| signature_request.sent | The request was sent to the signers and the signing links became active. |
| signature_request.viewed | A signer opened the request's signing page for the first time — from the email or WhatsApp link, or an embedded signing session (signing_session). |
| signature_request.partially_signed | One signer signed while others are still pending (in multi-signer requests). |
| signature_request.completed | All the signers signed and the request completed; the sealed document becomes available for download and is assigned a public verification reference. |
| signature_request.declined | One of the signers declined to sign, so the request stopped. |
| signature_request.expired | Expired expires_at before signing completes. |
| signature_request.canceled | You canceled the request through the API or the developer console before it completed. |
| signer.* — at the level of each signer | |
| signer.sent | The signing link was sent to a specific signer (fired for each signer when their turn comes, if a signing order is set). |
| signer.viewed | The signer opened their signing page for the first time — from the email or WhatsApp link, or an embedded signing session (signing_session). |
| signer.otp_verified | The signer successfully entered the one-time verification code (OTP) sent to their email. |
| signer.identity_verified | The signer passed identity verification — official document and live face match via a specialized identity-verification provider — at the AES level. |
| signer.identity_in_review | The signer completed the identity confirmation steps and the result was referred to human review; signing is unavailable to them until a decision is issued. |
| signer.identity_resubmission_requested | The reviewer asked the signer to redo one of the identity confirmation steps, such as capturing the document or the face match, within the same session. |
| signer.identity_declined | The identity confirmation of the signer was declined, automatically or after human review; signing is unavailable to them unless a later confirmation is approved. |
| signer.identity_match_hold | The signer passed identity verification, but the name on their document differs from the name sent in the request, so their signing was held until the identity reviewer in your account decides. The event carries the identity_match object with both names, the document type and its last four digits. |
| signer.identity_match_decided | A decision on the name difference was made from the developer console or via POST /signers/{id}/identity_match: approved continues signing, and rejected stops this party's signing. |
| signer.signed | The signer completed their signature on the document. |
| signer.declined | The signer declined to sign, with an optional reason. |
| document.sealed and verification.created — sealing and verification | |
| document.sealed | All the signatures completed, so an authenticated evidence record that can be verified independently was sealed for the request — carrying an Ed25519 signature (any party can verify it with the public key at /trust) and an RFC 3161 timestamp. A PAdES signature is not embedded inside the PDF file through the API route. Not emitted for test requests. |
| verification.created | A public verification record was created with a reference (in the format WTQ-) allows the document's integrity to be verified publicly. Not emitted for test requests. |
The event envelope and the affected object
The body of every Webhook request is an object event A single envelope that wraps the affected object under data.object. The field type indicates the event type, and livemode distinguishes live mode from test mode, and created_at is a Unix timestamp (in seconds).
{
"id": "evt_...",
"object": "event",
"type": "signature_request.completed",
"created_at": 1754500000,
"livemode": true,
"data": {
"object": { /* Affected object: signature_request, signer, or ... */ }
}
}A full example of the event signature_request.completed with the signature_request object in full under data.object:
{
"id": "evt_2M8kQ1",
"object": "event",
"type": "signature_request.completed",
"created_at": 1754500000,
"livemode": true,
"data": {
"object": {
"id": "sr_3n8Kd2Qa1V",
"object": "signature_request",
"livemode": true,
"status": "completed",
"title": "Employment contract — Ahmed M.",
"legal_level": "aes",
"format": "pades-lt",
"source": { "type": "template", "template_id": "tpl_employment" },
"signers": [
{
"id": "sgr_9fA2",
"object": "signer",
"name": "Ahmed Mohamed",
"email": "ahmed@example.com",
"type": "individual",
"method": "draw",
"require_identity": true,
"order": 1,
"status": "signed",
"signing_url": "https://wthaiq.com/sign.php?token=uZ8..",
"viewed_at": 1754000100,
"signed_at": 1754499900,
"identity": { "status": "approved", "provider": "external", "level": "aes" },
"fields": { "job_title": "Software engineer", "start_date": "2026-08-01" }
}
],
"ordered": true,
"require_identity": true,
"reference": "WTQ-1A2B3C4D5E6F",
"reminders": { "enabled": true, "interval_hours": 48, "max": 3 },
"expires_at": 1755000000,
"completed_at": 1754500000,
"download_url": "https://wthaiq.com/api/v1/signature_requests/sr_3n8Kd2Qa1V/download",
"metadata": { "order_id": "A-1024" },
"created_at": 1754000000
}
}
}data.object according to type. Events signer.* carries the object signer, and document.sealed carries the object signature_request, and verification.created carries the object verification. Always rely on the value of type to determine how to read the payload.Verify every request before trusting it
With every request Wthaiq sends the header Wthaiq-Signature It lets you confirm that the payload came from us and has not been tampered with. Verification is mandatory: do not process any payload before it succeeds.
Wthaiq-Signature: t=1754500000,v1=<hmac_sha256 hex>t = the delivery timestamp (Unix seconds). v1 = the HMAC SHA-256 signature in hex.
Verification steps
| Step | Detail |
|---|---|
| 1 · Extract | Separate t and v1 from the header value. |
| 2 · Assemble | The signed payload = "{t}.{raw_body}" — that is, the timestamp, then a full stop, then the raw body verbatim. |
| 3 · Compute | Compute HMAC-SHA256 of the signed payload, with key = the endpoint secret whsec_, and output it as hex. |
| 4 · Compare | Compare the output withv1 with a constant-time comparison (hash_equals / timingSafeEqual) to avoid timing attacks. |
| 5 · Window | Reject the request if |now - t| > 300 seconds (5 minutes), to protect against replay attacks. |
Implementing verification
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.WTHAIQ_WEBHOOK_SECRET; // whsec_...
const TOLERANCE = 300; // seconds
// Important: receive the raw body (Buffer), not parsed JSON
app.post('/hooks/wthaiq',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body; // Raw Buffer
const header = req.get('Wthaiq-Signature') || '';
// 1) Extract t and v1
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=')));
const t = parts.t, v1 = parts.v1;
// 2) Timestamp window (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (!t || Math.abs(now - Number(t)) > TOLERANCE)
return res.status(400).send('timestamp out of tolerance');
// 3) Recompute the HMAC over "{t}.{raw_body}"
const signedPayload = t + '.' + raw.toString('utf8');
const expected = crypto
.createHmac('sha256', SECRET)
.update(signedPayload)
.digest('hex');
// 4) Constant-time comparison
const ok = v1 && expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(raw.toString('utf8'));
// Deduplicate on event.id, then respond immediately and process later
res.status(200).send('ok');
});import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WTHAIQ_WEBHOOK_SECRET"].encode() # whsec_... as bytes
TOLERANCE = 300 # Seconds
@app.post("/hooks/wthaiq")
def handle():
raw = request.get_data() # Raw bytes — do not use request.json
header = request.headers.get("Wthaiq-Signature", "")
# 1) Extract t and v1
parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
t, v1 = parts.get("t"), parts.get("v1")
# 2) Timestamp window (5 minutes)
if not t or abs(time.time() - int(t)) > TOLERANCE:
abort(400)
# 3) Recompute the HMAC over "{t}.{raw_body}"
signed_payload = f"{t}.".encode() + raw
expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
# 4) Constant-time comparison
if not v1 or not hmac.compare_digest(expected, v1):
abort(400)
event = request.get_json()
# Deduplicate on event["id"], then respond immediately and process later
return "", 200<?php
$secret = getenv('WTHAIQ_WEBHOOK_SECRET'); // whsec_...
$tolerance = 300; // seconds
// Read the raw body — never use $_POST
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_WTHAIQ_SIGNATURE'] ?? '';
// 1) Extract t and v1
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
// 2) Timestamp window (5 minutes)
if ($t === '' || abs(time() - (int)$t) > $tolerance) {
http_response_code(400);
exit('timestamp out of tolerance');
}
// 3) Recompute the HMAC over "{t}.{raw_body}"
$signedPayload = $t . '.' . $raw;
$expected = hash_hmac('sha256', $signedPayload, $secret);
// 4) Constant-time comparison
if ($v1 === '' || !hash_equals($expected, $v1)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($raw, true);
// Deduplicate on $event['id'], then respond immediately and process later
http_response_code(200);
echo 'ok';hash_equals and timingSafeEqual compare in constant time, regardless of where the difference lies.What happens on failure, and how to build a robust handler
2xx (or does not respond), we retry automatically using exponential backoff over a period of up to about 46 hours. After the nine attempts are used up, the delivery is marked as failed. You can redeliver any notification with the “Redeliver notification” button on the Webhooks page of the developer portal, or via POST /v1/events/{id}/redeliver, and catch up on missed events via GET /v1/events.event.id (in the format evt_) and ignore any ID you have already processed — so the result is the same however often delivery repeats.2xx as soon as you have verified the signature and stored the ID, then push the heavy work — updating databases, sending email, generating files — to a queue or an asynchronous job. Long synchronous processing slows the response, so it counts as a failure and the delivery is retried.created_at for ordering, and when you need the definitive state, query the latest version through GET /v1/signature_requests/{id} instead of relying on the event payload alone.Test your integration before launch
Build your integration with a test key sk_test_; its events carry livemode:false and nothing is charged and no message is sent to anyone. Register the webhook endpoint with the same test key via POST /v1/webhook_endpoints so that test events reach it, since endpoints added from the developer console are live. Then create a test request with your email and open the signing link yourself to receive the events signature_request.* and signer.* to your endpoint without affecting real customers.
Send a real event to yourself
Create a signature request with your key sk_ and make your own email the recipient, so you receive real signature_request.* and signer.* to your address to confirm delivery and verification — without affecting real customers, bearing in mind that the request is genuinely billed.
Send a test event
Send a test event from the developer console to your endpoint to check signature verification and processing, catch up on past events by their IDs via GET /v1/events, and redeliver any specific past notification from the Webhooks page or via POST /v1/events/{id}/redeliver.
Inspect deliveries
The developer console shows the latest deliveries for each endpoint: status, number of attempts, response code and time, so you can diagnose any failure.
# Create a small real request (use your own email as the recipient) to generate real events — it counts as one send from your plan allowance
curl -X POST https://wthaiq.com/api/v1/signature_requests \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Wthaiq-Version: 2026-07-01" \
-H "Content-Type: application/json" \
-d '{
"title": "Webhook test",
"source": {"type":"template","template_id":"tpl_employment"},
"legal_level": "ses",
"signers": [{"name":"Developer","email":"you@example.com","method":"draw"}]
}'A checklist for a production integration
- Verify every request before processing it; do not trust any payload that is unsigned or outside the timestamp window.
- Use the raw body literally in the HMAC computation, before any JSON parsing.
- Make your processing idempotent by storing
event.idand ignore duplicates. - Return 2xx quickly then process the heavy work asynchronously.
- Do not assume any ordering; query the latest status through the API whenever you need certainty.
- Use a separate secret for each endpoint and store it in an environment variable, and rotate it periodically.
- Keep test events separate from live ones. Field
livemodeequalsfalsein events from test keys; so do not apply any real effect to them in your system. - Subscribe only to what you need via
enabled_eventsto reduce noise and load. - Monitor deliveries from the developer console and resend when necessary.
- Always use HTTPS and never expose the secret in logs or error messages.
Developer questions about Webhooks
How do I verify that an incoming request is genuine?
Recompute HMAC-SHA256 over the signed payload "{t}.{raw_body}" with key = the endpoint secret whsec_, and compare the hex output with the value of v1 in the header Wthaiq-Signature with a constant-time comparison. Reject the request if the signature differs or if the gap between now and t greater than 300 seconds.
Why must the raw body specifically be used?
Because the signature is computed over the bytes exactly as they were sent. Parsing JSON and then re-serializing it may change the whitespace, the key order or the character encoding, so the bytes differ, the HMAC computation fails and a valid request is rejected. Read the raw body before any middleware that parses JSON.
What happens if my server fails to respond?
If your server responds with anything other than 2xx or times out, we retry with exponential backoff over about 46 hours. After the attempts are exhausted the delivery is marked failed, and you can catch up on missed events via GET /v1/events after fixing the problem.
Is the order of event delivery guaranteed?
No. Events may arrive in a different order from the one in which they occurred, and the same event may be repeated. Order them using created_at, and deduplicate on event.id, and query the latest status via GET /v1/signature_requests/{id} whenever you need certainty.
How do I test my integration before launch?
Use a test key sk_test_ and register your endpoint with it via POST /v1/webhook_endpoints, then create a test request with your email and open the signing link yourself; the events reach you with livemode: false and nothing is charged. Send a test event from the dashboard, and check the delivery log to see each attempt’s status and response code.
Ready to receive your first event?
Create an endpoint, verify the signature, and start reacting to signing events in real time. The quick guide takes you from zero to your first working Webhook in minutes.