Integration guides
Complete references for hosted verification links, name screening, Quick Scan and workflow webhooks.
... mark illustrative shapes, not literal JSON to submit. Quick Scan calls use apiVersion=1.0.0.Verification Link
Generate a one-off web link that takes an end user straight into the hosted KYC or KYB flow. Send them the link — by email, chat, or any channel you already use — and they complete the verification in a browser. This is the path to take when you have no website or app of your own to embed an SDK into.
Endpoint:
sdk.launch.link.generate
POST
Use apiVersion 1.0.0. Signing, gateway and the
x-domain header are the same as every other OpenAPI method
(see Authentication).
Request
{
"email": "enduser@example.com",
"customerId": "cust_internal_id",
"eventType": "KYC_EVENT"
}
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | End-user email |
customerId |
string | Yes | Your internal user ID (keep it stable for the same customer) |
eventType |
string | Yes |
Which flow the link opens: KYC_EVENT (individual) or
KYB_EVENT (corporate)
|
Response
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"msg": "success",
"code": 200,
"data": {
"url": "https://id-link-platform.width.com/?linkToken=71a2743a-ca3c-436f-bc24-af3a73405a58",
"expiresAt": 1786679539986
}
}
}
| Field | Description |
|---|---|
data.data.url |
The link to hand to the end user. The
linkToken query parameter identifies this one
invitation — treat the whole URL as a secret and send it
only to the address you passed in email.
|
data.data.expiresAt |
Expiry time, epoch milliseconds. A link is valid for 7 days from the moment it is generated; after that, call this endpoint again for a fresh one. |
email invalidates the previous link
the moment the new one is issued — the copy
already in the end user’s hands stops opening, and there is no
way to bring it back. Before you reissue, make sure the recipient is
not part-way through the flow; if they are, send them the new link.
Links issued to different email addresses never affect one
another.
Errors
An unsupported eventType is reported inside
data, with the gateway-level apiCode still
200 — read data.code, not the HTTP
status:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"msg": "Invalid eventType: TRANSACTION_EVENT",
"code": 400,
"data": {}
}
}
Name Screening
Send a subject — a person or a company — to be screened
against watchlists. The platform files the subject under a customer
record, runs the screening inside your workflow, raises alerts on
matches and opens a case for an analyst to adjudicate. Use
eventType: "MANUAL_CASE_EVENT".
customerId comes back. If all you want
is a throwaway lookup with no customer and no case, use
Quick Scan instead.
Endpoint:
workflow.apply.submit
POST
POST to the gateway with the usual query string —
api=workflow.apply.submit, apiVersion=2.0.0,
apiKey and sign — plus the
x-domain header carrying your domain ID. The body is UTF-8
JSON sent with Content-Type: application/json, and
sign is its HMAC-SHA256 over the
exact bytes you put on the wire: serialise the body
once, sign those bytes, send those same bytes, and do not let a
framework re-serialise the body afterwards. Full rules and code samples
are in Authentication.
Request
{
"eventType": "MANUAL_CASE_EVENT",
"source": "API",
"workflowId": "{workflowId}",
"data": {
// subject fields, see below
}
}
| Field | Type | Required | Description |
|---|---|---|---|
eventType |
string | Yes |
Fixed value MANUAL_CASE_EVENT. The Console labels
this event Manual.
|
source |
string | Yes |
Fixed value API for server-side integrations.
|
workflowId |
string | Yes, for this flow | Which screening workflow to run. Take the ID from the workflow list in the Console. Leaving it out is technically allowed — the platform then routes to the most recently published workflow — but for name screening name the workflow explicitly, so a later publish elsewhere cannot silently change what your submissions run through. |
data |
object | Yes |
The subject being screened. Fields go at the top level of
data, not nested deeper.
|
Subject Fields
| Field | Required | Description |
|---|---|---|
customerId |
Yes | Your customer number. The platform creates or reuses a customer profile under this key, and an analyst’s decision is remembered against it — several cases on the same customer share what has already been adjudicated. Pick a key that never changes for that customer; the cross-case memory depends on it. |
name |
Yes | The subject’s name, as one string — do not split it into surname and given-name fields. For a person send the full name in English; for a company send the registered name. |
aliasName |
Recommended |
Other names the subject is known by, such as the name in its
original script. Each name is screened separately, and the
original-script name improves match accuracy noticeably. Separate
several names with commas — which means
an alias itself must not contain a comma: written
as Chen, Dawei it is read as two names, so write
Chen Dawei. This applies to
aliasName only; name is never
split.
|
customerType |
Yes |
individual for a person, corporate for
a company.
|
sex |
Recommended (person) |
Sex. F / M or the English word in
full.
|
dateOfBirth |
Recommended (person) |
Date of birth. A full date (1966-03-15) or the year
alone (1966) both work.
|
countryOfNationality |
Recommended (person) | Nationality. ISO 3166 two-letter or three-letter codes are both accepted; the platform normalises them. |
countryOfResidence |
Optional | Country of residence, or principal place of business for a company. |
occupation |
Optional (person) | Occupation or job title. Shown on the case page. |
idType, idNumber |
Optional (person) | Identity document type and number. Send them as a pair, and only for a real identity document (passport, national ID and the like) — never an internal reference number. When they take effect: only on that customer’s first submission, while the platform has no profile for them yet; from then on the document held on the profile is authoritative and a document sent in the event no longer applies. |
countryOfIncorporation |
Recommended (company) |
Country of incorporation. If omitted,
countryOfResidence is used instead.
|
incorporationDate |
Optional (company) | Date of incorporation, full date or year alone. Written to the customer profile and shown on the case page; in the current version it is not sent to the screening engine as match evidence. |
incorporationNumber |
Optional (company) | Company registration number. Matched against the registration numbers on watchlist records — for a company this is the strongest single piece of evidence, so the same rule applies: better empty than approximate, send only a real registration number. |
sex, dateOfBirth and
countryOfNationality for a person, and
countryOfIncorporation and
incorporationNumber for a company, are used as
corroborating evidence: they feed the match and push down weak hits. The
more of them you send, the less same-name noise comes back. A missing
field is treated neutrally and never counts against the subject.
Response
The call is asynchronous: it returns as soon as the submission is accepted, and the screening runs on the platform.
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"executionId": "exec-xxxxxxxxxxxxxxxx",
"requestId": "req-xxxxxxxxxxxxx",
"status": "RUNNING"
}
}
| Field | Description |
|---|---|
data.executionId |
Identifies this execution. Keep it: it is what you quote when asking about progress or reporting a problem, and what workflow.execution.status takes to poll the outcome. |
data.status |
RUNNING on a successful submission. The screening
result and risk score end up on the case page in the
Console.
|
A failed submission returns the gateway’s standard error envelope.
The usual causes are a signature that does not match the bytes sent, a
missing x-domain header, a body that is not valid JSON, and
a missing required field.
Examples
Individual subject:
{
"eventType": "MANUAL_CASE_EVENT",
"source": "API",
"workflowId": "{workflowId}",
"data": {
"customerId": "CIF-0001234",
"name": "CHEN, DAWEI",
"aliasName": "陳大偉",
"customerType": "individual",
"sex": "M",
"dateOfBirth": "1968",
"countryOfNationality": "TW",
"countryOfResidence": "TW",
"occupation": "Director"
}
}
Corporate subject:
{
"eventType": "MANUAL_CASE_EVENT",
"source": "API",
"workflowId": "{workflowId}",
"data": {
"customerId": "CIF-0005678",
"name": "HUAYUAN INTERNATIONAL TRADING CO., LTD",
"aliasName": "華遠國際貿易有限公司",
"customerType": "corporate",
"countryOfIncorporation": "HK",
"incorporationDate": "2003",
"incorporationNumber": "0691355",
"countryOfResidence": "HK"
}
}
Quick Scan API
Quick Scan provides one-off compliance screening with no need to create a customer or configure a workflow. Submit a single name and the platform runs several screening engines in parallel — World-Check, Dow Jones, ArtemiScan, Internet Search and your internal watchlist — and returns the matches (hits). Useful for ad-hoc sanctions / PEP (Politically Exposed Person) name checks.
api / apiVersion /
apiKey / sign) plus the
x-domain header, and the same HMAC-SHA256 request-body
signing as every other OpenAPI call. See
OpenAPI Reference → Getting Started for the base
URL and request format, and Authentication for the
full signing rules.
Flow
-
quickscan.submit— submit a screening; returns anidimmediately (asynchronous) -
quickscan.detail— poll for status; onDONEit returns the hit summary -
quickscan.hits— fetch full hit details per provider, paginated (optional)
Endpoints
| API Name | Description | Timeout |
|---|---|---|
quickscan.submit
POST
|
Submit a screening | 30s |
quickscan.detail
POST
|
Query status and result | 10s |
quickscan.hits
POST
|
Fetch hit details (paginated) | 10s |
quickscan.submit — Submit a Screening
Request
{
"data": {
"name": "Vladimir Putin",
"customerType": "Individual",
"providers": ["WORLD_CHECK", "DOW_JONES", "ARTEMISCAN", "INTERNET_SEARCH"],
"description": "AML compliance check",
"providerSettings": {
"artemiscan": { "sensitivity": 0.6 },
"worldCheck": { "matchStrength": "STRONG", "maxHits": 20 },
"dowJones": { "searchType": "Precise", "maxHits": 20 },
"internetSearch": { "resultCount": 20 }
}
}
}
data Fields
| Field | Type | Required | Description |
|---|---|---|---|
name |
String | Yes | Name of the entity to screen |
customerType |
String / Integer | Yes |
"Individual" or "Business" (integers
1 / 2 also accepted)
|
providers |
String[] | Yes | List of screening engines (at least one) |
description |
String | No | Free-text note |
providerSettings |
Object | No | Per-engine parameters (defaults are used when omitted) |
Available Engines (providers)
| Engine | Description | Consumes Credit |
|---|---|---|
WORLD_CHECK |
Refinitiv World-Check sanctions / PEP lists | Yes |
DOW_JONES |
Dow Jones Risk & Compliance lists | Yes |
ARTEMISCAN |
ArtemiScan composite risk screening | Yes |
INTERNET_SEARCH |
Public internet information search | Yes |
WATCHLIST |
Internal watchlist (tenant-defined) | No (free) |
Engine Parameters (providerSettings)
| Engine | Parameter | Default | Options |
|---|---|---|---|
artemiscan |
sensitivity |
0.6 |
0–1 (higher = looser matching) |
worldCheck |
matchStrength |
STRONG |
EXACT / STRONG / WEAK |
worldCheck |
maxHits |
20 |
5 / 10 / 20 / 50 |
dowJones |
searchType |
Precise |
Precise / Near / Broad |
dowJones |
maxHits |
20 |
5 / 10 / 20 / 50 |
internetSearch |
resultCount |
20 |
10 / 20 / 50 |
Response
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"status": "RUNNING"
}
}
| Field | Description |
|---|---|
data.id |
Screening record ID, used for subsequent queries |
data.status |
RUNNING (screening in progress)
|
quickscan.detail — Query Result
Request
{
"data": {
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d"
}
}
Response — in progress
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"status": "RUNNING"
}
}
Response — done, hits ≤ 50 (inlined)
When totalHits ≤ 50, the hits field returns
all hit details inline:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"status": "DONE",
"totalHits": 31,
"truncated": false,
"hitCounts": {
"WORLD_CHECK": 7,
"DOW_JONES": 3,
"ARTEMISCAN": 1,
"INTERNET_SEARCH": 20
},
"hits": {
"WORLD_CHECK": [ { ... }, { ... } ],
"DOW_JONES": [ { ... } ],
"ARTEMISCAN": [ { ... } ],
"INTERNET_SEARCH": [ { ... }, { ... } ]
}
}
}
Response — done, more than 50 hits
When there are more than 50 hits, hits is
null and truncated is true. Use
quickscan.hits to fetch details page by page:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-xxx",
"status": "DONE",
"totalHits": 128,
"truncated": true,
"hitCounts": {
"WORLD_CHECK": 45,
"DOW_JONES": 33,
"ARTEMISCAN": 30,
"INTERNET_SEARCH": 20
},
"hits": null
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
status |
String |
RUNNING / DONE / FAILED
|
totalHits |
Integer | Total hits across all engines |
truncated |
Boolean |
true = too many hits, fetch via
quickscan.hits
|
hitCounts |
Object |
Per-engine hit counts
{ "WORLD_CHECK": 7, ... }
|
hits |
Object / null |
Hit details (returned only when
totalHits ≤ 50)
|
quickscan.hits — Paginate Hit Details
When quickscan.detail returns truncated: true,
use this endpoint to fetch hit details per engine, page by page. It also
works when truncated: false if you want a single engine's
results on demand.
Request
{
"data": {
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"provider": "WORLD_CHECK",
"page": 1,
"pageSize": 20
}
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
String | Yes | Screening record ID |
provider |
String | Yes |
Engine name (WORLD_CHECK / DOW_JONES /
ARTEMISCAN / INTERNET_SEARCH /
WATCHLIST)
|
page |
Integer | No |
Page number (default 1)
|
pageSize |
Integer | No |
Items per page (default 20, max
100)
|
Response
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"id": "qs-b701733c3eca438fb27b68b0ed05ad9d",
"totalHits": 7,
"truncated": true,
"hits": {
"WORLD_CHECK": [
{
"firstName": "Vladimir",
"lastName": "PUTIN",
"category": "PEP",
"subCategory": "PEP - Current",
"addresses": [ { "country": { "code": "RUS", "name": "Russian Federation" } } ],
"dobs": [ { "dateOfBirth": "1952-10-07" } ]
}
]
}
}
}
| Field | Description |
|---|---|
totalHits |
Total hits for this engine |
truncated |
Whether more pages remain (true = there is a next
page)
|
hits |
Hit details for the current page
{ "PROVIDER": [...] }
|
Code Examples
End-to-end example: submit, poll until DONE, then paginate
if truncated. Remember the signing rule —
serialize the request body to a string once, compute the signature on
that string, then send the very same string as the HTTP body.
Python:
import hmac, hashlib, json, os, time, urllib.request, urllib.error
GATEWAY = "https://api-gateway-platform.width.com/gateway"
API_KEY = os.environ["WIDTH_API_KEY"]
API_SECRET = os.environ["WIDTH_API_SECRET"]
DOMAIN_ID = os.environ["WIDTH_DOMAIN_ID"]
def sign(body: str) -> str:
return hmac.new(API_SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
def post(api: str, body: str) -> dict:
url = f"{GATEWAY}?api={api}&apiVersion=1.0.0&apiKey={API_KEY}&sign={sign(body)}"
req = urllib.request.Request(url, data=body.encode(), method='POST',
headers={'Content-Type': 'application/json', 'x-domain': DOMAIN_ID})
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
if result.get("apiCode") != 200 or result.get("data", {}).get("code") != 200:
raise RuntimeError(f"WIDTH request failed: {result}")
return result
# 1. Submit a screening
submit_body = json.dumps({"data": {
"name": "Vladimir Putin",
"customerType": "Individual",
"providers": ["WORLD_CHECK", "DOW_JONES", "ARTEMISCAN"],
"providerSettings": {
"worldCheck": {"matchStrength": "STRONG", "maxHits": 20},
"dowJones": {"searchType": "Precise", "maxHits": 20},
"artemiscan": {"sensitivity": 0.6}
}
}}, separators=(',', ':'))
result = post("quickscan.submit", submit_body)
scan_id = result["data"]["id"]
print(f"Submitted: {scan_id}")
# 2. Poll for the result
while True:
detail_body = json.dumps({"data": {"id": scan_id}}, separators=(',', ':'))
detail = post("quickscan.detail", detail_body)
status = detail["data"]["status"]
print(f"Status: {status}")
if status in ("DONE", "FAILED"):
break
time.sleep(3)
if status == "FAILED":
raise RuntimeError(f"Screening failed: {scan_id}")
# 3. If truncated, paginate
if detail["data"].get("truncated"):
for provider, count in detail["data"]["hitCounts"].items():
page = 1
while True:
hits_body = json.dumps({"data": {
"id": scan_id, "provider": provider, "page": page, "pageSize": 20
}}, separators=(',', ':'))
hits = post("quickscan.hits", hits_body)
items = hits["data"]["hits"][provider]
print(f"{provider} page {page}: {len(items)} hits")
if not hits["data"]["truncated"]:
break
page += 1
else:
# Result already inlined
for provider, items in detail["data"]["hits"].items():
print(f"{provider}: {len(items)} hits")
cURL:
Submit a screening
curl -X POST '{baseUrl}?api=quickscan.submit&apiVersion=1.0.0&apiKey={ak_xxx}&sign={sign}' \
-H 'Content-Type: application/json' \
-H 'x-domain: {domainId}' \
-d '{"data":{"name":"Vladimir Putin","customerType":1,"providers":["WORLD_CHECK","DOW_JONES"]}}'
Query result
curl -X POST '{baseUrl}?api=quickscan.detail&apiVersion=1.0.0&apiKey={ak_xxx}&sign={sign}' \
-H 'Content-Type: application/json' \
-H 'x-domain: {domainId}' \
-d '{"data":{"id":"qs-xxx"}}'
Paginate hits
curl -X POST '{baseUrl}?api=quickscan.hits&apiVersion=1.0.0&apiKey={ak_xxx}&sign={sign}' \
-H 'Content-Type: application/json' \
-H 'x-domain: {domainId}' \
-d '{"data":{"id":"qs-xxx","provider":"WORLD_CHECK","page":1,"pageSize":20}}'
Error Codes
Gateway-layer codes (apiCode)
| apiCode | apiMsg | Cause |
|---|---|---|
200 |
OK |
Request succeeded |
999001 |
SIGN_ERROR |
Signature verification failed |
999301 |
API_INVALID_REQUEST |
Malformed request |
999302 |
API_NOT_FOUND |
Wrong API name |
999304 |
RATE_LIMIT_TRY_AGAIN |
Rate limited |
999305 |
TENANT_NOT_FOUND |
No tenant for this apiKey
|
Business-layer codes (data.code)
| code | Meaning |
|---|---|
200 |
Success |
400 |
Parameter error (missing required field, invalid provider, etc.) |
403 |
Insufficient Credit |
404 |
Screening record not found |
500 |
Internal server error |
FAQ
Q: How long until I get a result?
A: Usually 3–10 seconds. World-Check and Dow Jones typically
return in 2–5 seconds; Internet Search may be slower. Poll
quickscan.detail every 3 seconds.
Q: What does truncated: true mean?
A: When the total hit count exceeds 50,
quickscan.detail does not inline all hit details (to avoid
an oversized response). In that case hits is
null, and you fetch details per engine via
quickscan.hits.
Q: How much Credit does each scan consume?
A: One Credit per engine (WATCHLIST is free). For example,
choosing 3 engines consumes 3 Credits.
Q: What does customerType affect?
A: "Individual" and "Business" determine the
engines' matching strategy. The Business type additionally matches
company name, registration number and similar fields. Integers
1 (Individual) / 2 (Business) are also
accepted.
Q: My signature keeps failing.
A: Make sure the body bytes used for signing are exactly the bytes you send. Build the JSON string once → sign it → send the same string. Do not pretty-print and do not serialize again.
Q: Can I submit multiple screenings at once?
A: Yes. Each quickscan.submit call is independent and
returns its own id. You can submit in parallel and poll
each result independently.
Webhook Integration
Webhooks let the platform call your own server from inside a workflow — the outbound counterpart to the OpenAPI pull model. This section covers configuration, request format, signature verification, retry semantics, and reference implementations.
Concept
A Webhook is a reusable HTTP configuration that workflow nodes can invoke. Two call paths share the same configuration:
| Path | Trigger | Mode | Retry | Captures response |
|---|---|---|---|---|
| Webhook Action node | Reached as a step in a workflow | Sync (workflow waits) | Configurable per node | Yes — outputs parsed by JsonPath |
| Decision Webhook hook |
Optional post-action on a Decision node when status settles to
approve / reject
|
Fire-and-forget async | None | No (failure is audit-logged only) |
Both paths read the same HttpServiceDefinition (URL,
method, headers, signature, etc.) — configure once, use in both.
Configure a Webhook (UI)
Path:
Sidebar → Tools → Webhook → + New Webhook
| Field | Required | Description |
|---|---|---|
| Webhook Name | yes | Display name; referenced in node editors |
| HTTP Method | yes |
POST (default) / PUT /
PATCH / GET / DELETE.
GET and DELETE are sent without a
body
|
| Webhook URL | yes |
Absolute URL. Supports {variable} placeholders
resolved against the input map at runtime, e.g.
https://api.example.com/users/{userId}
|
| Headers | optional |
List of {name, defaultValue}. Values support
{variable} substitution
|
| Inputs | optional | Declared input fields. Become the top-level keys of the JSON request body |
| Outputs | optional |
List of
{paramName, jsonPath, paramType, defaultValue}.
Extracted from the response as the node's outputs (sync path
only)
|
| Signature | optional | See Signature Verification below |
After saving, copy the Webhook ID — workflow node editors reference Webhooks by this ID.
Request Format
Headers
-
Headers configured on the Webhook are sent verbatim (after
{var}substitution). - When signature is enabled, two extra headers are added automatically (see Signature Verification).
-
For body-carrying methods (POST/PUT/PATCH),
Content-Typedefaults toapplication/json; charset=utf-8; override by adding aContent-Typeheader on the Webhook.
Body (POST/PUT/PATCH only)
The resolved inputs map is serialized directly as the JSON body — declared input fields become top-level keys:
POST /receive HTTP/1.1
Content-Type: application/json; charset=utf-8
{
"customerId": "cust-001",
"action": "approve",
"score": 86
}
GET and DELETE are sent without a body.
Placeholder substitution
The Webhook URL and header values support {var}
(single-brace) substitution. The source is the resolved inputs map for
the call.
Signature Verification
The platform signs outbound requests with HMAC so the receiver can authenticate them.
Configuration (per Webhook)
| Field | Values |
|---|---|
type |
NONE (default) / HMAC_SHA256
|
payloadFormat |
TIMESTAMP_BODY (default) / BODY
|
secret |
Shared secret. Encrypted at rest. Cannot be viewed after saving — keep a backup |
signatureHeader |
Header name for the signature. Default
X-Webhook-Signature
|
timestampHeader |
Header name for the timestamp. Default
X-Webhook-Timestamp
|
Signing rules
algorithm = HMAC_SHA256
timestamp = current Unix epoch in milliseconds, as a decimal string
content = if payloadFormat == BODY → body
else (TIMESTAMP_BODY) → timestamp + "\n" + body
signature = lowercase hex( HMAC(secret, content) )
-
bodyis the exact bytes sent on the wire —JSON.stringify(inputs)for body-carrying methods, or the empty string""for GET/DELETE. -
Both
secretandcontentare UTF-8 encoded. -
The platform sends
X-Webhook-Timestamp(or your custom header) alongside the signature so the receiver can recompute it deterministically.
Verification on the receiver side
-
Read the timestamp from
X-Webhook-Timestamp. -
(Recommended) reject if
|now - timestamp| > 5 minutesto prevent replay. - Recompute the signature using the shared secret and the raw request body.
-
Compare in constant time against
X-Webhook-Signature.
Reference: Node.js verifier
The reference verifiers require an ASCII decimal millisecond timestamp and a 64-character lowercase hexadecimal signature, matching the signing rules above. Malformed headers are rejected.
const crypto = require('crypto');
function verify(req, secret) {
const timestamp = req.header('X-Webhook-Timestamp');
const signature = req.header('X-Webhook-Signature');
if (typeof timestamp !== 'string' || typeof signature !== 'string') return false;
if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) return false;
if (!Number.isSafeInteger(Number(timestamp))) return false;
// Replay protection (5-minute window)
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
// payloadFormat = TIMESTAMP_BODY (default)
// Capture req.rawBody before any JSON parser; preserve the exact bytes.
if (!Buffer.isBuffer(req.rawBody)) return false;
const content = Buffer.concat([Buffer.from(`${timestamp}\n`, 'utf8'), req.rawBody]);
const expected = crypto
.createHmac('sha256', secret)
.update(content, 'utf8')
.digest('hex');
// Constant-time comparison
const a = Buffer.from(signature, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Reference: Python verifier
import hmac, hashlib, time, re
def verify(request_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp")
sig = headers.get("X-Webhook-Signature")
if not isinstance(ts, str) or not re.fullmatch(r"[0-9]+", ts):
return False
if not isinstance(sig, str) or not re.fullmatch(r"[0-9a-f]{64}", sig):
return False
try:
timestamp = int(ts)
except ValueError:
return False
if abs(int(time.time() * 1000) - timestamp) > 5 * 60 * 1000:
return False
content = f"{ts}\n".encode() + request_body # payloadFormat = TIMESTAMP_BODY
expected = hmac.new(secret.encode(), content, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
Response Handling (Webhook Action node only)
Only the sync Webhook Action node inspects the response and parses outputs; the Decision hook does not parse responses.
| Outcome | Behavior |
|---|---|
2xx |
Success. Body is parsed against the Webhook's
outputs JsonPath list; matched values become node
outputs
|
4xx |
Failure. Not retried by default (unless listed in
retryOnStatus)
|
5xx |
Failure. Retried up to maxAttempts with exponential
backoff
|
| Network error / timeout |
Failure. Retried up to maxAttempts with exponential
backoff
|
Output parsing
For each output
{paramName, jsonPath, paramType, defaultValue}:
- The response body is parsed as JSON.
- The JsonPath expression is evaluated.
-
The result is type-converted to
paramTypeand exposed as the node outputparamName. -
If JsonPath misses or the body is not JSON,
defaultValueis used (nullif not set).
// Response body
{ "data": { "score": 92, "reason": "OK" } }
| paramName | jsonPath | result |
|---|---|---|
score |
$.data.score |
92 |
reason |
$.data.reason |
"OK" |
Retry semantics
| Field | Default | Notes |
|---|---|---|
maxAttempts |
1 (no retry)
|
Clamped to [1, 5]
|
backoffMs |
500 |
Exponential: delay = backoffMs * 2^(attempt-1)
|
retryOnStatus |
empty |
Extra HTTP status codes (typically 408,
429) to retry beyond the default 5xx +
network/timeout
|
At default backoffMs=500, maxAttempts=5, the
worst-case total backoff is ~7.5 s (0.5s + 1s + 2s + 4s).
Decision Webhook Hook
A Decision node can optionally fire a Webhook after the
decision lands on approve / reject. This path
is fire-and-forget:
- Sent asynchronously; the workflow does not wait for the response.
- No retry. Failures are audit-logged only and never block the workflow.
- The response body is not parsed.
-
Decision metadata (
action/code/reason/tags) is auto-merged into the payload — only if your input fields don't already declare them.
Receiver Best Practices
-
Idempotency — the sync path may re-deliver
on network blips; de-duplicate by
executionId(or your own ID). - Respond fast — the per-attempt HTTP timeout is 10 s by default; queue long work and ACK immediately.
-
Status code semantics —
2xxsuccess;4xxpermanent failure (no retry);5xxtransient (retried). Returning2xxfor a logical failure is treated as success. - Verify the signature on every request — never trust the body until the signature passes.
- Reject stale timestamps — protects against replay.
-
Log the full
X-Webhook-SignatureandX-Webhook-Timestampduring integration to compare.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| 401 / 403 on receiver |
Signature mismatch — check algorithm, payload format, and
secret; verify body is the raw bytes (not
pretty-printed JSON)
|
Receiver sees no body for POST
|
Method is GET / DELETE, or the resolved
inputs map is empty
|
outputs are all null
|
Response is non-JSON, or JsonPath does not match. Adjust JsonPath
in the Webhook editor's Output section
|
| Webhook never fires from Decision node |
Decision did not land on approve /
reject; or triggerOn excludes the
current action; or hook.enabled = false
|
Webhook URL is blank after placeholder resolve
|
A {var} in the URL was not present in inputs.
Declare it in Inputs or ensure an upstream node
provides it
|
| Signature secret error | Secret is blank or decryption failed. Re-save the secret in the Webhook editor (Signature section) |
Quick Reference
-
Signature default headers:
X-Webhook-Signature,X-Webhook-Timestamp(customizable per Webhook) -
Default algorithm:
HMAC_SHA256(when signature is enabled) -
Default payload format:
TIMESTAMP_BODY→timestamp + "\n" + body -
Default retry: disabled
(
maxAttempts=1);backoffMs=500when enabled - Per-attempt HTTP timeout: 10 s