Skip to content
Signing via API · Under your brand

Sign inside your app under your own brand, without redirecting the user to any external site

Embed a complete signing page in your product, with your own logo, colors and domain. You issue a short-lived signing session from your server, then embed it in an iframe or redirect to it, and receive events in real time via postMessage and Webhooks — while the secret key stays on your server alone. Signing, identity verification and qualified signing all sit within a single flow that never leaves your application.

iframe embed or redirect Your own signing domain Identity verification and QES inside the flow
wthaiq:signed signing_session app.yourbrand.com Account activation Review the agreement and sign it to complete registration Embedded · under your brand Sign here Signature confirmation
Integration models

Three ways to integrate signing — Choose what suits your application

The foundation is the same in all three: you create signature_request on your server, then you get a signing link for the signer. The model differs in how the user reaches that link.

1
hosted

The hosted page

Redirect the user to signing_url which comes ready in the signer's data. It is the fastest way to launch: a complete signing page hosted by Wthaiq on your custom domain, with no front-end code.

  • Zero code in the browser — a direct link
  • runs on the custom domain sign.yourbrand.com
  • Ideal for links sent by email or messaging
2
embedded / iframe

In-app embedding

Issue signing_session short-lived, then load it inside an iframe in your page. The user stays exactly where they are, and you receive the session events in real time via postMessage.

  • The highest completion rate — without leaving the page
  • Events wthaiq:viewed / signed / declined
  • A short-lived client token — not a secret key
3
redirect

Redirect

Redirect the browser to the signing page, then return it automatically to success_url after signing or cancel_url on cancellation. A middle ground between simplicity and control.

  • A guaranteed return to your application's route
  • Suitable for environments that do not allowiframe
  • The reference is returned in the URL parameter
Workflows

Integrate with three things: a workflow ID, a secret key and a signing secret

Set the workflow up once in the developer console: the document, the signature method, the send channel and the link validity. Your server then sends one short request naming the workflow and the signers, and the result comes back to it in a signed webhook.

wf_…

The ID of the published workflow in your account. It is sent in the field workflow_id, and every setting of the request is taken from it.

sk_…

The secret key. It is sent only from your server, in the header Authorization; the session endpoints do not accept the publishable key pk_.

whsec_…

The webhook signing secret. You use it to verify the header Wthaiq-Signature on every webhook that reaches your server, before you rely on it.

Where is the workflow built? Workflows are created and published from the Workflows page in the developer console. A session runs on the latest published version of the workflow; draft edits do not affect your requests until you publish them.

Create a session from a workflow

A session is a signature request created with the settings of a published workflow, and its ID is the signature request ID itself sr_…; so the endpoints under signature_requests work with it unchanged: fetch, cancel, events, download and the evidence record. It is not the embedded signing session signing_session explained in the “Signing session” section below.

POST /v1/sessions

Creates a signature request with the settings of the published workflow and sends it at once, then returns the session object with the signing link of every signer.

ParameterDescription
workflow_id
string Required
The ID of a published workflow in your account, in the form wf_….
signers
array Required
From one to five signers. Each signer has a name name (required), then email or phone depending on the send channel. Optionally also type · fields · access_code.
vendor_data
string Optional
Your reference in your own system, up to 190 characters. It is stored with the session, returned in its object and in webhooks, and can filter the sessions list.
template_id
string Depends on the workflow
The template for the request, in the form tpl_ followed by the template number. Send it when the workflow document is “Set with each request”; it is required if the workflow has no default template. In its place you may send document_id for a text document in your account. The two are not accepted together, and an uploaded PDF file is not accepted.
title
fields
metadata
Optional
The request title, the template field values and your free-form metadata, as on the signature requests endpoint.
Access code. If the workflow requires it, send for every signer an access_code of 4 to 32 characters. The code is stored hashed and is not written in the invitation, so hand it to the signer through your own channel.
cURL Node Response
session.sh
curl -X POST https://wthaiq.com/api/v1/sessions \
  -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Wthaiq-Version: 2026-07-01" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5f3c9c2e-2b7a-4a1e-9c2f-1d3e4f5a6b7c" \
  -d '{
    "workflow_id": "wf_8f3a1c9e2b",
    "vendor_data": "ORD-1001",
    "signers": [
      {"name": "Ahmed Mohamed", "email": "ahmed@example.com"}
    ]
  }'
