OpenAPI · REST reference
REST request envelopes, authentication, workflow lifecycle, error handling, configuration and file upload.
Getting Started
Base URL
| Environment | Gateway URL |
|---|---|
| Staging | https://api-gateway-platform.width.com/gateway |
| Production | https://api-gateway.width.com/gateway |
Request Format
POST {base_url}?api={apiName}&apiVersion={apiVersion}&apiKey={apiKey}&sign={sign}
Content-Type: application/json
x-domain: {domainId}
{JSON body}
| Parameter | Location | Required | Description |
|---|---|---|---|
api |
Query | Yes | API name |
apiVersion |
Query | Yes |
Varies per interface —
workflow.apply.submit recommends 2.0.0,
others use 1.0.0 (see
Workflow Lifecycle API)
|
apiKey |
Query | Yes | Your API key |
sign |
Query | Yes | HMAC-SHA256 signature of request body |
x-domain |
Header | Yes | Your business domain ID |
Submit a Workflow
Endpoint:
workflow.apply.submit
POST
Routes to the currently published (PUBLISHED) workflow by
eventType + source and starts an execution.
apiVersion
| apiVersion | Behaviour |
|---|---|
1.0.0 |
Auto-routes by eventType + source
|
2.0.0 |
Everything in 1.0.0 plus an optional workflowId to
name the published workflow explicitly; an empty
workflowId behaves exactly like 1.0.0.
Recommended.
|
Request
{
"eventType": "KYC_EVENT",
"source": "API",
"data": {
"customerId": "demo-customer-001",
"fullName": "ZHANG SAN",
"dateOfBirth": "1990-01-01",
"nationality": "CN"
},
"workflowId": ""
}
| Field | Type | Required | Description |
|---|---|---|---|
eventType |
string | Yes |
Event type: KYC_EVENT / KYB_EVENT /
TRANSACTION_EVENT /
WEB3_TRANSACTION_EVENT /
MANUAL_CASE_EVENT, subject to the events enabled for
your tenant. The Console labels the first two
KYC and KYB — those are
display names; the API expects KYC_EVENT and
KYB_EVENT. MANUAL_CASE_EVENT submits a
subject for name screening; it
appears as Manual in the Console.
|
source |
string | Yes |
Origin. Server-side integrations always send
API.
|
data |
object | Yes |
Business payload. customerId is required and must
stay stable for the same customer (workflow.apply.query
relies on it for recovery); the remaining fields follow the field
definitions of the workflow’s entry event.
|
workflowId |
string | No | 2.0.0 only. Names a published workflow; empty means auto-route by event. |
Response
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"message": null,
"executionId": "exec-7208f03b931e4897",
"status": "EXECUTING",
"requestId": "...",
"sync": false
}
}
| Field | Type | Description |
|---|---|---|
executionId |
string |
Execution instance ID. Persist it — you
poll workflow.execution.status with it.
|
status |
string |
Initial status (EXECUTING /
SUBMITTED).
|
sync |
boolean | Whether the execution ran synchronously; in sync mode the output carries the decision directly. |
code |
int |
200 = OK; 403 = quota exhausted
(billing blocked).
|
submit is the only billed
endpoint. A data.code of 403 means the
eventType’s quota is exhausted; do not retry blindly — ask
an admin to top up or adjust the quota.
Where to find the Workflow ID: in WIDTH One Platform, open Workflow. Copy the Workflow ID shown beside the workflow name in the listing and pass it as workflowId when using apiVersion 2.0.0.
Response Format
Success:
{
"apiCode": 200,
"apiMsg": "OK",
"data": {}
}Error:
{
"apiCode": 999001,
"apiMsg": "SIGN_ERROR",
"data": null
}
4xx/5xx means the
gateway did not accept the request (no data). On HTTP
200, read apiCode for the gateway result
(200 = OK). Some business endpoints then wrap their
result in a second { code, data, msg } layer under
data — so the business payload is at
data.data (see the Register Customer example).
Read the response in order: HTTP status, then apiCode, then data.code and data.data where the endpoint uses a business envelope. apiCode 200 alone does not prove business success; use the endpoint-specific business result.
Error Codes
| apiCode | apiMsg | Description | Action |
|---|---|---|---|
| 200 | OK | Success | — |
| 999000 | GATEWAY_ERROR | General gateway error | Contact support |
| 999001 | SIGN_ERROR | Signature verification failed | See Authentication |
| 999301 | API_INVALID_REQUEST | Malformed or invalid request | Check request body |
| 999302 | API_NOT_FOUND | API name not found |
Verify api parameter
|
| 999303 | API_NON_AUTHORIZED | Not authorized for this API | Check apiKey permissions |
| 999304 | RATE_LIMIT_TRY_AGAIN | Rate limit exceeded | Wait and retry |
| 999401 | SERVICE_TIME_OUT | Backend service timeout | Retry with backoff |
| 999402 | SERVICE_INVOKE_ERROR | Backend invocation failed | Retry; contact support if persistent |
| 999403 | SERVICE_NOT_FOUND | Backend service unavailable | Contact support |
| 999404 | SERVICE_RESPONSE_FORMAT_ERROR | Unexpected response from backend | Contact support |
| 999501 | REQUEST_API_EXCEED_LIMIT | API call quota exceeded | Contact support to increase limits |
Authentication
Signature Computation
sign = HmacSHA256(apiSecret, requestBody)
Both key and body use UTF-8 encoding. The result is a 64-character lowercase hex string. The signature must be computed on the exact bytes sent as the HTTP body.
Code Examples
Java:
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes("UTF-8"), "HmacSHA256"));
byte[] hash = mac.doFinal(requestBody.getBytes("UTF-8"));
String sign = HexFormat.of().formatHex(hash); // Java 17+
Python:
import hmac, hashlib
sign = hmac.new(
api_secret.encode('utf-8'),
request_body.encode('utf-8'),
hashlib.sha256
).hexdigest()
Node.js:
const crypto = require('crypto');
const sign = crypto.createHmac('sha256', apiSecret)
.update(requestBody, 'utf8').digest('hex');
Idempotency
All node data submission APIs are idempotent based on
callbackKey. Submitting the same
callbackKey multiple times is safe — only the first
successful submission takes effect. This allows safe retries on network
timeouts.
Interaction Model
-
Client-Side (App/Web): Chain-Driven Flow.
Your frontend is driven by the
nextStepobject returned in each submission response. No global polling required. Only poll dedicated.dataendpoints for async tasks like OCR. -
Server-Side (Tenant Backend): Final Status Polling.
After the last user-input node, poll
workflow.execution.statusat relaxed intervals (1–5 min) until terminal state.
Workflow Lifecycle API
The Workflow Lifecycle API is the pure server-side integration path: your backend submits an event, polls for the execution status and the Case decision, and — when a request is lost or retried — queries whether the customer already has an execution in flight. Three endpoints form one closed loop.
This differs from the node-driven SDK path documented under KYC / KYB, where the client submits data node by node. Here the platform runs the entire published workflow end to end and you only submit and poll.
Flow Overview
- Submit
- Poll Status
- Case Decision
workflow.apply.query sits before this loop as a recovery
path: call it when a submit may have already happened
(timeout or upstream retry) to avoid submitting twice.
Billing
| API | Purpose | Billed |
|---|---|---|
workflow.apply.submit |
Start a workflow execution | Yes — charged per eventType |
workflow.execution.status |
Read execution status + Case decision by executionId | No |
workflow.apply.query |
Check whether a customer already has an execution | No |
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 (Staging
api-gateway-platform.width.com/gateway / Production
api-gateway.width.com/gateway) and request format, and
Authentication for
the full signing rules.
Status & Case Decision
Endpoint:
workflow.execution.status
POST
Read-only — it does not trigger execution and is not billed.
executionId is checked against your tenant (and
x-domain); it cannot be queried across tenants. Use
apiVersion 1.0.0.
Request
{ "executionId": "exec-7208f03b931e4897" }
| Field | Type | Required | Description |
|---|---|---|---|
executionId |
string | Yes |
The execution instance ID returned by
workflow.apply.submit.
|
Response
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"executionId": "exec-7208f03b931e4897",
"status": "COMPLETED",
"caseNo": "C001443",
"caseStatus": "REJECTED",
"riskScore": 45.0,
"resolvedAt": 1784714818000
}
}
| Field | Type | Description |
|---|---|---|
status |
string |
Execution status: RUNNING / WAITING /
COMPLETED / FAILED /
cancellation value: pending confirmation.
|
caseNo |
string |
Associated Case number (C%06d); empty until a Case
is generated.
|
caseStatus |
string |
Case handling status: UNASSIGNED (queued, not yet
assigned) / UNDER_REVIEW (being handled by a
reviewer) / RETURNED (sent back for rework) /
OTHER_GROUP (this group is done; the flow continues
in another group/node) / APPROVED /
REJECTED / CLOSED (finished, flow
ended); empty when there is no Case.
Polling exit conditions key off this field.
|
riskScore |
number | Risk score (0–100); empty if none. |
resolvedAt |
long | Resolution time (epoch millis); empty until resolved. |
currentNode etc.
|
object |
SDK flow-control internals (currentNode /
output / pendingCallbackTask); ignore
them for server-side integration.
|
data string means the
executionId does not exist, or belongs to a different
tenant/domain than the current apiKey.
Status Semantics
status |
caseStatus |
Meaning |
|---|---|---|
RUNNING / WAITING |
empty | Executing (including waiting on data collection); no Case yet. |
RUNNING / WAITING |
UNASSIGNED / UNDER_REVIEW /
RETURNED
|
Stopped at a review node awaiting manual handling — keep polling. |
RUNNING / WAITING |
OTHER_GROUP |
The current Case’s group is done; the flow continues in another group/node — keep polling. |
COMPLETED / cancellation value: pending confirmation |
APPROVED / REJECTED / CLOSED
|
Decision reached, flow finished. |
FAILED |
— |
Execution failed; contact support with the
executionId.
|
Response by Stage
Stage 1 — automatic nodes running, no Case yet (the brief window before the decision node):
{ "apiCode": 200, "apiMsg": "OK",
"data": { "executionId": "exec-xxxx", "status": "RUNNING" } }
No caseStatus field → no Case generated yet, keep
polling.
Stage 2 — Case generated, awaiting manual handling:
{ "apiCode": 200, "apiMsg": "OK",
"data": { "executionId": "exec-xxxx", "status": "RUNNING",
"caseNo": "C001286", "caseStatus": "UNASSIGNED", "riskScore": 49.17 } }
While being handled manually, caseStatus reflects progress:
UNASSIGNED (queued) → UNDER_REVIEW (after
a reviewer picks it up); RETURNED when sent back for
rework.
Stage 3 — decision reached (manual review or automatic decision):
{ "apiCode": 200, "apiMsg": "OK",
"data": { "executionId": "exec-xxxx", "status": "COMPLETED",
"caseNo": "C001443", "caseStatus": "REJECTED", "riskScore": 45.0,
"resolvedAt": 1784714818000 } }
caseStatus is OTHER_GROUP — keep
polling until caseStatus reaches a terminal state (APPROVED
/ REJECTED / CLOSED).
Polling Exit Conditions
Every execution generates a Case once it reaches the decision node
(manual review → UNASSIGNED /
UNDER_REVIEW; automatic pass/reject/close → a terminal
Case directly), so:
caseStatus in {APPROVED, REJECTED, CLOSED}
OR status == FAILED
OR status == CONFIRMED_CANCELLATION_ENUMcaseStatus ∈
{UNASSIGNED, UNDER_REVIEW,
RETURNED}) you can back off to 30–60 seconds.
Recovery Query (Dedup & Resume)
Endpoint:
workflow.apply.query
POST
Looks up whether a customer already has an execution by
eventType + source + customerId. Read-only — it
starts no new execution and is not billed. Use
apiVersion 1.0.0.
-
Resume after a break — if
submittimed out or the process crashed before you got anexecutionId: query first; on a hit, recover theexecutionIdand resume polling, avoiding a duplicate (billed) submit. -
Deduplicate submits — when an upstream retry
may re-fire for the same customer: skip
submitif the query returnsRUNNING/APPROVED.
Request
{
"eventType": "KYC_EVENT",
"source": "API",
"customerId": "demo-customer-001"
}
| Field | Type | Required | Description |
|---|---|---|---|
eventType |
string | Yes | Same as at submit time. |
source |
string | Yes | Same as at submit time. |
customerId |
string | Yes |
Same as data.customerId at submit time (must stay
stable for the same customer).
|
Response
{
"apiCode": 200,
"apiMsg": "OK",
"data": {
"code": 200,
"executionId": "exec-7208f03b931e4897",
"status": "RUNNING"
}
}
| Match | executionId |
status |
Suggested action |
|---|---|---|---|
| Customer already approved | most recent COMPLETED execution | APPROVED |
Do not submit again. |
| An execution is in flight | that execution | RUNNING |
Switch to polling workflow.execution.status.
|
| No match (rejected or no record) | empty | — |
Call submit normally.
|
customerId must exactly match the
data.customerId used at submit time and stay stable for
the same customer over time; otherwise the recovery query will never
find a match.
Error Codes
| Layer | Code | Meaning | Action |
|---|---|---|---|
Gateway apiCode
|
200 |
Success (check data.code for the business
result)
|
— |
Gateway apiCode
|
999302 |
API name error |
Check api / apiVersion
|
Gateway apiCode
|
999305 |
apiKey invalid or disabled
|
Verify the API Key on the API & Integration page |
Gateway apiCode
|
999001 |
Signature error |
Check the sign computation (sign the raw body, no
extra newlines)
|
Gateway apiCode
|
999401 |
Service timeout | Retry later; contact support if it persists |
data.code |
400 |
Missing parameter | Check the request body |
data.code |
403 |
Quota exhausted (submit only) | Ask an admin to top up / adjust the quota |
empty data
|
— | Execution does not exist or belongs to another tenant/domain (status only) |
Verify executionId against your
apiKey / x-domain; always send the
required x-domain header
|
Code Example
curl (full loop)
API_KEY="ak_xxx"
API_SECRET="sk_xxx"
DOMAIN_ID="{domainId}"
BASE_URL="https://api-gateway-platform.width.com/gateway"
# HMAC-SHA256 sign helper / 签名助手
sign() { printf "%s" "$1" | openssl dgst -sha256 -mac HMAC -macopt key:"$API_SECRET" | sed 's/^.* //'; }
# 0. (optional) recovery query — dedup before submit / 恢复查询防重
BODY='{"eventType":"KYC_EVENT","source":"API","customerId":"demo-customer-001"}'
curl -s -X POST "${BASE_URL}?api=workflow.apply.query&apiVersion=1.0.0&apiKey=${API_KEY}&sign=$(sign "$BODY")" \
-H "Content-Type: application/json" -H "x-domain: ${DOMAIN_ID}" -d "$BODY"
# 1. submit / 发起工作流
BODY='{"eventType":"KYC_EVENT","source":"API","data":{"customerId":"demo-customer-001","fullName":"ZHANG SAN"},"workflowId":""}'
curl -s -X POST "${BASE_URL}?api=workflow.apply.submit&apiVersion=2.0.0&apiKey=${API_KEY}&sign=$(sign "$BODY")" \
-H "Content-Type: application/json" -H "x-domain: ${DOMAIN_ID}" -d "$BODY"
# -> record data.executionId / 记下 data.executionId
# 2. poll status / 轮询执行状态
BODY='{"executionId":"exec-7208f03b931e4897"}'
curl -s -X POST "${BASE_URL}?api=workflow.execution.status&apiVersion=1.0.0&apiKey=${API_KEY}&sign=$(sign "$BODY")" \
-H "Content-Type: application/json" -H "x-domain: ${DOMAIN_ID}" -d "$BODY"
Python (polling loop)
import hashlib, hmac, json, os, time, requests
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"]
# Supply only the cancellation enum verified by WIDTH engineering.
# Do not guess between the conflicting spellings in the reference.
CANCELLED_STATUS = os.environ["WIDTH_CONFIRMED_CANCEL_STATUS"]
def call(api, version, payload):
body = json.dumps(payload, separators=(",", ":"))
signature = hmac.new(API_SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
response = requests.post(GATEWAY,
params={"api": api, "apiVersion": version, "apiKey": API_KEY, "sign": signature},
headers={"Content-Type": "application/json", "x-domain": DOMAIN_ID},
data=body, timeout=30)
response.raise_for_status()
result = response.json()
if result.get("apiCode") != 200:
raise RuntimeError(result)
data = result.get("data")
if isinstance(data, dict) and data.get("code", 200) != 200:
raise RuntimeError(data)
return data
identity = {"eventType": "KYC_EVENT", "source": "API", "customerId": "demo-customer-001"}
recovered = call("workflow.apply.query", "1.0.0", identity)
if recovered and recovered.get("status") == "APPROVED":
print(recovered)
else:
if recovered and recovered.get("status") == "RUNNING":
execution_id = recovered["executionId"]
else:
submitted = call("workflow.apply.submit", "2.0.0", {
"eventType": identity["eventType"], "source": identity["source"],
"data": {"customerId": identity["customerId"], "fullName": "ZHANG SAN"},
"workflowId": ""})
execution_id = submitted["executionId"]
# Persist execution_id before polling; recover after an interrupted submit.
while True:
state = call("workflow.execution.status", "1.0.0", {"executionId": execution_id})
if not isinstance(state, dict):
raise RuntimeError("Verify executionId, apiKey and x-domain; no execution returned")
if state.get("caseStatus") in ("APPROVED", "REJECTED", "CLOSED") or state.get("status") in ("FAILED", CANCELLED_STATUS):
print(state)
break
time.sleep(30 if state.get("caseStatus") in ("UNASSIGNED", "UNDER_REVIEW", "RETURNED") else 5)
FAQ
Do not retry
submit directly (you’d be billed
twice). Call workflow.apply.query first: on a
RUNNING / APPROVED hit, recover the
executionId and resume polling; only re-submit if there
is no match.
data)?Confirm you are using the same
apiKey and
x-domain as at submit time — the endpoint checks
ownership by tenant and domain, and a cross-domain query returns empty
data.
Not necessarily. While stopped at a review node awaiting manual handling, the execution status stays non-terminal and
caseStatus is UNASSIGNED /
UNDER_REVIEW / RETURNED. Use
caseStatus to track handling progress.
Configuration
Retrieve platform configuration data (country lists, document types, form templates, etc.) before starting a workflow.
Endpoint:
prepare.avira.config
POST
{
"keys": "particular,avira_ssic,avira_ssoc,avira_countries,avira_document_master,avira_document_default_rule,required-form,avira_phone_code,avira_entity_types,ownership_structure_layer,transaction_behaviour,product_service_complexity,source_of_wealth,avira_source_of_funds"
}
Configuration Keys
| Key | Description | Used By |
|---|---|---|
particular |
Personal/corporate information field definitions | Particular nodes (KYC & KYB) |
required-form |
Form templates with validation rules | Required Forms nodes |
avira_countries |
Country list (ISO codes + names) | Nationality, residence, etc. |
avira_document_master |
Supported document types per country | OCR & document upload |
avira_document_default_rule |
Default document requirements | Required document nodes |
avira_ssic |
Industry classification codes | Industry field |
avira_ssoc |
Occupation classification codes | Occupation field |
avira_phone_code |
Country phone codes | Phone verification |
avira_entity_types |
Corporate entity types | KYB particular |
ownership_structure_layer |
Ownership structure depth settings | KYB workflows |
transaction_behaviour |
Transaction behaviour categories | KYB forms |
product_service_complexity |
Product/service complexity categories | KYB forms |
source_of_wealth |
Source of wealth options | KYC/KYB forms |
avira_source_of_funds |
Source of funds options | KYC/KYB forms |
File Upload
For any API requiring S3 URIs (documents, photos), upload files first.
Step 1 — Get Upload Token
Endpoint:
storage.upload.token
POST
Request body: {}. Token is valid for
15 minutes.
Response:
{
"apiCode": 200,
"data": {
"uploadToken": "a1b2c3d4e5f6...",
"expiresIn": 900
}
}
Step 2 — Upload File
Storage Service URL:
| Environment | URL |
|---|---|
| Staging | https://api-gateway-platform.width.com/storage |
| Production | https://api-gateway.width.com/storage |
Multipart upload:
curl -X POST "${STORAGE_URL}/api/v1/upload/file" \
-F "uploadToken=a1b2c3d4e5f6..." \
-F "file=@passport_front.jpg"
Base64 upload:
curl -X POST "${STORAGE_URL}/api/v1/upload" \
-H "Content-Type: application/json" \
-d '{
"uploadToken": "a1b2c3d4e5f6...",
"image": "data:image/jpeg;base64,/9j/4AAQ...",
"filename": "passport_front.jpg"
}'
Response:
{
"apiCode": 200,
"data": {
"url": "s3://workflow-kyc/tenantImages/2026-04-16/abc123.jpg",
"filename": "passport_front.jpg",
"content_type": "image/jpeg",
"size": 245760
}
}
Supported types: PNG, JPG, JPEG, GIF, WEBP, TIFF, PDF — Max size: 20 MB
Error handling & retry strategy
| Scenario | Action |
|---|---|
| SIGN_ERROR / 999001 | Fix the signature; do not retry unchanged. |
| 999304 | Wait and retry after rate limiting. |
| HTTP 500 / 999401 | Retry up to three times with exponential backoff (1 s, 2 s, 4 s); recover a potentially accepted submit with workflow.apply.query first. |
| data.code = 403 | Ask an administrator to replenish or adjust the event quota; do not retry blindly. |
| Upload token HTTP 401 | Obtain a new upload token and retry the upload once. |
| OTP verification | Resend the OTP if the maximum attempt count is exhausted. |
| Node submission timeout | Retry the same callbackKey for node-data submissions; do not generalise this guarantee to workflow.apply.submit. |