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: LEGEND is 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

图表加载中…
StepEndpoint or eventRequiredDescription
1GET /openapi/v1/customers/provider-kyc/requirementsRequiredGet countries, options, and file requirements. The dictionaries can change. Get the latest values before you submit.
2POST /openapi/v1/customers/provider-kycRequiredSubmit the company data and files. The first submission creates the customer at the provider.
3provider.kyc.submittedEventThe data is under review.
4provider.kyc.approved / provider.kyc.rejected / provider.kyc.action_requiredEventThe review result. You can also call GET /openapi/v1/customers/provider-kyc.
5GET .../provider-kyc/rfis, GET .../rfis/{rfiId}ConditionalAfter action_required, find the RFIs waiting for answers.
6POST .../rfis/{rfiId}/questions/{questionId}/idv-sessionConditionalFor IDV questions only.
7POST .../rfis/{rfiId}/answersConditionalSubmit the answers to the full RFI in one request.
8Get providerCustomerIdRequiredRead it from the approved event of step 4 or from the KYC status query. You need it to open the account.
9GET /openapi/v1/fiat-accounts/opening-optionsOptionalChoose the opening route and confirm the fees.
10POST /openapi/v1/fiat-accountsRequiredOpen the account.
11fiat.account.activatedEventThe account can receive funds.
12GET /openapi/v1/fiat-accounts/{accountId}RequiredGet 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

HeaderDescriptionRequired
AuthorizationBearer {accessToken}Yes
X-Channel-ProviderAlways LEGENDYes
Idempotency-KeyUnique request identifier, at most 128 charactersRequired to submit KYB, submit RFI answers, and create a fiat account
Content-Typemultipart/form-data to submit KYB and RFI answers. application/json to create a fiat accountRequired 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.

  • customerUid is optional. Without it, options has 8 keys.
  • With customerUid, a customer that has a profile at the provider also gets questionnaire.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 no questionnaire.purposes.
  • On the first submission of a new customer, questionnaire.purposes can use the reference values invest_trade and crypto_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:

FieldDescription
countriesCountries the provider accepts. Prohibited countries are excluded. Use the code in every country field.
optionsAllowed 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.
fileConstraintsLimits 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 data part: 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 clientFileId values 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" }
  ]
}
ObjectDescription
customerUidUID 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.
companyCompany information. phone, registeredAddress, and businessAddress are required. taxNumber is required when the registration country is US.
businessBusiness and banking information. A regulated financial institution (regulatedFinancialInstitution: true) fills in policy and uploads an AML_POLICY file.
questionnaireQuestionnaire. Currencies in transactionEstimates[].currency must not repeat.
personsExactly 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.
documentsFile 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 (SUBMITTED or APPROVED), the current status is returned. No data is written and nothing is submitted again. The response has no submittedAt.
  • 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-Key is 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_editable is returned. Get the KYC status first.
  • providerCustomerId can be empty while the status is SUBMITTED. 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.phone and persons[].personId. Error code provider_kyc.field.required.
  • If data is missing, is not valid JSON, or has unknown fields, provider_kyc.data.invalid is returned.
  • A duplicate personId or clientFileId returns provider_kyc.field.duplicate. A person file that points to an unknown person returns provider_kyc.field.invalid.
  • If file parts and clientFileId values do not match, provider_kyc.file.missing or provider_kyc.file.unreferenced is returned.
  • A file over 2 MB returns provider_kyc.file.too_large. A file whose header is not PDF, PNG, or JPEG returns provider_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"
  }
}
StatusDescriptionCaller action
PENDINGThe provider has a profile. The data is not submitted or is still being filled inSubmit KYB
SUBMITTEDSubmitted. The provider is reviewing itWait for the Webhook
APPROVEDApproved. The account can be openedOpen the account with providerCustomerId
REJECTEDRejected. See failureReasonAct on the reason
ACTION_REQUIREDThe provider needs more information or a reviewAnswer the RFIs, or resubmit KYB as the provider requires
  • providerCustomerId is the customer identifier assigned by the provider. It can be empty while SUBMITTED. It is returned after APPROVED. You need it to open and query accounts.
  • failureReason is returned only when REJECTED.

KYC Webhooks

These events are sent when the provider KYC status changes. PENDING is not sent.

