Skip to main content

Integration guides

Complete references for hosted verification links, name screening, Quick Scan and workflow webhooks.

Examples use placeholder credentials and sample data. Replace placeholders before sending. Comments and ... mark illustrative shapes, not literal JSON to submit. Quick Scan calls use apiVersion=1.0.0.

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.
Only one link is live per email address at a time. Calling this endpoint again for the same 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": {}
  }
}
This endpoint serves identity verification only. Transaction and Web3 event types have no hosted web flow to link to — submit those through workflow.apply.submit.

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

Two ways to screen a name, for two different jobs. This endpoint keeps a record: the subject becomes a customer profile, hits become alerts, and an analyst’s decision is remembered and reused the next time the same 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.

Screening consumes quota from the third-party name screening provider, so treat a submission as something that costs money: de-duplicate before you call, and if a call times out, look up how far the previous submission got with workflow.apply.query instead of resending blind. How the quota is actually drawn down is whatever the Admin Console → Billing usage page reports (see Usage & Quota) — read it there rather than working it out from your own assumptions, and before you integrate at volume put a small trial batch through and compare that page before and after.

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.

Quick Scan uses the same gateway base URL, the same query-string request envelope (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

  1. quickscan.submit — submit a screening; returns an id immediately (asynchronous)
  2. quickscan.detail — poll for status; on DONE it returns the hit summary
  3. 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

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) )

Verification on the receiver side

  1. Read the timestamp from X-Webhook-Timestamp.
  2. (Recommended) reject if |now - timestamp| > 5 minutes to prevent replay.
  3. Recompute the signature using the shared secret and the raw request body.
  4. 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}:

// 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:

Need response-based branching? Use a Webhook Action node instead.

Receiver Best Practices

  1. Idempotency — the sync path may re-deliver on network blips; de-duplicate by executionId (or your own ID).
  2. Respond fast — the per-attempt HTTP timeout is 10 s by default; queue long work and ACK immediately.
  3. Status code semantics — 2xx success; 4xx permanent failure (no retry); 5xx transient (retried). Returning 2xx for a logical failure is treated as success.
  4. Verify the signature on every request — never trust the body until the signature passes.
  5. Reject stale timestamps — protects against replay.
  6. Log the full X-Webhook-Signature and X-Webhook-Timestamp during 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