server.js
// Server-side only — the secret key never leaves your server
const r = await fetch('https://wthaiq.com/api/v1/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.WTHAIQ_SECRET_KEY}`,
    'Wthaiq-Version': '2026-07-01',
    'Content-Type': 'application/json',
    'Idempotency-Key': order.id
  },
  body: JSON.stringify({
    workflow_id: process.env.WTHAIQ_WORKFLOW_ID,
    vendor_data: order.id,
    signers: [{ name: order.customerName, email: order.customerEmail }]
  })
});
const session = await r.json();

// Store session.id with the order in your system; the result arrives in the signed webhook
201 Created · session
{
  "id": "sr_3n8Kd2Qa1V",
  "object": "session",
  "status": "sent",
  "workflow_id": "wf_8f3a1c9e2b",
  "workflow_version": 1,
  "vendor_data": "ORD-1001",
  "channel": "email",
  "signers": [
    {
      "id": "sgr_9fA2",
      "name": "Ahmed Mohamed",
      "email": "ahmed@example.com",
      "phone": null,
      "status": "sent",
      "sign_url": "https://wthaiq.com/sign.php?token=uZ8.."
    }
  ],
  "sign_url": "https://wthaiq.com/sign.php?token=uZ8..",
  "created_at": 1754000000,
  "expires_at": 1754604800,
  "livemode": true,
  "cost": { "amount": 0, "currency": "EGP", "source": "plan" }
}

What a request may change

The workflow settings are the baseline, and a single request may change only the seven fields below. Whatever the request sends applies to that request alone and does not change the workflow.

The fieldDescription
signature_method
string
The signature method: simple an authenticated signature by drawing plus a verification code, identity a signature after identity verification, token a seal with the token certificate.
channel
string
The channel that delivers the signing link: email by email, whatsapp by WhatsApp message, link no message: the link returns to your server and you deliver it yourself.
cc
array
Email addresses that receive a copy of the contract once signing is complete, up to 20. The list you send replaces the workflow list.
require_id_attachment
boolean
Requires the signer to attach a photo of their ID; the signature cannot be completed before it is attached.
expires_in
integer
Link validity in days, from 1 to 30.
language
string
The language of the signing page and the invitation: ar or en.
return_url
string
A URL the signer returns to after signing or declining. It starts with https:// and is on a domain accepted on your account, and it replaces the workflow's URL for this request. An empty value removes the workflow's URL for this request.
ChannelWhat it requires and does
emailAn email for every signer. The invitation, the verification code and the reminders reach them by email; it is the channel of every new workflow unless you change it.
whatsappA WhatsApp number for every signer in international format; the invitation and the verification code reach them by WhatsApp at no extra cost. The channel is refused with a live key while it is not enabled on the platform.
linkAn e-mail address or a number for every signer, so the verification code reaches them when they open the link. No invitation or reminders are sent, and all links come back in the response. On a workflow whose parties sign in order, the signing page does not accept a party before the one ahead of them has signed.
What a request cannot carry. Set in the workflow only: ordered · reminders · require_access_code. These fields are refused too: send · legal_level · require_identity · source · document, as are the fields method · require_identity inside a signer, and naming a document for a workflow with a fixed template. The refusal comes back with the code invalid_override and the field name in param.
Defaults. A field the request does not send takes its value from the workflow. The workflow's base values, before you edit them or start from a preset, are: signature_method: simple · channel: email · expires_in: 7 · language: ar · require_id_attachment: false · cc: [].
Session and result

What comes back to your server: the session object, then the signed webhook

The create response returns the session object at once; the events then reach your endpoint under their usual names, each carrying the workflow ID, its version number and your reference.