EventStatus
provider.kyc.submittedSUBMITTED
provider.kyc.approvedAPPROVED
provider.kyc.rejectedREJECTED
provider.kyc.action_requiredACTION_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 statusDescription
ACTION_REQUIREDWaiting for answers
UNDER_REVIEWAnswers accepted. The provider is reviewing them. Acceptance does not lift restrictions
APPROVEDApproved
REJECTEDRejected
EXPIREDExpired without answers
CANCELLEDCancelled 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
      }
    ]
  }
}
TypeAnswer fields
TEXTtext, at most maxLength
DOCclientFileIds, at most maxFiles
TEXT_DOCtext and clientFileIds
CHECKBOXcheckboxSelections, values exactly as in options. When Others is selected, fill in othersDetails
IDVCreate 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 data part: 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. clientFileIds must not repeat across questions.
  • Idempotency-Key is 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 returns openapi.channel.rejected.
  • If the RFI is neither ACTION_REQUIRED nor UNDER_REVIEW, provider_kyc.rfi.not_actionable is 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&currency=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.

FieldDescription
openingOptionIdSend it back when you create the account to choose the route.
partnerBanksCandidate 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).
transferMethodsDeposit and payout methods, as the provider names them.
feeScheduleFees as strings in provider precision. A missing field does not mean the fee is 0.
nameMatchRequiredWhen 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 PENDING is returned, wait for the Webhook or poll the account detail.
  • If fiat.account.channel.detail_unavailable is returned, the account was created. Query with the account ID in the message. Do not open the account again.
  • accountHolderName comes from company.legalName in the KYB.
  • When nameMatchRequired is true, the name of the payer of a deposit must match the account holder name.
  • openedAt is in UTC and ends with Z. Parse it as ISO 8601.
  • On a timeout or openapi.channel.unavailable, retry with the original Idempotency-Key. If it still fails after a few retries, stop retrying. Contact support with the traceId, 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.

EventStatus
fiat.account.activatedACTIVE
fiat.account.failedFAILED
fiat.account.closedCLOSED

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 errorCode only, 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

errorCodeDescriptionAction
openapi.channel.unsupportedThe provider does not support this endpointCheck X-Channel-Provider
openapi.param.missingA required parameter is missingAdd the parameter
error.invalid.paramA parameter type or enum value is invalidCorrect the parameter
error.idempotent.key.missingIdempotency-Key is missingAdd the header
error.idempotent.key.too.longIdempotency-Key is longer than 128 charactersShorten it
openapi.customer_uid.requiredcustomerUid is emptyFill it in
openapi.provider_customer_id.requiredproviderCustomerId is emptyFill it in
provider_kyc.data.invaliddata is missing, is not valid JSON, or has unknown fieldsCorrect data
provider_kyc.field.required / provider_kyc.field.invalid / provider_kyc.field.too_long / provider_kyc.field.duplicateA field is empty, invalid, too long, or duplicated. message has the field pathCorrect the field
provider_kyc.file.missing / provider_kyc.file.unreferencedA file part is missing or extraMatch file parts and clientFileId values one to one
provider_kyc.file.too_large / provider_kyc.file.type_unsupportedA file is over 2 MB, or is not PDF, PNG, or JPEGUse another file
provider_kyc.account_type_unsupportedThe customerUid is not a business account at the providerUse a new customerUid
provider_kyc.not_editableThe provider does not allow edits nowGet the KYC status first
provider_kyc.rfi.id_requiredThe RFI ID is emptyFill it in
provider_kyc.rfi.not_actionableThe RFI is not waiting for answersGet the RFI status first
provider_kyc.rfi.question_not_idvThe question is not an IDV question of this RFICheck questionId
fiat.account.field.unsupported / fiat.account.field.requiredA field the provider does not support was sent, or a required field is missingCorrect it for the provider
fiat.account.opening_option_id.mismatchopeningOptionId does not match the currency or bank countryList the opening options again
fiat.account.channel.detail_unavailableThe account was created, but its detail is not available yetQuery with the account ID in the message. Do not open the account again
openapi.channel.rejectedThe provider rejected the requestCorrect it and submit again
openapi.channel.unavailableThe provider is temporarily unavailableRetry 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_statusThe provider response is invalid or its status is unknownContact 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 returning openapi.channel.unavailable. The cause is under investigation. Until then, you can open an SGD account (bank country SG) first. If you get this error, contact support.

On this page