Ownership discovery
A KYB step can discover a company's beneficial owners automatically, instead of asking the applicant to declare them. When it does, Zyphe buys the ownership graph from official business registers as soon as the applicant submits their business information, traverses it to natural persons, and materializes the discovered owners and directors onto the KYB result, where they are screened like any declared person.
The conceptual model — how the graph is traversed, how effective ownership is multiplied along a chain, and what happens when no natural person crosses the 25% threshold — is described in Ownership structure and recursive UBO discovery. This page is how you turn it on and operate it.
Turning it on
Discovery is configured on the anchor KYB step:
| Field | Type | Default | Meaning |
|---|---|---|---|
discoveryEnabled | boolean | false | Dispatch ownership discovery when business information is submitted. |
discoveryBudgetCredits | integer | — | Per-verification cap on discovery spend, in credits. Omit for the platform default. |
discoveryEnabled and discoveryBudgetCredits used to live on the KYB_UBOS satellite's
configuration. They now live on the anchor, because discovery is dispatched by the anchor's
business-information submission and the opt-in has to sit on the step that triggers it. Existing
flows were migrated in place — there is nothing to do unless you assemble flow configuration
yourself, in which case move the two keys.
The default is false. A traversal spends your credits, so a flow that never opted in never
dispatches one.
Beyond the step flag, ownership discovery has to be switched on for your deployment and for the country being verified. Ask Zyphe support to confirm coverage before you rely on it in production, and keep the declaration fallback in mind for the countries it does not reach.
What the applicant sees
Enabling discovery is the decision to let it stand in for the declaration steps. It is a single flag, not a pair of independent switches.
With discoveryEnabled on, the applicant submits business information and then never walks the
KYB_UBOS or KYB_DIRECTORS steps at all. Those satellites stay hidden. Discovered people are
written onto the result and their KYC invitations and screenings proceed exactly as they would
for declared people.
The applicant's session can read the discovery's progress and result:
GET /flow/{flow_id}/vr/{verification_request_id}/kyb/discovery
It returns status — NONE when no traversal has been dispatched, otherwise PENDING,
COMPLETED, or FAILED for the base run — plus ownershipStructure, uboSuggestions, and
optional stoppedReason and errorMessage. It deliberately does not return the expansion
history: per-node spend is your organization's business, not the applicant's. Read that from the
organization-scoped endpoint below.
Discovered owners arrive without an email address, so they are screened for PEP and sanctions by name. They are not invited into a KYC flow.
When discovery cannot finish
Registers do not cover every jurisdiction, and a chain can dead-end at an entity nothing authoritative reports. When discovery fails or produces nothing usable, Zyphe falls back to asking the applicant — it raises a declaration document request that reopens the hidden satellite for a fresh declaration:
| Request type | Reopens |
|---|---|
UBO_DECLARATION | KYB_UBOS |
DIRECTOR_DECLARATION | KYB_DIRECTORS |
A declaration request is raised automatically when the traversal fails, stops early, produces no
natural-person owner (or, for directors, no director), or is still unfinished after the fallback
window. The applicant receives an email with a deep link back into the reopened step, and answers
it by submitting that part normally — the request flips to UPLOADED on submission.
While one is outstanding, the KYB cannot be approved — not by an agent, not by the automatic completion path, and not by a human reviewer.
Each part is asked at most once, ever. If the applicant's declaration is unsatisfactory, take it forward as an ordinary clarification or document request rather than expecting a second automatic reopen. A reviewer can also raise one manually:
POST /organizations/{organization_id}/kyb/{kyb_result_id}/document-requests
{ "requests": [{ "requestType": "UBO_DECLARATION", "description": "Please declare the beneficial owners." }] }
Raising a declaration request on a step that does not have discoveryEnabled returns a 400
— that flow already collects declarations directly, so there is nothing to reopen.
requestType on document requestsEvery item in a document-request creation payload carries a requestType. It now has four
values: STANDARD, CLARIFICATION, and the two new UBO_DECLARATION and
DIRECTOR_DECLARATION.
Enrichment: the ENRICHING status
Between submission and review, a KYB result sits in ENRICHING while Zyphe buys the
authoritative registry record the business-information cross-check is graded against.
ENRICHING is an API read-only status:
- It appears when you read a KYB result through the API or see it in the dashboard.
- Webhooks report it as
PENDING_MODERATION, so existing integrations are unaffected and no webhook consumer needs to learn a new value. - No endpoint accepts it as a target status. Approve, moderate, and manual-review all reject a result that is still in it.
- A timeout sweep guarantees nothing stays in it indefinitely.
The full status vocabulary is in Statuses and codes.
Expanding the graph node by node
The automatic traversal runs against a budget. Once it is spent, the graph can be truncated: branches that were not followed to their end are recorded as such rather than silently dropped. A reviewer can then pay to continue specific branches, one node at a time, from the KYB result page.
These endpoints are organization-scoped and available to your organization administrators.
Preview what an expansion would cost
GET /organizations/{organization_id}/kyb/{kyb_result_id}/expansion-preview
A pure read: no register is queried and nothing is charged. It answers "which nodes can I expand, and what would each cost?"
{
"candidates": [
{
"nodeId": "n_7",
"legalName": "Beta Holdings S.à r.l.",
"countryCode": "LU",
"nodeStatus": "budget_truncated",
"expandable": true,
"alreadyExpanded": false,
"estimatedCredits": 42,
"estimateBasis": "variableCap",
"expectedLatency": "slow"
},
{
"nodeId": "n_9",
"legalName": "Gamma Ltd",
"countryCode": "KY",
"nodeStatus": "coverage_unavailable",
"expandable": false,
"reason": "countryUnsupported"
}
],
"inFlight": [{ "nodeId": "n_4", "discoveryId": "…", "startedAt": "2026-08-30T02:00:00Z" }],
"concurrencySlotsFree": 3,
"pricingFetchedAt": "2026-08-30T02:00:00Z"
}
| Field | Meaning |
|---|---|
countryCode | The node's own country. Pricing and coverage are decided by it, not by the subject company's country — expanding a Cayman parent of an Italian subject prices against Cayman. |
expandable | Whether the node can be sent to extend-discovery right now. |
alreadyExpanded | Reported, not a rejection. Re-pulling a node is legitimate and is usually near-free thanks to source-side deduplication. |
estimatedCredits | Estimated cost in credits. Absent when expandable is false. |
estimateBasis | fixedPrice (a firm per-country price), variableCap (a ceiling the request is capped at, never a firm price), or unknown (a guess against the same conservative ceiling). |
expectedLatency | fast (seconds), slow (minutes), or manual (days, for jurisdictions where the record is retrieved by hand). |
reason | Why a node is not expandable — see the table below. |
concurrencySlotsFree | How many more expansions this KYB result can have in flight at once. |
Optional fields are omitted rather than serialized as null. Treat "key absent" and "key
null" identically.
The preview is only meaningful once the base traversal has completed — before that there is
nothing to expand, and a dispatch attempt rejects every node with baseRunInFlight.
Dispatch the expansion
POST /organizations/{organization_id}/kyb/{kyb_result_id}/extend-discovery
{ "nodeIds": ["n_7", "n_9", "n_4"], "maxCreditsPerNode": 60 }
| Field | Required | Meaning |
|---|---|---|
nodeIds | yes | 1–25 company node ids from the stored ownership graph. |
maxCreditsPerNode | no | Per-node ceiling, in the credits you are actually charged. It only ever binds downward: on a variable-priced country it replaces the platform ceiling; on a fixed-price country it cannot raise the price, but a cap below that price makes the node come back rejected with the real cost rather than overspending it. |
The response is now a per-node dispatch report (202), not a single {discoveryId, status}
job handle. One register request, one row, and one verdict per requested node, with partial
acceptance. The old top-level discoveryId and status fields are gone — with N rows they were
never meaningful.
{
"verificationRequestId": "…",
"estimatedCreditsTotal": 84,
"expansions": [
{ "nodeId": "n_7", "accepted": true, "discoveryId": "…", "status": "PENDING", "estimatedCredits": 42, "estimateBasis": "variableCap" },
{
"nodeId": "n_4",
"accepted": true,
"discoveryId": "…",
"status": "COMPLETED",
"estimatedCredits": 42,
"estimateBasis": "variableCap",
"chargedCredits": 17
},
{ "nodeId": "n_9", "accepted": false, "reason": "countryUnsupported" }
]
}
A node can finalize inline when the source responds synchronously, in which case it comes back
COMPLETED with chargedCredits. Otherwise the charge arrives later. verificationRequestId in
the response is the anchor's verification request: join its websocket room to receive
discovery-update events carrying discoveryId, rootNodeId, status, and chargedCredits as
each node settles, or poll the discovery read.
The endpoint is rate limited to 10 calls per minute per KYB result, and a single result can have
a limited number of expansions in flight at once — concurrencySlotsFree on the preview tells you
how many are left.
Partial acceptance replaces all-or-nothing. An unknown or non-expandable id is reported in
place in expansions[]. Only a request where every node is rejected returns 400, carrying
each node's reason.
Rejection reasons. expansion-preview can return the first seven; extend-discovery can
return all ten.
| Reason | Meaning |
|---|---|
notACompanyNode | The node is a natural person, not a company. |
countryUnsupported | No source covers that node's country. |
graphDatapointUnavailable | Ownership data is not available for that country. |
expansionInFlight | Another request is already expanding this node. |
baseRunInFlight | The base traversal has not finished yet. Every node is rejected with this. |
concurrencyCap | This KYB result already has the maximum number of expansions in flight. |
unknownNodeId | No such node in the stored graph. |
providerError | Dispatch-time only. The lookup or row creation failed. |
budgetExceeded | Dispatch-time only. The continuation was refused as over budget. Free, and may carry a re-quote. |
insufficientCredits | Dispatch-time only. Your balance no longer covers this node once other in-flight traversals' commitments are counted. |
Clients that match exhaustively on reason must handle all ten.
402 Payment Required means the summed estimate of every accepted node, plus what this KYB's
in-flight traversals have already committed, exceeded your credit headroom. It is deliberately
all-or-nothing: zero rows were created and zero lookups were made, so there is nothing to
reconcile and nothing to cancel. Top up, lower maxCreditsPerNode, or ask for fewer nodes, and
retry.
Cancel an expansion
DELETE /organizations/{organization_id}/kyb/{kyb_result_id}/extend-discovery/{discovery_id}
Returns 204 No Content, or 409 if the expansion has already finished or failed.
Cancelling means "stop waiting for this and let me move on", not "stop spending". The traversal continues at the source, which exposes no abort, and you are billed for the work it does. When it eventually settles, the result is kept rather than discarded: the graph is merged into the KYB result, discovered people are materialized, and the real final cost is billed once. Paying for a traversal and throwing its result away would be the worse outcome. Your cancellation note is preserved in the audit trail either way.
Cancelling releases the node's lock immediately, so a fresh expansion of the same node can be dispatched right away rather than waiting out a timeout.
Reading the full picture
GET /organizations/{organization_id}/kyb/{kyb_result_id}/discovery
The reviewer's full read: the base traversal's status, the discovered graph, the derived UBO suggestions, and the per-node expansion history with its per-node spend. A pure read — no lookup, no charge.
{
"status": "COMPLETED",
"ownershipStructure": {},
"uboSuggestions": [],
"expansions": [
{
"discoveryId": "…",
"rootNodeId": "n_4",
"status": "COMPLETED",
"chargedCredits": 17,
"stoppedReason": null,
"createdAt": "2026-08-30T02:00:00Z",
"completedAt": "2026-08-30T02:01:00Z"
}
]
}
chargedCredits is always present and is 0 before the charge is claimed. 0 is not the same
as "free" — read status to tell a not-yet-charged row from a genuinely zero-cost one (a
deduplicated lookup). stoppedReason, when present, is one of completed, budget_exhausted,
max_depth_reached, max_nodes_reached, or timeout.
What a KYB costs
Everything below is denominated in credits, deducted from your organization's balance. See Billing for how credits are funded.
Every KYB carries a base charge, resolved for the jurisdiction of the business being verified, falling back to your plan's KYB credit cost when no jurisdiction price applies. Zyphe can set a per-organization override of a jurisdiction's price for you.
What that base charge covers depends on whether discovery is on:
discoveryEnabled | Charging model | What you pay |
|---|---|---|
false (default) | Flat | The jurisdiction base charge, and nothing else. The registry lookup behind the business-information cross-check is included — it is performed and costs you no extra credits. |
true | Metered | The jurisdiction base charge, plus the ownership-graph traversal and any operator-initiated node expansions, each charged in credits. |
Flat versus metered is a per-KYB property derived from the anchor step's discoveryEnabled,
not an organization-level setting. Two flows in the same organization can charge differently.
Within a metered KYB:
- The automatic traversal is bounded by
discoveryBudgetCreditson the step. - Each node expansion is priced and charged individually, against the node's own country, and
optionally capped by
maxCreditsPerNodeon the request. - Sandbox draws the same credit balance as production. There is no separate sandbox balance.
Approval preconditions
Approval readiness is now enforced identically everywhere — the organization approve endpoint,
the platform moderation endpoint, the manual-review endpoint, and the agent's approve tool. All of
them return a 400 naming the reason:
| Situation | What you get |
|---|---|
The result is still ACQUIRING or ENRICHING | 400 — the result is still being prepared and is not yet eligible for review. |
| Ownership discovery is still running | 400 — ownership discovery outstanding. |
| A UBO or director declaration request is unanswered | 400 — the applicant still owes a declaration. |
| Required documents missing, business information incomplete, or a person's KYC or screening unfinished | 400 naming the specific gap. |
This is stricter than before: paths that previously let an approval through in these states now refuse it.