Migrating From Organization Verifications to Compliance Reviews
This guide is for integrations currently using POST /organizations/{organizationId}/verification to onboard organizations for KYC (individual) or KYB (business). The new Compliance Reviews API replaces it with a smaller, composable surface that better reflects how reviews are actually evaluated. The legacy verification endpoint will continue to work during the migration window, but every new integration should target Compliance Reviews — and existing integrations should migrate at their next planned change.
The behavior of the system has not changed; only the API shape has. The same data you submit today still drives the same compliance decision.
What changes
Four things are different. The rest of this guide walks each one in detail.
| # | Old (verification) | New (compliance review) |
|---|---|---|
| 1 | One large request body containing every piece of KYC/KYB data. | Several small attestation uploads, one per logical group of fields. |
| 2 | Documents passed inline as base64-encoded documentContent data URIs. | Documents uploaded to S3 via a presigned URL, then referenced by documentId. |
| 3 | Validation errors all surface at submission time as a single 400. | Per-attestation validation: each upload can be flagged or rejected independently. |
| 4 | Submit is a side effect of POST /verification. | Submit is an explicit POST .../submit call that can reject blocking failures or require you to acknowledge warnings. |
The rest of the API — organizations, accounts, payouts — is unchanged. Only the onboarding submission path is migrating.
The Mural docs already cover the new flow end-to-end. Read Individual Compliance Review or Business Compliance Review before starting, then come back to this guide for the field-by-field mapping.
Change 1: One request → many attestations
The old endpoint took the entire KYC/KYB profile in a single body. The new flow breaks that body into discrete attestation types, each uploaded independently via PUT /api/compliance/{organizationId}/reviews/{reviewId}/attestations. Re-uploading the same type replaces the prior value, which is how you correct mistakes prior to submission.
Conceptually, one call becomes a short sequence:
Before — one request:
POST /organizations/{id}/verification
{
"type": "business",
"businessIdentificationInfo": { … },
"businessDetails": { … },
"financialDetails": { … },
"formationDocuments": [ … ],
"ultimateBeneficialOwners": [ … ],
"controlPerson": { … }
}After — a short sequence of small requests:
POST /api/compliance/{id}/reviews ← create draft review
PUT /api/compliance/{id}/reviews/{reviewId}/attestations ← N times, one per attestation type, or once with all attestation types
POST /api/compliance/{id}/associated-persons ← per UBO / control person
PUT /api/compliance/{id}/associated-persons/{personId}/attestations ← per UBO / control person
POST /api/compliance/{id}/reviews/{reviewId}/submit ← explicit submitBusiness mapping (KYB)
The old KYCBusinessVerificationRequest body maps to the new attestation types like this. Each row is an upload to PUT /reviews/{reviewId}/attestations.
Old field (under KYCBusinessVerificationRequest) | New attestation type | New field |
|---|---|---|
businessIdentificationInfo.businessName | businessIdentification | businessName |
businessIdentificationInfo.registrationNumber | businessIdentification | registrationNumber |
businessIdentificationInfo.dbaName | businessIdentification | dbaName |
businessIdentificationInfo.taxIdentificationNumber | businessTaxInfo | taxIdentificationNumber, taxIdentificationNumberType |
businessIdentificationInfo.businessAddress | businessAddress | registeredAddress (+ optional operatingAddress) |
businessIdentificationInfo.businessPhoneNumber | businessContactInfo | phoneNumber |
businessIdentificationInfo.businessEmail | businessContactInfo | email |
businessDetails.businessWebsite | businessDetails | businessWebsite |
businessDetails.legalStructure | businessDetails | legalStructure |
businessDetails.dateOfIncorporation | businessDetails | dateOfIncorporation |
businessDetails.industry | businessDetails | industry |
businessDetails.businessDescription | businessDetails | businessDescription |
businessDetails.usageOfMural | businessDetails | intendedMuralUsage |
businessDetails.transmittingCustomerFunds | businessOperations | transmittingCustomerFunds |
businessDetails.customerFundsTransmissionDetails | businessOperations | customerFundsTransmissionDetails |
financialDetails.highRiskBusinessActivities | businessOperations | highRiskBusinessActivities |
| (new) | businessOperations | primaryTargetMarket, expectedCounterpartyCountries |
financialDetails.primaryIncomeSourceCategory | businessFinancialInfo | primaryIncomeSourceCategory |
financialDetails.primaryIncomeSourceDescription | businessFinancialInfo | primaryIncomeSourceDescription |
financialDetails.annualRevenue | businessFinancialInfo | annualRevenue |
financialDetails.estimatedMonthlyVolume | businessFinancialInfo | estimatedMonthlyVolume |
formationDocuments[] | businessFormationDocuments | formationDocuments[] (each: documentType + documentId) |
uboOwnershipDocuments[] (international only) | businessUboOwnershipDocuments | uboOwnershipDocuments[] (each: documentType + documentId) |
businessProofOfAddress[] (international only) | businessProofOfAddressDocuments | proofOfAddressDocuments[] (each: documentType + documentId) |
flowOfFundsDoc (conditional: transmittingCustomerFunds=true) | businessFlowOfFundsDocument | document (documentType + documentId) |
controlPerson + ultimateBeneficialOwners[] | businessAssociatedPersons | associatedPersonIds[] (see below) |
The two
businessOperationsfieldsprimaryTargetMarketandexpectedCounterpartyCountriesare new and required forFULL. Plan to collect them before migrating — they are not derivable from the old body.
Associated persons (UBOs and control person)
In the old API, controlPerson and each entry in ultimateBeneficialOwners[] were full nested KYC objects embedded in the business request. In the new API, each person is a first-class resource you create before attaching them to the review:
POST /api/compliance/{organizationId}/associated-personsto create the person and get back anid. Userelationships: ["CONTROL_PERSON"],["BENEFICIAL_OWNER"], or both for a person filling both roles.PUT /api/compliance/{organizationId}/associated-persons/{associatedPersonId}/attestationsto upload that person's attestations —associatedPersonPersonalInfo,associatedPersonContactInfo,associatedPersonResidentialAddress,associatedPersonTaxInfo,associatedPersonIdentityDocumentfor both tiers, plusassociatedPersonProofOfAddressforFULL.- Add the returned
ids to the review'sbusinessAssociatedPersonsattestation underassociatedPersonIds[].
The full field-level mapping for an associated person's attestations mirrors the individual KYC mapping below — the only difference is the route they are uploaded to.
Individual mapping (KYC)
The old KYCIndividualOrganizationVerificationRequest body maps as follows. Both tiers (light and standard) use these same attestation types — standard requires all of them, light collapses the first three into a single individualPersonalInfoLight attestation.
| Old field | New attestation type (standard) | New field |
|---|---|---|
individualPersonalInfo.name | individualPersonalInfo | name |
individualPersonalInfo.birthDate | individualPersonalInfo | birthDate |
individualPersonalInfo.nationality | individualPersonalInfo | nationality |
isPoliticallyExposed | individualPersonalInfo | isPoliticallyExposed |
individualPersonalInfo.individualEmail | individualContactInfo | email |
individualPersonalInfo.individualPhoneNumber | individualContactInfo | phoneNumber |
individualPersonalInfo.individualResidentialAddress | individualResidentialAddress | address |
individualPersonalInfo.taxIdNumber | individualTaxInfo | taxIdentificationNumber, taxIdentificationNumberType |
individualIdentityInfo.governmentId | individualIdentityDocument | issuingCountry, governmentIdNumber, governmentId (discriminated by type, with documentId) |
individualFinancialInformation.sourceOfFunds | individualFinancialInfo | sourceOfFunds |
individualFinancialInformation.currentEmploymentStatus | individualFinancialInfo | employmentStatus |
individualFinancialInformation.primaryAccountPurpose | individualFinancialInfo | primaryAccountPurpose |
individualFinancialInformation.latestOccupation | individualFinancialInfo | occupation |
individualFinancialInformation.monthlyUsdVolume | individualFinancialInfo | monthlyUsdVolume |
individualFinancialInformation.isTransactingOnBehalfOfOthers | individualFinancialInfo | isTransactingOnBehalfOfOthers |
individualFinancialInformation.transactionCurrencies[] | individualFinancialInfo | transactionCurrencies[] |
individualFinancialInformation.sourceOfFundsDocument | individualSourceOfFunds (conditional) | sourceOfFundsDocuments[] (each: documentType + documentId) |
individualProofOfAddress (conditional) | individualProofOfAddress (conditional) | proofOfAddressDocuments[] (each: documentType + documentId) |
For light tier, the name, birthDate, nationality (optional), phoneNumber, and residentialAddress fields combine into a single individualPersonalInfoLight attestation — that is the entire payload required to onboard.
Change 2: Document URLs → upload endpoints
The old endpoint accepted documents inline as base64 data URIs in documentContent. The new flow uploads documents to S3 first and then references them by documentId:
POST /api/documents/generate-upload-urlwith thefilenameand MIMEcontentType. The response includes adocumentId, a presigned S3uploadUrl, and a set of signed form fields.POSTthe file directly to S3 asmultipart/form-data. Send every entry fromuploadUrlFormFieldsas a form field, and appendfilelast — S3 ignores anything after the file part.- Pass the returned
documentIdinto the attestation that references it (e.g. insidebusinessFormationDocuments.formationDocuments[], or insideindividualIdentityDocument.governmentId).
The full flow — including the JavaScript reference implementation, the 15-minute presigned URL expiry, the 10 MB file size cap, and the allowed content types — is documented in Document Uploads.
This is the single biggest payload change. The old endpoint's largest field by volume was always documentContent; the new payloads are small JSON bodies with UUID references.
Change 3: Per-attestation validation failures
In the legacy flow, validation issues only surfaced at submit time as a single 400. In the new flow, each attestation is validated as it lands, and the review carries a per-attestation validationStatus you can inspect at any time via GET /api/compliance/{organizationId}/reviews/{reviewId}.
A validation failure on an attestation has two fields you need to read:
failureCode— a stable enum identifying the failure (for example,INCORRECT_DOCUMENT).severity— eitherBLOCKINGorWARNING.
The difference matters at submit time (see Change 4):
BLOCKINGfailures cannot be acknowledged. You must re-upload the affected attestation with corrected data before submission will succeed.WARNINGfailures can be cleared by re-uploading, or acknowledged at submit time.
Re-uploading the same attestation type replaces the prior version and re-runs validation. Continue iterating until every attestation either passes or carries only WARNING-severity failures you intend to acknowledge.
Change 4: Submit is explicit and can fail
The old endpoint conflated "save my data" and "submit for review" — every successful POST /verification immediately handed the request off to compliance. The new flow separates them: attestation uploads are independent of submission, and you submit explicitly with POST /api/compliance/{organizationId}/reviews/{reviewId}/submit.
Submission can fail in two ways:
422 — MissingAttestationsException
MissingAttestationsExceptionReturned when one or more required attestations have not yet been uploaded. The response body lists exactly which attestationTypes are still required and which have already been uploaded — on the review itself or on any associated person. The fix is to upload the missing attestations and re-submit.
412 — AttestationValidationsRejectedException
AttestationValidationsRejectedExceptionReturned when one or more uploaded attestations carry validation failures. The response body identifies the offending attestations along with their failures[] (failureCode + severity).
There are two paths forward:
-
Re-upload to clear the failures. This is the only path for
BLOCKINGfailures and is also valid forWARNINGfailures. -
Acknowledge the warnings. If every remaining failure has
severity: "WARNING"and you accept the risk, retry submission withacknowledgeWarnings: truein the request body:curl --request POST \ --url https://api.muralpay.com/api/compliance/{organizationId}/reviews/{reviewId}/submit \ --header 'accept: application/json' \ --header 'authorization: Bearer $API-KEY' \ --header 'content-type: application/json' \ --data '{ "acknowledgeWarnings": true }'The flag is scoped to that single submit attempt. If new
WARNINGs appear after a subsequent re-upload, you will need to acknowledge them again.
BLOCKING failures are never acknowledgeable — passing acknowledgeWarnings: true while at least one BLOCKING failure remains still returns 412.
On success the review transitions from draft to inReview and is evaluated asynchronously. Poll GET /api/compliance/{organizationId}/reviews/{reviewId} for the decision.
Suggested migration sequence
A low-risk path for an existing integration:
- Implement document uploads against
POST /api/documents/generate-upload-url. This is the largest behavioral change and is safe to ship in isolation — old verifications continue to work while you build it. - Build the new submission flow in a feature branch or behind a flag, mirroring the existing onboarding code path. Use the field mapping tables above to decompose your current payload into attestations.
- Wire the new validation/warning UX (Changes 3 and 4) — the failure shapes are new and your client will need to surface them to end users.
- Cut new organizations over to the compliance review flow first; leave in-flight verifications on the legacy endpoint until they reach a terminal status.
- Sunset the legacy code path once your traffic on
POST /organizations/{id}/verificationhas drained.
If you are starting fresh, skip the legacy endpoint entirely and follow the Individual Compliance Review or Business Compliance Review guides directly.
Updated about 7 hours ago