Receive events on your server in real time instead of polling repeatedly. 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 instead of polling the API repeatedly.
signature_request.completed
t=1754500000,v1=6ff7d3f2b1a0c9e4d5f8a1b0c3a
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.
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_ It is shown once, at creation only. Store it in a secure environment variable — you will need it for every verification. Each endpoint has its own separate secret.When any subscribed event occurs, Wthaiq sends a POST to your address, whose body is JSON = the object event 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", ... }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.
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.
Events fall into groups: events at the signature request level signature_request.*, and events for each signer signer.*, in addition to document sealing and public verification. 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 | The first signer opened the request's signing page for the first time. |
| 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 cancelled the request through the API or the dashboard 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 in turn in ordered requests). |
| signer.viewed | The signer opened their own signing page. |
| 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 Didit — at the AES level. |
| 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. |
| verification.created | A public verification record was created with a reference (in the format WTQ-) allows the document's integrity to be verified publicly. |
The body of every Webhook request is an object event A single envelope that wraps the affected object under data.object. It tells you type with the event type, andlivemode distinguishes live mode from test mode, andcreated_at 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 — the event signature_request.completed with the object signature_request 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://sign.wthaiq.com/s/uZ8..",
"viewed_at": 1754000100,
"signed_at": 1754499900,
"identity": { "status": "approved", "provider": "didit", "level": "aes" },
"fields": { "job_title": "Software engineer", "start_date": "2026-08-01" }
}
],
"ordered": true,
"require_identity": true,
"reference": "WTQ-000123",
"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, anddocument.sealed carries the object document, andverification.created carries the object verification. Always rely on the value of type to determine how to read the payload.With every request Wthaiq sends the header Wthaiq-Signature It lets you prove that the payload came from us and has not been tampered with. Verification is mandatory: do not process any payload before verification succeeds.
Wthaiq-Signature: t=1754500000,v1=<hmac_sha256 hex>| Step | Detail |
|---|---|
| 1 · Extract | Separate t andv1 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. |
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 andtimingSafeEqual compares in constant time regardless of where the difference lies.2xx (or does not respond), we retry automatically using exponential backoff over a period of up to 24 hours. Once the retries are exhausted the delivery is marked as failed, and you can resend it manually from the dashboard.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.There is no separate test mode — every sk_ live and fires real events, with livemode:true always, and any signature request you create sends real email and is billed. To test safely, create a small real request and make yourself the signer (your own email) so that you receive real signature_request.* andsigner.* to your endpoint without affecting real customers.
Create a signature request with your key sk_ and your own email as the recipient, so you receive real signature_request.* andsigner.* to your address to confirm delivery and verification — without affecting real customers, bearing in mind that the request is genuinely billed.
Resend any earlier event with the same ID from the dashboard to test your deduplication logic and error handling without waiting for a new event.
The dashboard shows a log of every delivery attempt: the response code, the headers, the body and the response time — so you can diagnose any failure precisely.
# Create a small real request (use your own email as the recipient) to generate real events — your balance will be charged
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"}]
}'event.id and ignore duplicates.livemode fixed at true always — there is no test mode by which the event is distinguished.enabled_events to reduce noise and load.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 andt greater than 300 seconds.
Because the signature is computed over the bytes exactly as they were sent. Parsing JSON and then re-serialising 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.
If your server responds with anything other than 2xx or times out, we retry using exponential backoff over a period of up to 24 hours. Once the retries are exhausted the delivery is marked as failed, and you can resend it manually from the delivery operations dashboard after fixing the problem.
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.
There is no separate test mode — use your real key sk_ to create a small signature request with your own email as the recipient, so real events reach your endpoint (and the cost of the request is genuinely charged). Replay earlier events from the dashboard to test deduplication, and inspect the delivery log to see the response code, the headers, the body and the response time.
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.