The fieldDescription
idThe session ID, which is the signature request ID itself sr_….
statusThe request status: draft · sent · partially_signed · completed · declined · expired · canceled.
workflow_id
workflow_version
The workflow the session was created from, and its published version number at the moment of creation.
vendor_dataYour reference as you sent it, or null.
channelThe channel the signing link was sent through.
signers[]For every signer: id · name · email · phone · status · sign_url.
sign_urlThe signing link when there is a single signer. Its value is null when there are several signers; take the link from each signer in that case.
created_at
expires_at
Creation time and link expiry time, as unix timestamps.
costWhat was charged to your wallet for this request: the amount (amount) in your wallet currency (currency), and the source (source): plan when the send counts against your yearly plan allowance, so the amount covers identity checks only, and overage when the send is charged to the wallet. The amount is 0 with a test key.
return_urlThe return URL after signing. The field is present when the request has a return URL and absent otherwise.
trialIt is true for an older live request sent as one of the free requests that were available before account verification; the field is absent otherwise.
Request webhook Signer webhook Sessions list
signature_request.completed
{
  "id": "evt_2M8pQ",
  "object": "event",
  "type": "signature_request.completed",
  "created_at": 1754500000,
  "livemode": true,
  "data": {
    "object": {
      "id": "sr_3n8Kd2Qa1V",
      "object": "signature_request",
      "status": "completed",
      "reference": "WTQ-1A2B3C4D5E6F",
      "download_url": "https://wthaiq.com/api/v1/signature_requests/sr_3n8Kd2Qa1V/download",
      "workflow_id": "wf_8f3a1c9e2b",
      "workflow_version": 1,
      "vendor_data": "ORD-1001",
      "channel": "email"
    }
  }
}
signer.signed
{
  "id": "evt_2M8pP",
  "object": "event",
  "type": "signer.signed",
  "created_at": 1754499990,
  "livemode": true,
  "data": {
    "object": {
      "id": "sgr_9fA2",
      "object": "signer",
      "status": "signed",
      "signature_request": "sr_3n8Kd2Qa1V",
      "workflow_id": "wf_8f3a1c9e2b",
      "workflow_version": 1,
      "vendor_data": "ORD-1001"
    }
  }
}
list.sh
curl "https://wthaiq.com/api/v1/sessions?vendor_data=ORD-1001&limit=20" \
  -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Wthaiq-Version: 2026-07-01"

# Response
{
  "object": "list",
  "data": [ { "id": "sr_3n8Kd2Qa1V", "object": "session", "status": "completed", "vendor_data": "ORD-1001" } ],
  "has_more": false,
  "next_cursor": null
}
Workflow fields in webhooks. Event names do not change. Events of type signature_request.* carry the signature request object, which includes workflow_id · workflow_version · vendor_data · channel. Events of type signer.* carry the first three fields plus the request ID in signature_request. A request created without a workflow keeps its payload unchanged.

Fetch a session and browse sessions

The webhook is the recommended way to learn the result. The two endpoints below remain for querying when needed, and for reconciling your system by your reference.

GET /v1/sessions/{id}

Returns the session object in its current state. Returns 404 for a signature request that was not created from a workflow.

GET /v1/sessions

Returns sessions from newest to oldest in the mode of the key itself, live or test. It is filtered with the parameters workflow_id · vendor_data · status, and paged with the parameters limit · starting_after; a page holds 20 sessions unless you set otherwise, and at most 100.

Errors

Errors come back in the API's standard format, and the field param carries the name of the field that caused the error.

CodeWhen it is returned
parameter_missing
400
Missing workflow_id or signers, or missing template_id for a workflow whose document is set with each request and has no default template.
workflow_not_found
404
The workflow ID does not exist in your account.
workflow_not_live
409
The workflow has not been published yet, is archived, or its fixed template has not been set.
invalid_override
400
A field that cannot be changed from the request, or an invalid value for a field that can.
invalid_signer
400
Signer data is missing or invalid: the name, email, phone number, what the channel requires, or the access code; or there are more than five signers.
invalid_request
400
The value vendor_data is invalid, or the named template or document does not exist.
channel_unavailable
400
The WhatsApp channel was requested with a live key while it is not enabled on the platform.
insufficient_balance
402
The balance does not cover the request cost. It is checked before creation, so no request and no charge remain.
delivery_failed
502
No invitation could be delivered to any signer; the cost is returned to the balance and the request stays a draft.
domain_not_accepted
400
The domain of the URL sent in return_url is not accepted on your account. Add the domain on the Domains page of the developer console and wait for it to be accepted.
plan_required
402
A live request from an account without an active paid plan; test mode is always available. The error carries the link to the plans page in the field upgrade_url.
plan_expired
402
The subscription year ended without renewal, so live sending stopped until the plan is renewed. The error carries the link to the plans page in the field upgrade_url.
kyb_required
403
A live request before the company verification is approved. The error carries the link to the Company verification page in the developer portal in the field verify_url.
forbidden
403
A publishable key pk_, or a read-only key on a create request, was used.

