Developers · Webhooks

Webhooks — Real-time notifications of each signature's status.

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.

HMAC SHA-256 signature Retries for up to 24 hours 16 event types
event delivery
Wthaiq serverA signed event occurred in your account
POST
signature_request.completed
Your server · /hooks/wthaiqVerify the signature, then return 200 OK
Wthaiq-Signature t=1754500000,v1=6ff7d3f2b1a0c9e4d5f8a1b0c3a
How it works

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.

1

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 fieldTypeDescription
urlstring RequiredAn HTTPS URL that receives POST requests. It must be publicly reachable and respond quickly.
enabled_eventsstring[] RequiredThe list of event types sent to this address. Include only what you need.
create_endpoint.sh
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"]
  }'
webhook_endpoint · 201 Created
{
  "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
}
Save the secret immediately. The value 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.
2

Receive a POST request

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.

incoming request
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", ... }
3

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.

4

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.

The golden rule: Verify, store the event ID, return 200 immediately — then process the rest in the background.
Event catalogue

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.*, in addition to document sealing and public verification. Subscribe only to what you need through enabled_events.

The event typeWhen it fires
signature_request.* — at the signature request level
signature_request.createdA new signature request was created as a draft in your account before being sent.
signature_request.sentThe request was sent to the signers and the signing links became active.
signature_request.viewedThe first signer opened the request's signing page for the first time.
signature_request.partially_signedOne signer signed while others are still pending (in multi-signer requests).
signature_request.completedAll the signers signed and the request completed; the sealed document becomes available for download and is assigned a public verification reference.
signature_request.declinedOne of the signers declined to sign, so the request stopped.
signature_request.expiredExpired expires_at before signing completes.
signature_request.canceledYou cancelled the request through the API or the dashboard before it completed.
signer.* — at the level of each signer
signer.sentThe signing link was sent to a specific signer (fired for each signer in turn in ordered requests).
signer.viewedThe signer opened their own signing page.
signer.otp_verifiedThe signer successfully entered the one-time verification code (OTP) sent to their email.
signer.identity_verifiedThe signer passed identity verification — official document and live face match via Didit — at the AES level.
signer.signedThe signer completed their signature on the document.
signer.declinedThe signer declined to sign, with an optional reason.
document.sealed and verification.created — sealing and verification
document.sealedAll 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.createdA public verification record was created with a reference (in the format WTQ-) allows the document's integrity to be verified publicly.
Payload structure

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. It tells you type with the event type, andlivemode distinguishes live mode from test mode, andcreated_at A Unix timestamp (in seconds).

event envelope
{
  "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:

signature_request.completed
{
  "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
    }
  }
}
Note: The shape of 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.
Signature verification · critical

Verify every request before trusting it

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.

Anatomy of the header:
Wthaiq-Signature: t=1754500000,v1=<hmac_sha256 hex>
t = the delivery timestamp (Unix seconds). &nbsp; v1 = the HMAC SHA-256 signature in hex.

Verification steps

StepDetail
1 · ExtractSeparate t andv1 from the header value.
2 · AssembleThe signed payload = "{t}.{raw_body}" — that is, the timestamp, then a full stop, then the raw body verbatim.
3 · ComputeCompute HMAC-SHA256 of the signed payload, with key = the endpoint secret whsec_, and output it as hex.
4 · CompareCompare the output withv1 with a constant-time comparison (hash_equals / timingSafeEqual) to avoid timing attacks.
5 · WindowReject the request if |now - t| > 300 seconds (5 minutes) to protect against replay.
Use the raw body literally. Compute the HMAC over the body bytes exactly as they arrived. Any re-encoding — parsing JSON and re-serialising it, or changing whitespace or key order — produces different bytes, so verification fails and a valid request is rejected. Read the raw body before any middleware that parses JSON automatically.

Implementing verification

Node.js Python PHP
webhook.js · Express
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');
  });
webhook.py · Flask
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
webhook.php
<?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';
Why a constant-time comparison? A plain text comparison can finish early at the first difference, leaking timing information that can be exploited to guess the signature. hash_equals andtimingSafeEqual compares in constant time regardless of where the difference lies.
Retries and reliability

What happens on failure, and how to build a robust handler

Retry with exponential backoff. If your server responds with any code outside the range 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.
Make your handler idempotent. The same event may reach you more than once (because of a retry, or because your response did not get through). Store event.id (in the format evt_) and ignore any ID you have already processed — so the result is the same however often delivery repeats.
Respond in under 5 seconds. Respond 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.
Ordering is not guaranteed. Events may arrive in a different order from the one in which they occurred. Do not assume a sequence; use 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.
Testing

Test your integration before launch

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.

Send a real event to yourself

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.

Replay events

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.

Inspect deliveries

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.

trigger_test_events.sh
# 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"}]
  }'
Best practices

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.id and 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.
  • Remember that every event is live. Field livemode fixed at true always — there is no test mode by which the event is distinguished.
  • Subscribe only to what you need via enabled_events to reduce noise and load.
  • Monitor deliveries from the dashboard and resend when necessary.
  • Always use HTTPS and never expose the secret in logs or error messages.
FAQs

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 andt 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-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.

What happens if my server fails to respond?

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.

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?

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.

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.