Provider KYC and Account Opening
Complete KYB, RFI answers, and fiat account opening for a business customer through the LEGEND provider. Each step has request and response examples.
This page shows the full flow for a business customer on the LEGEND provider, from KYB to an open fiat account. Each step has a request example and a response example.
- Only
X-Channel-Provider: LEGENDis supported. - The result is the customer's KYC at the provider. It is not the Stabo platform KYC.
- The endpoints pass through to the provider. Stabo runs only the minimum checks needed to build the provider request and keep it safe. LEGEND decides all other business rules. A rejection returns
openapi.channel.rejected.
For parameters and field definitions, see the API reference. For provider differences of fiat accounts (including GLDB), see Payment fiat accounts.
Full flow
| Step | Endpoint or event | Required | Description |
|---|---|---|---|
| 1 | GET /openapi/v1/customers/provider-kyc/requirements | Required | Get countries, options, and file requirements. The dictionaries can change. Get the latest values before you submit. |
| 2 | POST /openapi/v1/customers/provider-kyc | Required | Submit the company data and files. The first submission creates the customer at the provider. |
| 3 | provider.kyc.submitted | Event | The data is under review. |
| 4 | provider.kyc.approved / provider.kyc.rejected / provider.kyc.action_required | Event | The review result. You can also call GET /openapi/v1/customers/provider-kyc. |
| 5 | GET .../provider-kyc/rfis, GET .../rfis/{rfiId} | Conditional | After action_required, find the RFIs waiting for answers. |
| 6 | POST .../rfis/{rfiId}/questions/{questionId}/idv-session | Conditional | For IDV questions only. |
| 7 | POST .../rfis/{rfiId}/answers | Conditional | Submit the answers to the full RFI in one request. |
| 8 | Get providerCustomerId | Required | Read it from the approved event of step 4 or from the KYC status query. You need it to open the account. |
| 9 | GET /openapi/v1/fiat-accounts/opening-options | Optional | Choose the opening route and confirm the fees. |
| 10 | POST /openapi/v1/fiat-accounts | Required | Open the account. |
| 11 | fiat.account.activated | Event | The account can receive funds. |
| 12 | GET /openapi/v1/fiat-accounts/{accountId} | Required | Get the account number and other payment details. The Webhook does not carry them. |
Before you start
Get a token
Get the token with the general flow in Authentication. See that page for the headers, parameters, and examples in other languages.
curl -X POST "https://api.test.stabofx.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-Tenant-ID: your_tenant_id" \
-H "X-Tenant-Type: COMPANY" \
-d "grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret&scope=company:TENANT:admin"Response example:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 31535999
}expires_in is in seconds. The lifetime differs by environment. Use the value in the response.
Request headers
| Header | Description | Required |
|---|---|---|
Authorization | Bearer {accessToken} | Yes |
X-Channel-Provider | Always LEGEND | Yes |
Idempotency-Key | Unique request identifier, at most 128 characters | Required to submit KYB, submit RFI answers, and create a fiat account |
Content-Type | multipart/form-data to submit KYB and RFI answers. application/json to create a fiat account | Required for POST requests |
When you use curl -F, do not set Content-Type yourself. curl adds the boundary.
Subscribe to Webhooks
provider.kyc.* and fiat.account.* events go to the Webhook endpoint that you configure. The Webhook endpoint must be enabled first. Contact the platform to configure it, then subscribe to these events. For signature verification and retries, see Webhooks.
Get requirements
GET /openapi/v1/customers/provider-kyc/requirements
Returns the countries, options, and file requirements for KYB. label follows the request language and is for display only. Submit the code.
customerUidis optional. Without it,optionshas 8 keys.- With
customerUid, a customer that has a profile at the provider also getsquestionnaire.purposes, 9 keys in total. The purpose options are queried for the customer in real time. - A new customer has no profile at the provider yet. With
customerUid, the request still succeeds, but the result has noquestionnaire.purposes. - On the first submission of a new customer,
questionnaire.purposescan use the reference valuesinvest_tradeandcrypto_fiat. This is not a complete list. LEGEND checks the values. - The first call takes about 10 seconds. Later calls hit the cache and are faster. Set the client timeout to at least 60 seconds.
Without customerUid:
curl "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc/requirements" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"With customerUid:
curl "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc/requirements?customerUid=cust_example_001" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example (with customerUid; lists are shortened):
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"countries": [
{ "code": "SG", "label": "Singapore" },
{ "code": "HK", "label": "Hong Kong" }
// ... about 191 items; countries prohibited by the provider are excluded
],
"options": {
"business.sourceOfFunds": [{ "code": "<code>", "label": "<label>" }],
"questionnaire.industry": [{ "code": "<code>", "label": "<label>" }],
"questionnaire.currency": [{ "code": "USD", "label": "<label>" }],
"questionnaire.annualRevenue": [{ "code": "<code>", "label": "<label>" }],
"questionnaire.transactionEstimates[].currency": [{ "code": "USD", "label": "<label>" }],
"questionnaire.transactionEstimates[].expectedMonthlyVolume": [{ "code": "<code>", "label": "<label>" }],
"questionnaire.transactionEstimates[].expectedMonthlyTransactionActivity": [{ "code": "<code>", "label": "<label>" }],
"documents[].category": [{ "code": "utility_bill", "label": "Utility bill" }],
"questionnaire.purposes": [
{ "code": "invest_trade", "label": "<label>" },
{ "code": "crypto_fiat", "label": "<label>" }
// ... 7 items in total; returned only when customerUid is given
]
},
"documents": [
{ "type": "INCORPORATION", "owner": "COMPANY", "required": true, "categoryRequired": false },
{ "type": "BUSINESS_ADDRESS", "owner": "COMPANY", "required": true, "categoryRequired": true },
{
"type": "AML_POLICY",
"owner": "COMPANY",
"required": false,
"requiredWhen": "business.regulatedFinancialInstitution=true",
"categoryRequired": false
},
{ "type": "RESIDENTIAL_ADDRESS", "owner": "REPRESENTATIVE", "required": true, "categoryRequired": true }
// ... 13 items in total
],
"fileConstraints": {
"maxBytes": 2097152,
"allowedContentTypes": ["application/pdf", "image/jpeg", "image/png"]
}
}
}Fields:
| Field | Description |
|---|---|
countries | Countries the provider accepts. Prohibited countries are excluded. Use the code in every country field. |
options | Allowed values grouped by the field path of the submit request. The key is the field path. For example, questionnaire.industry maps to data.questionnaire.industry. |
options["documents[].category"] | Reference values for the file category. Only utility_bill is listed now. It is a value known to be accepted by the provider. It is not a complete list. Other values go to the provider unchanged, and the provider decides. |
documents[] | Requirement for each file type. owner is COMPANY, REPRESENTATIVE, or BENEFICIAL_OWNER. required: true means at least one file. requiredWhen is the condition that makes the file required when required is false. When categoryRequired is true, the file must carry a category. |
fileConstraints | Limits for every file. One file is at most maxBytes (2 MB). Only PDF, JPEG, and PNG are accepted. |
When one person is both the representative and a beneficial owner, the file requirements of both roles apply.
Submit KYB
POST /openapi/v1/customers/provider-kyc
The request is multipart/form-data:
- The
datapart: the company data as JSON. It can be a plain form field or a JSON file part. - File parts: one part per file. The part name equals
data.documents[].clientFileId. - File parts and
clientFileIdvalues must match one to one. A missing, duplicate, or unreferenced file part is rejected.
data example
Values in angle brackets are code values from the options of requirements. In this example, one person is both the representative and the beneficial owner.
Use the values from requirements for questionnaire.purposes. If a new customer does not get them, use the reference values invest_trade and crypto_fiat. LEGEND checks the values.
{
"customerUid": "cust_example_001",
"company": {
"legalName": "Example Co Pte Ltd",
"registrationNumber": "202000000X",
"registrationCountry": "SG",
"incorporationDate": "2020-01-02",
"contactEmail": "ops@example.com",
"telegram": "@example_co",
"phone": { "callingCode": "+65", "number": "80000000" },
"registeredAddress": {
"country": "SG",
"state": "Singapore",
"city": "Singapore",
"postalCode": "000000",
"addressLine": "1 Example Street #01-01"
},
"businessAddress": {
"country": "SG",
"state": "Singapore",
"city": "Singapore",
"postalCode": "000000",
"addressLine": "1 Example Street #01-01"
}
},
"business": {
"bankCountry": "SG",
"regulatedFinancialInstitution": false,
"sourceOfFunds": ["<business.sourceOfFunds code>"],
"sourceOfFundsDescription": "Revenue from software services",
"customerCountries": ["SG", "HK"]
},
"questionnaire": {
"industry": "<questionnaire.industry code>",
"operatingCountries": ["SG"],
"description": "B2B software services",
"currency": "USD",
"annualRevenue": "<questionnaire.annualRevenue code>",
"purposes": ["invest_trade", "crypto_fiat"],
"transactionEstimates": [
{
"currency": "USD",
"expectedMonthlyVolume": "<expectedMonthlyVolume code>",
"expectedMonthlyTransactionActivity": "<expectedMonthlyTransactionActivity code>"
}
],
"anyDealing": false
},
"persons": [
{
"personId": "p1",
"roles": ["REPRESENTATIVE", "BENEFICIAL_OWNER"],
"firstName": "Alex",
"lastName": "Example",
"position": "Director",
"dateOfBirth": "1990-01-02",
"birthCountry": "SG",
"nationalities": ["SG"],
"email": "ops@example.com",
"phone": { "callingCode": "+65", "number": "80000000", "country": "SG" },
"residentialAddress": {
"country": "SG",
"state": "Singapore",
"city": "Singapore",
"postalCode": "000000",
"addressLine": "2 Example Road #02-02"
},
"criticalRole": true,
"politicallyExposed": false,
"ownershipPercentage": 100
}
],
"documents": [
{ "clientFileId": "incorporation", "type": "INCORPORATION" },
{ "clientFileId": "constitution", "type": "CONSTITUTION" },
{ "clientFileId": "ownership-chart", "type": "OWNERSHIP_CHART" },
{ "clientFileId": "business-address", "type": "BUSINESS_ADDRESS", "category": "utility_bill" },
{ "clientFileId": "board-resolution", "type": "BOARD_RESOLUTION" },
{ "clientFileId": "source-of-funds", "type": "SOURCE_OF_FUNDS" },
{ "clientFileId": "p1-id", "type": "IDENTITY_DOCUMENT", "personId": "p1" },
{ "clientFileId": "p1-selfie", "type": "SELFIE", "personId": "p1" },
{ "clientFileId": "p1-address", "type": "RESIDENTIAL_ADDRESS", "personId": "p1", "category": "utility_bill" }
]
}| Object | Description |
|---|---|
customerUid | UID that the caller defines for the customer, at most 128 characters. The first submission creates the customer at the provider. Use it for later queries and RFIs. |
company | Company information. phone, registeredAddress, and businessAddress are required. taxNumber is required when the registration country is US. |
business | Business and banking information. A regulated financial institution (regulatedFinancialInstitution: true) fills in policy and uploads an AML_POLICY file. |
questionnaire | Questionnaire. Currencies in transactionEstimates[].currency must not repeat. |
persons | Exactly one representative and at least one beneficial owner. One person can hold both roles. personId is unique in the request. A beneficial owner needs ownershipPercentage. |
documents | File declarations. clientFileId has only letters, digits, underscores, and hyphens, at most 128 characters. For person files (IDENTITY_DOCUMENT, SELFIE, RESIDENTIAL_ADDRESS), personId must point to a person in persons. Company files have no personId. |
For field lengths and value rules, see the API reference.
Request example
curl -X POST "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND" \
-H "Idempotency-Key: 5f0c7b2e-1d3a-4c8e-9b6f-2a4d6e8f0a1c" \
-F "data=@./kyb.json;type=application/json" \
-F "incorporation=@./incorporation.pdf" \
-F "constitution=@./constitution.pdf" \
-F "ownership-chart=@./ownership-chart.pdf" \
-F "business-address=@./business-address.pdf" \
-F "board-resolution=@./board-resolution.pdf" \
-F "source-of-funds=@./source-of-funds.pdf" \
-F "p1-id=@./p1-id.jpg" \
-F "p1-selfie=@./p1-selfie.jpg" \
-F "p1-address=@./p1-address.pdf"Depending on the number of files, the request can take more than 30 seconds. Set the client timeout to at least 60 seconds. After a timeout, follow Repeat requests and retries.
Response examples
This request submitted the KYB:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"customerUid": "cust_example_001",
"status": "SUBMITTED",
"submittedAt": "2026-11-09T17:36:21.687+08:00"
}
}The provider already accepted a submission (repeat request):
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"customerUid": "cust_example_001",
"providerCustomerId": "usr_xxx",
"status": "APPROVED"
}
}submittedAt is ISO 8601 with a time zone offset (such as +08:00). The number of fraction digits varies. Parse it as ISO 8601. Do not cut it by a fixed pattern.
Repeat requests and retries
- Each submission checks the provider status first. If the provider already accepted a submission (
SUBMITTEDorAPPROVED), the current status is returned. No data is written and nothing is submitted again. The response has nosubmittedAt. - On a timeout or no response, retry with the same data and the original
Idempotency-Key. If the earlier request was submitted, the retry returns the current status. Idempotency-Keyis required. It links the request for audit. The LEGEND KYB write APIs do not accept an idempotency key. The status check above keeps repeat requests safe.- If the provider status does not allow edits,
provider_kyc.not_editableis returned. Get the KYC status first. providerCustomerIdcan be empty while the status isSUBMITTED. It is returned after approval (APPROVED).
Validation scope
Stabo runs only the minimum checks needed to build the provider request and keep it safe:
- Objects and fields needed to build the provider request must not be empty, such as
company.phoneandpersons[].personId. Error codeprovider_kyc.field.required. - If
datais missing, is not valid JSON, or has unknown fields,provider_kyc.data.invalidis returned. - A duplicate
personIdorclientFileIdreturnsprovider_kyc.field.duplicate. A person file that points to an unknown person returnsprovider_kyc.field.invalid. - If file parts and
clientFileIdvalues do not match,provider_kyc.file.missingorprovider_kyc.file.unreferencedis returned. - A file over 2 MB returns
provider_kyc.file.too_large. A file whose header is not PDF, PNG, or JPEG returnsprovider_kyc.file.type_unsupported.
LEGEND decides field formats, value ranges, the number of persons per role, required files, and other rules. A failure returns openapi.channel.rejected.
Get the KYC status
GET /openapi/v1/customers/provider-kyc?customerUid=
curl "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc?customerUid=cust_example_001" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"customerUid": "cust_example_001",
"providerCustomerId": "usr_xxx",
"status": "APPROVED"
}
}| Status | Description | Caller action |
|---|---|---|
PENDING | The provider has a profile. The data is not submitted or is still being filled in | Submit KYB |
SUBMITTED | Submitted. The provider is reviewing it | Wait for the Webhook |
APPROVED | Approved. The account can be opened | Open the account with providerCustomerId |
REJECTED | Rejected. See failureReason | Act on the reason |
ACTION_REQUIRED | The provider needs more information or a review | Answer the RFIs, or resubmit KYB as the provider requires |
providerCustomerIdis the customer identifier assigned by the provider. It can be empty whileSUBMITTED. It is returned afterAPPROVED. You need it to open and query accounts.failureReasonis returned only whenREJECTED.
KYC Webhooks
These events are sent when the provider KYC status changes. PENDING is not sent.
| Event | Status |
|---|---|
provider.kyc.submitted | SUBMITTED |
provider.kyc.approved | APPROVED |
provider.kyc.rejected | REJECTED |
provider.kyc.action_required | ACTION_REQUIRED |
data.object has the same fields as data of Get the KYC status. When the provider resends a callback, the same status can arrive more than once. Deduplicate by customerUid + status.
provider.kyc.submitted:
{
"id": "evt_xxx",
"object": "event",
"type": "provider.kyc.submitted",
"created": 1762680981,
"data": {
"object": {
"customerUid": "cust_example_001",
"status": "SUBMITTED"
}
}
}provider.kyc.approved:
{
"id": "evt_xxx",
"object": "event",
"type": "provider.kyc.approved",
"created": 1762681981,
"data": {
"object": {
"status": "APPROVED",
"customerUid": "cust_example_001",
"providerCustomerId": "usr_xxx"
}
}
}provider.kyc.rejected:
{
"id": "evt_xxx",
"object": "event",
"type": "provider.kyc.rejected",
"created": 1762681981,
"data": {
"object": {
"customerUid": "cust_example_001",
"status": "REJECTED",
"failureReason": "<reason from the channel>"
}
}
}provider.kyc.action_required:
{
"id": "evt_xxx",
"object": "event",
"type": "provider.kyc.action_required",
"created": 1762681981,
"data": {
"object": {
"customerUid": "cust_example_001",
"providerCustomerId": "usr_xxx",
"status": "ACTION_REQUIRED"
}
}
}providerCustomerId appears only after the provider assigns it.
RFI answers
An RFI (Request for Information) is a request from the provider for more information. RFIs have no Webhook of their own. After provider.kyc.action_required, list the ACTIVE RFIs. Only an RFI in ACTION_REQUIRED can be answered.
| RFI status | Description |
|---|---|
ACTION_REQUIRED | Waiting for answers |
UNDER_REVIEW | Answers accepted. The provider is reviewing them. Acceptance does not lift restrictions |
APPROVED | Approved |
REJECTED | Rejected |
EXPIRED | Expired without answers |
CANCELLED | Cancelled by the provider |
List RFIs
GET /openapi/v1/customers/provider-kyc/rfis?customerUid=&status=
status is ACTIVE (waiting for answers only) or ALL (including finished ones). The default is ALL. The list does not include questions.
curl "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc/rfis?customerUid=cust_example_001&status=ACTIVE" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": [
{
"rfiId": "rfc_xxx",
"customerUid": "cust_example_001",
"status": "ACTION_REQUIRED",
"restrictions": ["swap", "fiat_withdrawal"],
"expiresAt": "2026-11-20T00:00:00+08:00",
"updatedAt": "2026-11-10T10:00:00+08:00"
}
]
}restrictions are the limits the provider applies until the RFI is resolved.
Get an RFI
GET /openapi/v1/customers/provider-kyc/rfis/{rfiId}?customerUid=
curl "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc/rfis/rfc_xxx?customerUid=cust_example_001" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example (one question of each of the five types):
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"rfiId": "rfc_xxx",
"customerUid": "cust_example_001",
"status": "ACTION_REQUIRED",
"restrictions": ["swap", "fiat_withdrawal"],
"expiresAt": "2026-11-20T00:00:00+08:00",
"updatedAt": "2026-11-10T10:00:00+08:00",
"questions": [
{
"questionId": "rfq_text",
"replyType": "TEXT",
"questionText": "Describe the main business of the company.",
"required": true,
"maxLength": 500
},
{
"questionId": "rfq_doc",
"replyType": "DOC",
"questionText": "Upload the latest bank statement.",
"required": true,
"fileRequired": true,
"maxFiles": 3
},
{
"questionId": "rfq_text_doc",
"replyType": "TEXT_DOC",
"questionText": "Explain the source of the last large deposit and upload proof.",
"required": true,
"fileRequired": true,
"maxLength": 500,
"maxFiles": 2,
"rejectionReason": "The document is not readable."
},
{
"questionId": "rfq_checkbox",
"replyType": "CHECKBOX",
"questionText": "Select the expected payment purposes.",
"options": ["Supplier payments", "Payroll", "Others"],
"required": true
},
{
"questionId": "rfq_idv",
"replyType": "IDV",
"questionText": "Complete identity verification for the director.",
"required": true,
"idvStarted": false
}
]
}
}| Type | Answer fields |
|---|---|
TEXT | text, at most maxLength |
DOC | clientFileIds, at most maxFiles |
TEXT_DOC | text and clientFileIds |
CHECKBOX | checkboxSelections, values exactly as in options. When Others is selected, fill in othersDetails |
IDV | Create an IDV session and complete the verification first. Then submit only questionId |
rejectionReason appears only after the previous answer was rejected.
Create an IDV session
POST /openapi/v1/customers/provider-kyc/rfis/{rfiId}/questions/{questionId}/idv-session?customerUid=
Only for an IDV question of an RFI in ACTION_REQUIRED. Otherwise provider_kyc.rfi.not_actionable or provider_kyc.rfi.question_not_idv is returned.
curl -X POST "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc/rfis/rfc_xxx/questions/rfq_idv/idv-session?customerUid=cust_example_001" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"url": "https://idv.example.com/session/xxx"
}
}url is a one-time session link. Give it only to the person who completes the verification, and open it on that person's device. Do not log or forward it. After the verification, still submit this question with the full RFI.
Submit RFI answers
POST /openapi/v1/customers/provider-kyc/rfis/{rfiId}/answers
The request is multipart/form-data:
- The
datapart: the answers as JSON. Submit the full RFI at once, one answer per required question. - File parts: the part name equals a value in
answers[].clientFileIds.clientFileIdsmust not repeat across questions. Idempotency-Keyis required. Reuse the original key when you retry the same submission.
data example (one answer per type):
{
"customerUid": "cust_example_001",
"answers": [
{
"questionId": "rfq_text",
"text": "B2B software services for retail merchants."
},
{
"questionId": "rfq_doc",
"clientFileIds": ["bank-statement"]
},
{
"questionId": "rfq_text_doc",
"text": "Payment from a customer for an annual contract.",
"clientFileIds": ["contract", "invoice"]
},
{
"questionId": "rfq_checkbox",
"checkboxSelections": ["Supplier payments", "Others"],
"othersDetails": "Refunds to customers"
},
{
"questionId": "rfq_idv"
}
]
}Request example:
curl -X POST "https://api.test.stabofx.com/openapi/v1/customers/provider-kyc/rfis/rfc_xxx/answers" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND" \
-H "Idempotency-Key: 0b8e4c1a-6f2d-4e3b-a5c7-9d1e2f3a4b5c" \
-F "data=@./rfi-answers.json;type=application/json" \
-F "bank-statement=@./bank-statement.pdf" \
-F "contract=@./contract.pdf" \
-F "invoice=@./invoice.pdf"This request submitted the answers:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"rfiId": "rfc_xxx",
"status": "UNDER_REVIEW",
"submittedAt": "2026-11-10T10:00:00+08:00"
}
}The RFI is already under review (repeat request). Nothing is written and there is no submittedAt:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"rfiId": "rfc_xxx",
"status": "UNDER_REVIEW"
}
}- Acceptance is not approval and does not lift
restrictions. Use the KYC status and the RFI status for the result. - Stabo checks only that required fields are not empty, the safe characters of
clientFileId, the match between file parts and references, and file size and type. LEGEND decides question ownership, required questions, length, file count, and selected values. A failure returnsopenapi.channel.rejected. - If the RFI is neither
ACTION_REQUIREDnorUNDER_REVIEW,provider_kyc.rfi.not_actionableis returned.
List opening options
GET /openapi/v1/fiat-accounts/opening-options
After KYC is APPROVED, query with providerCustomerId. This step is optional. Use it to choose the opening route or confirm the fees in advance.
curl "https://api.test.stabofx.com/openapi/v1/fiat-accounts/opening-options?providerCustomerId=usr_xxx¤cy=SGD&bankCountry=SG" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"options": [
{
"openingOptionId": "brt_xxx",
"currency": "SGD",
"bankCountry": "SG",
"partnerBanks": [
{ "bankCode": "DBS", "bankName": "DBS Bank" },
{ "bankCode": "SCB", "bankName": "Standard Chartered Bank" }
],
"transferMethods": {
"deposit": ["fast"],
"payout": ["fast", "swift"]
},
"feeSchedule": {
"accountOpenFee": "10.00",
"monthlyFee": "5.00",
"depositFee": { "rate": "0.001", "flat": "0.50", "min": "1.00", "max": "50.00" },
"payoutFee": { "rate": "0.001", "flat": "0.50", "min": "1.00" }
},
"nameMatchRequired": true
}
]
}
}currency and bankCountry are both optional. Without both, all options of the customer are returned.
| Field | Description |
|---|---|
openingOptionId | Send it back when you create the account to choose the route. |
partnerBanks | Candidate banks. The account detail shows the bank actually used, which can differ from the candidates. For example, in the test environment the option listed DBS and SCB, and the account was opened at Xfers (BANK_CODE is FAZZ). |
transferMethods | Deposit and payout methods, as the provider names them. |
feeSchedule | Fees as strings in provider precision. A missing field does not mean the fee is 0. |
nameMatchRequired | When true, the name of the payer of a deposit must match the account holder name. |
Create a fiat account
POST /openapi/v1/fiat-accounts
LEGEND uses application/json. providerCustomerId and Idempotency-Key are required. Without openingOptionId, the provider chooses the route. alias is optional. It has only letters, digits, spaces, and hyphens, at most 50 characters.
The request can be slow. Set the client timeout to at least 60 seconds.
curl -X POST "https://api.test.stabofx.com/openapi/v1/fiat-accounts" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND" \
-H "Idempotency-Key: 9c2d4e6f-8a0b-4c1d-b3e5-7f9a1b3c5d7e" \
-H "Content-Type: application/json" \
-d '{
"providerCustomerId": "usr_xxx",
"currency": "SGD",
"bankCountry": "SG",
"alias": "Main SGD Account",
"openingOptionId": "brt_xxx"
}'Response example (opened synchronously):
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"accountId": "bnk_xxx",
"providerCustomerId": "usr_xxx",
"currency": "SGD",
"status": "ACTIVE",
"alias": "Main SGD Account",
"bankCountry": "SG",
"bankName": "Xfers Pte Ltd",
"accountHolderName": "Example Co",
"accountNumber": "0000000000",
"routingCodes": [{ "type": "BANK_CODE", "value": "FAZZ" }],
"openedAt": "2026-11-09T10:06:32Z"
}
}- If
PENDINGis returned, wait for the Webhook or poll the account detail. - If
fiat.account.channel.detail_unavailableis returned, the account was created. Query with the account ID in the message. Do not open the account again. accountHolderNamecomes fromcompany.legalNamein the KYB.- When
nameMatchRequiredistrue, the name of the payer of a deposit must match the account holder name. openedAtis in UTC and ends withZ. Parse it as ISO 8601.- On a timeout or
openapi.channel.unavailable, retry with the originalIdempotency-Key. If it still fails after a few retries, stop retrying. Contact support with thetraceId, or choose another opening option. - Unknown fields in the request body are rejected. GLDB-only fields return
fiat.account.field.unsupported.
Fiat account Webhooks
These events are sent when the account reaches a terminal status. PENDING is not sent.
| Event | Status |
|---|---|
fiat.account.activated | ACTIVE |
fiat.account.failed | FAILED |
fiat.account.closed | CLOSED |
The Webhook carries only a few fields. It has no account number or bank details. After you receive it, call Retrieve an account to get the payment details. When the provider resends a callback, the same status can arrive more than once. Deduplicate by accountId + status.
fiat.account.activated:
{
"id": "evt_xxx",
"object": "event",
"type": "fiat.account.activated",
"created": 1762682792,
"data": {
"object": {
"status": "ACTIVE",
"currency": "SGD",
"accountId": "bnk_xxx",
"routingCodes": [],
"providerCustomerId": "usr_xxx"
}
}
}fiat.account.failed:
{
"id": "evt_xxx",
"object": "event",
"type": "fiat.account.failed",
"created": 1762682792,
"data": {
"object": {
"status": "FAILED",
"currency": "SGD",
"accountId": "bnk_xxx",
"routingCodes": [],
"providerCustomerId": "usr_xxx"
}
}
}fiat.account.closed:
{
"id": "evt_xxx",
"object": "event",
"type": "fiat.account.closed",
"created": 1762682792,
"data": {
"object": {
"status": "CLOSED",
"currency": "SGD",
"accountId": "bnk_xxx",
"routingCodes": [],
"providerCustomerId": "usr_xxx"
}
}
}providerCustomerId can be missing.
Retrieve an account
GET /openapi/v1/fiat-accounts/{accountId}?providerCustomerId=
curl "https://api.test.stabofx.com/openapi/v1/fiat-accounts/bnk_xxx?providerCustomerId=usr_xxx" \
-H "Authorization: Bearer {accessToken}" \
-H "X-Channel-Provider: LEGEND"Response example:
{
"code": 200,
"message": "OK",
"traceId": "trace-uuid-string",
"data": {
"accountId": "bnk_xxx",
"providerCustomerId": "usr_xxx",
"currency": "SGD",
"status": "ACTIVE",
"alias": "Main SGD Account",
"bankCountry": "SG",
"bankName": "Xfers Pte Ltd",
"accountHolderName": "Example Co",
"accountNumber": "0000000000",
"routingCodes": [{ "type": "BANK_CODE", "value": "FAZZ" }],
"openedAt": "2026-11-09T10:06:32Z"
}
}ACTIVE includes at least accountNumber or iban. Give bankName, accountNumber, routingCodes, and the other details to the payer.
Error responses
Format
A business error returns HTTP 400. The response body code is always 500, and data.errorCode is the error code. Use data.errorCode in program logic. message is for display and troubleshooting only. Decide whether to retry by errorCode only, not by the HTTP status code.
{
"code": 500,
"message": "The channel rejected the request",
"traceId": "trace-uuid-string",
"data": {
"errorCode": "openapi.channel.rejected"
}
}Parameter errors
A missing header, query parameter, or multipart part returns openapi.param.missing. message contains only the parameter name.
{
"code": 500,
"message": "Required parameter X-Channel-Provider is missing",
"traceId": "trace-uuid-string",
"data": {
"errorCode": "openapi.param.missing"
}
}A parameter of the wrong type (such as status=FOO) returns error.invalid.param.
Provider unavailable
Returned on a provider timeout, rate limit, or network failure. Retry later. Create requests must reuse the original Idempotency-Key.
- Decide whether to retry by
errorCodeonly, not by the HTTP status code. This error currently returns HTTP 400. - If it still fails after a few retries with the original key, stop retrying. Contact support with the
traceId, or choose another opening option.
{
"code": 500,
"message": "The channel is temporarily unavailable. Retry later; for create requests, reuse the original Idempotency-Key",
"traceId": "trace-uuid-string",
"data": {
"errorCode": "openapi.channel.unavailable"
}
}Provider rejection
Returned when the provider rejects the request for certain. Do not retry it unchanged. Correct the data under the provider rules, then submit again.
{
"code": 500,
"message": "The channel rejected the request",
"traceId": "trace-uuid-string",
"data": {
"errorCode": "openapi.channel.rejected"
}
}Error codes
| errorCode | Description | Action |
|---|---|---|
openapi.channel.unsupported | The provider does not support this endpoint | Check X-Channel-Provider |
openapi.param.missing | A required parameter is missing | Add the parameter |
error.invalid.param | A parameter type or enum value is invalid | Correct the parameter |
error.idempotent.key.missing | Idempotency-Key is missing | Add the header |
error.idempotent.key.too.long | Idempotency-Key is longer than 128 characters | Shorten it |
openapi.customer_uid.required | customerUid is empty | Fill it in |
openapi.provider_customer_id.required | providerCustomerId is empty | Fill it in |
provider_kyc.data.invalid | data is missing, is not valid JSON, or has unknown fields | Correct data |
provider_kyc.field.required / provider_kyc.field.invalid / provider_kyc.field.too_long / provider_kyc.field.duplicate | A field is empty, invalid, too long, or duplicated. message has the field path | Correct the field |
provider_kyc.file.missing / provider_kyc.file.unreferenced | A file part is missing or extra | Match file parts and clientFileId values one to one |
provider_kyc.file.too_large / provider_kyc.file.type_unsupported | A file is over 2 MB, or is not PDF, PNG, or JPEG | Use another file |
provider_kyc.account_type_unsupported | The customerUid is not a business account at the provider | Use a new customerUid |
provider_kyc.not_editable | The provider does not allow edits now | Get the KYC status first |
provider_kyc.rfi.id_required | The RFI ID is empty | Fill it in |
provider_kyc.rfi.not_actionable | The RFI is not waiting for answers | Get the RFI status first |
provider_kyc.rfi.question_not_idv | The question is not an IDV question of this RFI | Check questionId |
fiat.account.field.unsupported / fiat.account.field.required | A field the provider does not support was sent, or a required field is missing | Correct it for the provider |
fiat.account.opening_option_id.mismatch | openingOptionId does not match the currency or bank country | List the opening options again |
fiat.account.channel.detail_unavailable | The account was created, but its detail is not available yet | Query with the account ID in the message. Do not open the account again |
openapi.channel.rejected | The provider rejected the request | Correct it and submit again |
openapi.channel.unavailable | The provider is temporarily unavailable | Retry later with the original Idempotency-Key. If it still fails after a few retries, stop, and contact support with the traceId or choose another opening option |
openapi.channel.invalid_response / openapi.channel.unknown_status | The provider response is invalid or its status is unknown | Contact support with the traceId |
Known limitations
- In the test environment, opening a USD account (bank country
BH, candidate bank SGB) for a newly approved business keeps returningopenapi.channel.unavailable. The cause is under investigation. Until then, you can open an SGD account (bank countrySG) first. If you get this error, contact support.