Cost

Every signer in a live session counts as one send from your annual plan balance, with a maximum of five signers. Once the balance runs out, each extra send is charged to the wallet at your plan's overage rate, and $1.50 is charged to the wallet for each signer when the signing method is identity. The amount charged to the wallet is returned in the cost field, and its source in cost.source.

Test mode

With a test key sk_test_ nothing is charged and every feature except the custom domain is available. The invitation arrives by email marked “Test” and no copies are sent; WhatsApp messages are not sent but logged in the request events, and identity verification is simulated with no real session. The signer's email must belong to your account or to a member of your team, and the signing link comes back in the response so you can try it yourself. The daily limit is 20 requests and 50 emails, with at most 5 signers per request.

Idempotency

Send the header Idempotency-Key with the create request. If the request is repeated with the same key within 24 hours, the same session comes back with the header Idempotent-Replayed: true, so no second session is created and nothing is charged twice.

Accepted keys

The session endpoints are for the secret key only. The publishable key pk_ is refused with code 403, and a read-only key can fetch sessions but cannot create them.

Return URL after signing

Set the return URL in the workflow, or send it with the request in the field return_url. After the signer signs or declines, a return button appears and they are redirected to your URL with session_id · signer_id · status set to completed or declined. Rely on the signed webhook or on fetching the session for the result, not on the return.

Signing in order

When the parties of a workflow sign in order, each party is invited after the previous one signs, and you receive the event signer.sent when their turn comes. With the link channel, all links come back in the creation response, and the signing page does not accept a party before the one ahead of them has signed.

Verification code channel

The verification code goes through the request's channel only: by e-mail with email, and by WhatsApp with whatsapp. It goes by e-mail if the signer has no number or WhatsApp codes are not enabled on the platform. With link it goes to the signer's e-mail if there is one, otherwise to their number. The WhatsApp channel has no extra cost.

Live mode and company verification

The test key is free within a daily limit on every plan, and its requests carry no legal weight. A live request requires an active paid plan and a verified company: without a plan the error is plan_required with status 402, after the subscription year ends plan_expired with status 402, and before company verification is approved kyb_required with status 403. Companies are verified from the “Company verification” page in the developer console: an identity check of the representative, then a review of the company documents.

The signing session

Generate a short-lived signing session

Never send your secret key to the browser. Instead, your server calls this endpoint to mint a temporary session token scoped to a single signer, which is safe to send to the front end.

POST /v1/signers/{id}/signing_session

Issues an embedded signing link valid for 30 minutes for a specific signer, as part of the branded signing pattern.

ParameterDescription
return_url
string Optional
A full https URL on a domain accepted in your account, to which the signer returns after signing; available on the Business plan and above. The session lifetime is fixed at 30 minutes.
Why a short-lived session instead of the secret key? Key sk_ has full authority over your account — creating requests, downloading signed documents, managing Webhooks. If it leaked from the browser, any party could take control of it. The session token, by contrast, is restricted to a single signer, to the "signing" scope only, to a few minutes, and to specific domains — so even if it is intercepted, it opens only what it was minted for, and then it expires.
cURL Node Response
signing_session.sh
curl -X POST https://wthaiq.com/api/v1/signers/sgr_9fA2/signing_session \
  -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Wthaiq-Version: 2026-07-01" \
  -H "Content-Type: application/json" \
  -d '{ "return_url": "https://app.acme-pay.com/signed" }'
