Skip to content
Developers · Webhooks

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.

HMAC SHA-256 signature Retries for about two days 21 event types
event delivery
Wthaiq serverAn event in your account is sent signed
POST
signature_request.completed
Your server · /hooks/wthaiqVerifies the signature, then returns 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_ 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.
2

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.

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.
The list of events

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 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.viewedA 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_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 canceled the request through the API or the developer console before it completed.
signer.* — at the level of each signer
signer.sentThe signing link was sent to a specific signer (fired for each signer when their turn comes, if a signing order is set).
signer.viewedThe signer opened their signing page for the first time — from the email or WhatsApp link, or an embedded signing session (signing_session).
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 a specialized identity-verification provider — at the AES level.
signer.identity_in_reviewThe 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_requestedThe 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_declinedThe 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_holdThe 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_decidedA 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.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. Not emitted for test requests.
verification.createdA 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.
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. The field type indicates the event type, and livemode distinguishes live mode from test mode, and created_at is 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 of the event signature_request.completed with the signature_request object 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://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
    }
  }
}
Note: The shape of 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.
Signature verification · critical

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.

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

Verification steps

StepDetail
1 · ExtractSeparate t and v1 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 attacks.
Use the raw body literally. Compute the HMAC over the body bytes exactly as they arrived. Any re-encoding — parsing JSON and re-serializing 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 and timingSafeEqual compare 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 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.
When is a notification sent? Up to ten notifications from a single operation are sent the moment it happens; any beyond that go out within about a minute.
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 with 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

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.

trigger_test_events.sh
# 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"}]
  }'
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.
  • Keep test events separate from live ones. Field livemode equals false in events from test keys; so do not apply any real effect to them in your system.
  • Subscribe only to what you need via enabled_events to 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.
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 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.