server.js
// Server-side only — the secret key never leaves your server
const r = await fetch('https://wthaiq.com/api/v1/signers/sgr_9fA2/signing_session', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.WTHAIQ_SECRET_KEY}`,
    'Wthaiq-Version': '2026-07-01',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ return_url: 'https://app.acme-pay.com/signed' })
});
const session = await r.json();

// Send only this to the browser — nothing else
res.json({ signing_url: session.signing_url });
200 OK · signing_session
{
  "object": "signing_session",
  "signer": "sgr_9fA2",
  "signing_url": "https://wthaiq.com/embed/sign/9aF2xQ7bV3..",
  "expires_at": 1754001800
}
White-label

Full brand customization

The signing page is entirely yours. Branding is set once at the account level from the “Branding” page in the developer console, is available on the “Business” plan and above, and is applied automatically to every signature request you create through the API; the request body carries no branding settings.

account-branding
{
  "brand_name": "Acme Pay",
  "logo_url": "https://cdn.acme-pay.com/logo.svg",
  "brand_color": "#0B5FFF",
  "custom_domain": "sign.acme-pay.com",
  "from_email": "sign@acme-pay.com",
  "hide_powered": true,
  "sign_welcome_ar": "مرحبًا بك في Acme Pay. راجع العقد ثم وقّعه.",
  "sign_welcome_en": "Welcome to Acme Pay. Review the contract, then sign.",
  "sign_button_color": "#0B5FFF"
}
The fieldDescription
brand_nameYour brand name shown on the signing page; it also becomes the sender name of invitation and reminder emails once your domain is verified.
logo_urlYour logo, shown at the top of the signing page. SVG or PNG with a transparent background is preferred.
brand_colorYour primary brand color (HEX); applied to buttons, links and progress indicators.
custom_domainThe custom signing domain, such as sign.acme-pay.com. The custom domain is activated by contacting our team.
from_emailThe sender address on your domain, after configuring SPF and DKIM records to ensure delivery.
hide_poweredWhen on, the “via Wthaiq platform” line is hidden from the signing page for API requests. The signature certificate and the public verification page always name Wthaiq.
sign_welcome_ar
sign_welcome_en
A welcome sentence shown to the signer at the top of the signing page in the page language: plain text, up to 240 characters.
sign_button_colorThe colour of the primary buttons on the signing page, in HEX. If absent, the brand colour is used; a light colour is not accepted because the button text is white.
The custom domain makes the address bar itself display sign.acme-pay.com instead of the Wthaiq domain — so the user sees no reference to an external provider at any point in the signing journey.
Embedding

Embedding in your page — iframe and postMessage events

Load the URL signing_url produced by the session inside iframe, then listen for the window events through postMessage to update your interface in real time.

embed.html
<iframe
  id="wthaiq-frame"
  src="https://sign.acme-pay.com/embed/sign/9aF2xQ7bV3.."
  allow="camera; microphone"
  style="width:100%;height:760px;border:0;border-radius:16px">
</iframe>

<!-- allow: camera/microphone required for the identity verification step inside the iframe -->
listen.js
window.addEventListener('message', function (e) {
  // Always verify the origin of the message before trusting it
  if (e.origin !== 'https://sign.acme-pay.com') return;

  const evt = e.data;
  switch (evt.type) {
    case 'wthaiq:viewed':
      // The signer opened the document
      console.log('viewed', evt.signer);
      break;
    case 'wthaiq:signed':
      // Signing completed — close the frame and start activation
      onSigned(evt.signature_request);
      break;
    case 'wthaiq:declined':
      // The signer declined — log the reason and redirect
      onDeclined(evt.reason);
      break;
  }
});
wthaiq:viewed

Fired when the signer opens the document for the first time inside the iframe — use it to track the start of the journey.

wthaiq:signed

Fired as soon as signing completes successfully, and carries signature_request and signer.

wthaiq:declined

Fired when the signer declines or cannot complete, and carries the field reason.

Browser events versus Webhooks. Events postMessage are ideal for updating the interface immediately, but they are not the source of truth — the user may close the tab before they arrive. Always rely on the Webhook signature_request.completed that reaches your server, as the final trusted signal before triggering any sensitive action.

Redirection instead of embedding

If you would rather not use theiframe, mint the session using the redirect with success_url and cancel_url, then send the browser to signing_url. Wthaiq returns the user to your route automatically with the reference in the URL parameter.

After the user signs, they are redirected to success_url, or, if they decline to sign, to cancel_url, and the URL receives session_id and signer_id and status, and {signature_request} in the URL is replaced with the request ID. Both URLs must be https on a domain accepted on your account; rely on the signed webhook for the outcome, not on the redirect.

redirect.sh
curl -X POST https://wthaiq.com/api/v1/signers/sgr_9fA2/signing_session \
  -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Wthaiq-Version: 2026-07-01" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "redirect",
    "success_url": "https://app.acme-pay.com/onboarding/done?sr={signature_request}",
    "cancel_url": "https://app.acme-pay.com/onboarding/canceled"
  }'
Identity and qualified signing

Identity verification and QES — inside the same flow

The user never leaves your iframe to verify their identity or to sign with a qualified certificate. Both steps appear embedded immediately before signing, and this is what the user sees in each of them.

AES

Identity verification before signing

When you enable require_identity on the signer, the frame shows a full verification step before the signing page opens — and the verification decision is tied to the signature itself.

What the user sees
  • Capturing the official documentThe iframe asks for a photograph of the ID card or the passport to be captured with the camera directly inside your page.
  • Live face matchThe user is asked to move their face in front of the camera to confirm that they are a live person present, and to match them against the photograph on the document.
  • "Verifying", then approval ofA processing indicator appears, then a green success mark — and the signer's status becomes identity_verified.
  • Opening the signing pageOnly after approval does the signing area become available, so the user signs with their signature already tied to a verified identity.
QES · token agent

Qualified signing with a token

for qualified signing (method: "token"), signing with the hardware certificate is carried out through a local agent on the user's device at 127.0.0.1:8899 without the private key ever leaving the token (deferred, detached PAdES).

What the user sees
  • Agent and token detectionThe page checks that the local agent is running and the token is connected, and displays "Token detected".
  • Preparing the signature on the serverThe server computes signedAttributes (the document's SHA-256 hash) and sends only the hash — no private key is involved here.
  • Entering a PIN on the deviceThe agent asks for the token PIN, which the user enters to authorize a single signing operation on the device.
  • Signing locally, then embedding the CMSThe token signs the hash and returns a structure CMS, and the server embeds it in the document and completes PAdES up to LTA.
qes-deferred.js
// 1) The server prepares signedAttributes (the document hash) — no private key
const prep = await signingPage.prepareTokenSignature();
// prep.digest = SHA-256 of signedAttributes (ESS signingCertificateV2)

// 2) In the browser: the local agent signs on the device
const signed = await fetch('https://127.0.0.1:8899/sign', {
  method: 'POST',
  body: JSON.stringify({ digest: prep.digest, alg: 'SHA256withRSA' })
}).then(r => r.json());
// The key never leaves the token; the agent returns a CMS structure (adbe.pkcs7.detached)

// 3) The server embeds the CMS in the document and finalizes PAdES (LT/LTA)
await signingPage.completeTokenSignature({ cms: signed.cms });
The snippet above shows what the signing page does by itself inside the frame; you do not call any of it from your server. Both steps appear with your own branding and language inside the iframe. See The legal standing page for a breakdown of the SES / AES / QES levels and the ladder of PAdES formats from B to LTA.
The full flow

From the request to the signed document — step by step

One complete embedding journey: create the request on your server, embed the signing, wait for the trusted completion signal, then download the sealed document.

  1. Create an embedded signature request

    On your server, create signature_request with its signers, its source and its legal level.

    POST /v1/signature_requests
  2. Get the signing link

    Get signing_url from the signer's data, or issue signing_session short-lived, for embedding.

    signers[0].signing_url
  3. Embed it in your page

    Load the URL in iframe and listen for the events postMessage to update the interface.

  4. The user completes signing

    verifies their identity (AES) where required, then signs inside your frame without leaving your app.

  5. Receive the trusted notification

    Your server receives the completion event — this is the definitive signal to trigger your post-signing logic.

    signature_request.completed
  6. Download the sealed document

    Request the final PDF once the request is complete; its independent cryptographic proof is the sealed evidence record (Ed25519 + an RFC 3161 timestamp).

    GET /v1/signature_requests/{id}/download
1 · Create 5 · Webhook 6 · Download
create.sh
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" \
  -H "Idempotency-Key: 5f3c9c2e-2b7a-4a1e-9c2f-1d3e4f5a6b7c" \
  -d '{
    "title": "Merchant Agreement — Acme Pay",
    "source": {"type":"template","template_id":"tpl_merchant_agreement"},
    "legal_level": "aes",
    "signers": [
      {"name":"Salma Hassan","email":"salma@example.com","type":"individual",
       "method":"draw","require_identity":true}
    ],
    "metadata": {"merchant_id":"M-88213"}
  }'
webhook.json
{
  "id": "evt_2M8pQ",
  "object": "event",
  "type": "signature_request.completed",
  "created_at": 1754500000,
  "livemode": true,
  "data": {
    "object": {
      "id": "sr_3n8Kd2Qa1V",
      "object": "signature_request",
      "status": "completed",
      "reference": "WTQ-1A2B3C4D5E6F",
      "download_url": "https://wthaiq.com/api/v1/signature_requests/sr_3n8Kd2Qa1V/download"
    }
  }
}
download.sh
# Available only once the status becomes completed
curl https://wthaiq.com/api/v1/signature_requests/sr_3n8Kd2Qa1V/download \
  -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Wthaiq-Version: 2026-07-01"

# Output: links to the final contract (JSON) · independent cryptographic proof: the evidence record via GET /v1/signature_requests/{id}/evidence
Security

Embedding security rules

Embedding is safe as long as you separate what stays on the server from what reaches the browser.

Never send sk_ to the browser, ever

The secret key stays exclusively on the server. The browser sees only the signing link and the short-lived session token.

A short token lifetime (TTL)

The session link expires 30 minutes after it is created, and an expired link is useless even if intercepted; so create the session at the moment the signer needs it.

An allowed-domains list

Set allowed_origins to your domains only. Any attempt to embed the session from an origin outside the list is rejected.

Verify events on the server

Do not trigger a sensitive action based on postMessage alone; rely on the Webhook signed withwhsec_ as the source of truth.

FAQs

Developer questions about embedding

Does the user see any reference to Wthaiq while signing?

No, if you enable full White-label. The signing page appears with your logo and colors and on your custom domain sign.yourbrand.com, emails are sent under your own brand, and the "Powered by Wthaiq" line is removed once you set hide_powered: true. The user stays inside your experience from start to finish.

Why do I mint signing_session instead of passing the secret key?

because sk_ has full privileges over your account and must never reach the browser. The short-lived session token is restricted to a single signer, to the signing permission only, to specific domains and to a few minutes — so even if it is intercepted, it opens only what it was minted for and then expires automatically.

What is the difference between the events postMessage and Webhooks?

Browser events such as wthaiq:signed to update the interface in real time, but it may not arrive if the user closes the tab. The Webhook is like signature_request.completed reaches your server reliably and signed withwhsec_, which is the source of truth for any sensitive action such as activation.

How does QES qualified signing work inside the iframe?

through a local signing agent on the user's device at 127.0.0.1:8899. The server prepares the hash signedAttributes and sends only the hash, so the user enters the token code and the device signs locally — the private key never leaves the token — and then it is embedded CMS in the document to complete PAdES up to LTA. This is deferred/detached signing.

What if our environment does not allow the use of theiframe?

Use the pattern redirect: mint the session with success_url and cancel_url, then send the browser to signing_url. Wthaiq returns the user to your route automatically with the reference in the URL. Or use the hosted page directly via signing_url on your custom domain.

Sign inside your application — under your own brand.

Start with a single signing session in minutes, then embed a complete signing experience under your own brand and domain without sending any user to an external site.