CorePass
Start integrating

KYB: verified business data and signatures

Ask a business for verified data, verify the RS256 attestation that carries the values, and ask a business to sign a message you wrote.

12 minute read · Updated 2026-09-05


KYC transfers data about a person, who approves it on their own phone. KYB transfers data about a business, and a business has no phone: its members vote, and the data is released only if enough of them approve. It is a separate API, a separate key set and a separate signature header from KYC, on purpose.

What is different from KYC #

  1. A value never travels on the merchant API, and never through anyone's phone. A data request carries field names and hashes. The values arrive at your registered webhook endpoint inside a signed attestation — an RS256 JWT — and there is no unsigned copy of them anywhere, so there is no way to read a value without verifying the signature first.
  2. The approval window is a fixed 24 hours, not the 5–15 minutes of a KYC request, and it is not configurable per request.
  3. The KYC integrity model does not apply. There is no pepper and no fingerprint to check a KYB value against. What replaces it is the attestation, with the values inside the claims rather than beside them, and with the event identifiers duplicated inside the signature so a relabelled delivery is detectable.

A merchant acting as a business signs in with one extra scope — see business login.

Asking a business for data #

The merchant API lives at /api/v1/kyb on the connector gateway, behind your API key. The key goes in Authorization rawAuthorization: <api key>, never Bearer <api key>; a prefixed key is refused as apikey_invalid.

POST /api/v1/kyb/data-requests
curl --request POST \
  --url https://api-develop.corepassdev.eu/api/v1/kyb/data-requests \
  --header 'Authorization: <api key>' \
  --header 'Idempotency-Key: 4b7f1c2e9a0d4f83' \
  --header 'Content-Type: application/json' \
  --data '{
    "businessWalletAddress": "cb12…",
    "fields": ["BUSINESS_LEGAL_NAME", "BUSINESS_REGISTRATION_NUMBER"],
    "optionalFields": ["BUSINESS_VAT_NUMBER"],
    "purpose": "Onboarding a business customer for wholesale orders",
    "reference": "your-own-id"
  }'
  • Idempotency-Key is optional here and strongly recommended — 1–128 printable ASCII, scoped to your client domain. A replay of the same key returns the original request with a 200 instead of creating a second one.
  • purpose is 8–280 characters and it is what the members read before they approve. It is your text, shown to a human, and echoed back on every read.
  • optionalFields are asked for but do not refuse the request when they cannot be answered.
  • There is no callback, no status callback and no expiry on the body. Delivery goes to the one endpoint you registered, and the window is 24 hours.
  • Requesting a field about a natural person — a director's date of birth, or any party field — needs the PII capability, and is refused at ingest with pii_not_entitled if you do not hold it.

The 201 body includes requestId, the fields as they resolved, vocab and attestationAudience — your client domain uuid, which is the aud every attestation for this request will carry. There is no other way to learn it; keep it in your configuration.

GET /api/v1/kyb/data-requests/{requestId} returns the request with a status — pending_approval, released, refused, cancelled or failed — and, once it is not pending, a reason code. It never returns a value.

Asking by QR code #

When you do not know the business's wallet address — a screen in a shop, an onboarding page — create an offer instead.

POST /api/v1/kyb/data-requests/offers
curl --request POST \
  --url https://api-develop.corepassdev.eu/api/v1/kyb/data-requests/offers \
  --header 'Authorization: <api key>' \
  --header 'Idempotency-Key: 9c2e5d1b7a634f80' \
  --header 'Content-Type: application/json' \
  --data '{
    "fields": ["BUSINESS_LEGAL_NAME"],
    "purpose": "Onboarding a business customer for wholesale orders",
    "expiresInSeconds": 1800
  }'

The 201 body carries offerId, expiresAt, a base64 PNG and a link:

The deep link
corepass:bizdata/?app=<requester name>&exp=<unix>&offer=<offer id>&tok=<claim token>

Show the QR, or the link on mobile. A member with voting rights scans it, picks which of their businesses answers, and that claim creates the data request.

Idempotency-Key is required on an offer. A retried create without one mints a second QR, and you cannot tell which of the two the person in front of you scanned. expiresInSeconds defaults to 30 minutes and is refused above 3600 — an offer is a bearer link, and a long-lived one left on a printed QR is a liability.

Poll the offer: OPEN until somebody scans it, then CLAIMED with a request id and a wallet address; EXPIRED and CANCELLED are terminal. The link and the QR come back only on the create response — they embed the claim token, which is never stored anywhere it could be read back — so keep the ones you were given. An offer with no registered webhook endpoint is refused with no_webhook_endpoint at mint, rather than after somebody has scanned it.

Registering your webhook endpoint #

PUT /api/v1/kyb/webhook-endpoint
curl --request PUT \
  --url https://api-develop.corepassdev.eu/api/v1/kyb/webhook-endpoint \
  --header 'Authorization: <api key>' \
  --header 'Content-Type: application/json' \
  --data '{"url": "https://your.example/kyb/webhook"}'

The response carries the signing secret exactly once. Store it before you do anything else with the response; losing it means registering again.

  • HTTPS only, on a public host. Redirects are never followed — a 302 is you asking CorePass to POST a signed disclosure somewhere you did not register. Change the URL by registering it again.
  • A GET on the same path shows the current registration — secret version, active flag, consecutive failures, last delivery, and the quarantine reason if there is one. It never carries the secret.
  • Rotating the secret bumps the version, and your previous secret stays valid for 24 hours, so a deploy does not have to be instantaneous. Deliveries name the version they were signed with.
  • Sustained failure quarantines your endpoint: deliveries stop and events accumulate rather than being dropped. Reactivating clears it and the backlog goes out; reactivating one that is not quarantined is a 409.

What arrives #

One envelope per event, POSTed to your registered URL:

A delivery
Corepass-KYB-Signature: t=1787472000,v1=<hex hmac-sha256>,kv=1
Corepass-Event-Id: 01K3QW8P4Z0M5R7T9V2X6B4Y8C
Corepass-Delivery-Attempt: 1
Corepass-Kyb-Api-Version: 2026-09-05

{
  "event":       "kyb.data_request.released",
  "event_id":    "01K3QW8P4Z0M5R7T9V2X6B4Y8C",
  "stream_id":   "01K3QW1A0000000000000000AB",
  "seq":         2,
  "timestamp":   1787472000,
  "api_version": "2026-09-05",
  "data":        { /* routing and version dials only */ },
  "attestation": "eyJhbGciOiJSUzI1NiIsInR5cCI6Imt5Yi1hdHRlc3RhdGlvbitqd3QiLCJraWQiOiJreWItMjAyNi0wOC1hIn0…"
}

The members are snake_case here, unlike the camelCase merchant API, and there is no top-level request id: the stream is identified by stream_id, and the request id lives inside the signed claims. A cross-check written against a top-level request id fails on every genuine delivery.

Event seq Attested What it means
kyb.data_request.pending_approval 1 no The members have been asked. Carries the authoritative expiry and the field list — names, hashes, whether a field is personal data, whether it is optional, and its subject type — and no values; plus the reserved price, and the offer id on the QR path.
kyb.data_request.released 2 yes Approved. The values are inside the attestation; data carries only the request id, the wallet address, the release time, the field count and the attestation dials.
kyb.data_request.refused 2 no Not approved. data.reason is one of the closed set below.
kyb.business.wallet_address_changed own stream yes A business you hold attested data about now answers at a different address. The old one stops resolving: no moved-to pointer, no grace period, no alias.

A handler that treats a missing attestation on pending_approval or refused as an error stops at the stream's first message. A handler that does not verify one that is present has no security at all. Verify whenever a token is present, whether or not you recognise the event type: new attested types get added, and a verifier that branches on the types it knows would hand your application a new attested event with its signature ignored.

Refusal reasons #

Reason Meaning
declined A member declined it.
threshold_not_met The vote did not reach the approval threshold.
expired The 24 hours ran out before the members finished.
business_not_verified The business is not, or is no longer, verified.
business_wallet_rotated The business moved to a new wallet address mid-request.
cancelled_by_merchant You withdrew it.
unavailable The data could not be released.

You are never told who voted, how they voted, the tally, the threshold, the eligible-voter count, the governance mode, or which member declined. The attestation carries a boolean and an audit handle, and only on a release.

Verifying a delivery #

The signature header #

Corepass-KYB-Signature: t=<unix>,v1=<hex hmac-sha256>,kv=<secret version> — an HMAC-SHA256 keyed with your webhook secret over the timestamp, a dot, and the raw body bytes. This is transport hygiene, not the security boundary: a merchant that verifies only the JWT and throws the header away is safe, and that is a designed property. What it is still worth is dropping obvious garbage before you parse JSON or fetch a key, and binding this attempt to a clock.

If you do check it, check it properly: compare the timestamp against your own clock, MAC the raw bytes you received, and compare in constant time. The header is deliberately not named Corepass-Signature, so a KYC verifier cannot be pointed at KYB traffic — the two use different secrets and different schemes.

The attestation — the five checks #

  1. The type is kyb-attestation+jwt, checked before the signature. This is what stops a CorePass login token being accepted where an attestation was expected, and it only works if it happens before anything trusting does.
  2. The algorithm is RS256, as a constant. Never read the algorithm out of the header and look up a verifier by it — "alg":"none" and RS256-verified-as-HS256 both come from that one mistake.
  3. The signature verifies against the key named by kid, from the gateway's /.well-known/kyb-jwks.json.
  4. Issuer, audience and the two times hold, with a 60 second skew leeway. The issuer defaults to https://corepass.net/kyb; the audience is the attestationAudience the create response gave you.
  5. The envelope's event id, stream id and seq equal the signed copies. The signed copy is authoritative. Without this, anything between CorePass and you can relabel event 5 as event 2 and you will act on the wrong disclosure.

And one thing that is deliberately not a check: the vocabulary digest. And one thing the snippets cannot do for you: dedupe on the event id. It is a 26-character ULID, byte-identical on every retry and every operator redelivery, and it is the only dedupe key — not the timestamp, not a hash of the body. Put a unique index on it before you run more than one replica.

Reference verifiers — standard library only, with no dependency to install — are published with the platform for Go and TypeScript. Copy the one for your language.

Go
verifier := &kybattestation.Verifier{
	JWKSURL:  "https://api-develop.corepassdev.eu/.well-known/kyb-jwks.json",
	Issuer:   "https://corepass.net/kyb",
	Audience: "<your client domain uuid>", // attestationAudience from the create response
	CacheTTL: 5 * time.Minute,             // never cache the key set forever
}

func handle(w http.ResponseWriter, r *http.Request) {
	raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))

	// Optional, but if you do it, do it over these bytes and against your clock.
	if err := checkSignatureHeader(r.Header.Get("Corepass-KYB-Signature"), raw); err != nil {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	// Refuses a released event with no token, verifies any token that is
	// present, and cross-checks the envelope against the signed copies.
	envelope, claims, err := verifier.Verify(r.Context(), raw)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Your dedupe, on event_id and nothing else. A duplicate answers 2xx and
	// does nothing.
	if seen, err := store.ClaimEvent(r.Context(), envelope.EventID); err != nil {
		w.WriteHeader(http.StatusServiceUnavailable) // retry us
		return
	} else if seen {
		w.WriteHeader(http.StatusOK)
		return
	}

	for _, field := range claims.Fields {
		// field.Name, field.Value, field.Provenance, field.SubjectType,
		// field.Subject
	}
	w.WriteHeader(http.StatusOK)
}
TypeScript
const verifier = new KybAttestationVerifier({
  jwksUrl: "https://api-develop.corepassdev.eu/.well-known/kyb-jwks.json",
  issuer: "https://corepass.net/kyb",
  audience: "<your client domain uuid>",
  cacheTtlSeconds: 300,
});

app.post("/kyb/webhook", express.raw({ type: "*/*" }), async (req, res) => {
  try {
    // Pass the RAW body. express.json() would hand you a re-serialisation.
    const { envelope, claims } = await verifier.verify(req.body);

    if (await store.claimEvent(envelope.event_id)) {
      return res.sendStatus(200); // already processed
    }

    for (const field of claims?.fields ?? []) {
      // field.name, field.value, field.provenance, field.subject_type,
      // field.subject
    }
    res.sendStatus(200);
  } catch (error) {
    res.sendStatus(400);
  }
});

Failure modes #

Failure What it means, and what to do
wrong_type Not a KYB attestation — most likely a token of another kind. Reject.
wrong_algorithm The header names something other than RS256, including none. Reject; this is a forgery attempt.
bad_signature Signed by the wrong key. Reject and raise an incident.
unknown_key The kid is absent from the key set even after a refresh. Reject; not a retry.
expired Past exp. Attestations are valid for 168 hours. Ask for a redelivery if you still need the data.
issued_in_the_future Clock skew. Fix your clock; do not widen the leeway until the check stops firing.
wrong_issuer / wrong_audience Not the KYB issuer, or addressed to a different merchant. Reject.
vocabulary_mismatch A genuine token naming a vocabulary you were not built against. Refresh your field map and keep the delivery.
envelope_mismatch The cross-check failed — something relabelled the delivery. Reject.
replay You have already processed this event id. Acknowledge and ignore.

Reading the released fields #

Inside the claims
"fields": [
  { "name": "BUSINESS_LEGAL_NAME", "hash": "…", "subject_type": "BUSINESS",
    "provenance": "VERIFIED_BY_REGISTRY", "value": "Happy Paws Wholesale Ltd" },
  { "name": "BUSINESS_DIRECTOR_DOB", "hash": "…", "subject_type": "DIRECTOR",
    "subject": "…", "provenance": "DECLARED_BY_APPLICANT", "value": "1986-09-06" },
  { "name": "BUSINESS_PARTY_ROLES", "hash": "…", "subject_type": "PARTY",
    "subject": "6d1f0d2e-9a1b-4c3e-8f7a-2b5c9e1d4a6f", "provenance": "ATTESTED_BY_SERVICE",
    "value": "ubo,director" }
]
  • Subject type is who the value is about: BUSINESS, DIRECTOR, UBO or PARTY. Business rows carry no subject. Director and UBO rows are keyed by the person's name as the registry or the applicant spelt it. Party rows describe any party in the company structure whatever its roles, and are keyed by the provider's beneficiary id — a UUID, stable for the life of the applicant, never a name.
  • Every party value is personal data, even when the party is a legal entity: the row still names a person's role, share or contact inside a company. Asking for one needs the PII capability.
  • Provenance is where the value came from: VERIFIED_BY_REGISTRY, DECLARED_TO_PROVIDER, OFFICER_CORRECTED, ATTESTED_BY_SERVICE or DECLARED_BY_APPLICANT. Only the first was independently confirmed by a corporate registry. Show all of them, but never show a declared value as if a registry had confirmed it.
  • A company with two directors answers a director-level field twice. The two entries differ only in their subject. Key on the subject — keying on the name alone silently keeps one of the two. The same holds for every party-level field.

The vocabulary digest #

The field vocabulary — the names, their numbers, who they describe and where they come from — is one file owned by the platform, and its sha256 is the vocabulary digest. It appears on every create and read response and inside every attestation.

  • A bump is additive. Names are appended, never renamed, renumbered or deleted, and a retired number is never reissued. Every name you asked for under an older digest still means the same thing under a newer one.
  • An attestation signs the digest in force at signing, not at request time. A request created before a bump and approved after it carries the new digest. Do not expect the create-time digest to be echoed back.
  • A mismatch is not a forgery. Treat it as “refresh your field map”: fetch the pricing endpoint or the published table, accept the newer digest, and keep the delivery. Rejecting on it would drop genuine data every time the platform learns a new field.
  • Never reject a name you do not know. Under an older field map an appended name is simply one you have not seen; show it by its raw name and treat it as personal data until your map says otherwise.

Delivery, retries and pull #

  • Delivery is at least once. You will occasionally see the same event id twice; that is the queue being safe rather than fast, and it is why the dedupe key exists.
  • Events on one stream are delivered in order: seq 2 is not sent until seq 1 has been delivered or has permanently stopped being retryable.
  • Answer 2xx to accept. Anything else is retried on an exponential schedule for roughly nine hours. Answer fast — a slow 200 costs you the same retry a 500 would.
  • A redelivery reuses the same event ids, which is exactly why your dedupe must key on them.
  • Pull: the release endpoint returns the attestation verbatim while the payload is retained — for a receiver that was down during the delivery window. It answers 404 not_released while the vote is open and 410 payload_expired once retention closes. Verify a pulled token exactly as you would a delivered one: the API key proves who is asking, not what the token says.
  • Attestations are valid for 168 hours and the payload is retained for the same window. After that there is no re-signed substitute: store what you were given.

CorePass rotates the signing key on an overlap — the new public half is published first, signing switches to it later, and the old half stays in the key set until the last token signed with it has expired. You need do nothing, provided you do not cache the key set forever.

Signature requests #

A data request asks a business to disclose something about itself. A signature request asks it to sign something you wrote — an acceptance of your terms, an order, a delivery confirmation, a typed-data message your own contract will check later. The members vote on it exactly as they vote on a data request, and the result comes back on the same webhook endpoint.

Signature requests need the signature capability on your API key. It is default-deny and granted per organisation; a key without it is refused with kyb_signature_not_entitled. They are also free — only field disclosure is priced.

POST /api/v1/kyb/signature-requests
curl --request POST \
  --url https://api-develop.corepassdev.eu/api/v1/kyb/signature-requests \
  --header 'Authorization: <api key>' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: 9f1c-terms-v3-happypaws' \
  --data '{
    "businessWalletAddress": "cb12…",
    "messageKind": "TEXT",
    "message": "Happy Paws Ltd accepts the pet shop wholesale terms, version 3, dated 2026-09-01.",
    "purpose": "Wholesale terms acceptance for this quarter",
    "reference": "PS-2026-00417"
  }'

The 201 answers with the request, including the digest — computed by CorePass and returned to you, because you cannot derive it without reimplementing the hasher, and it is the value the eventual signature is verified against.

201 Created
{
  "requestId":             "01K3SG1A0000000000000000AA",
  "businessWalletAddress": "cb12…",
  "messageKind":           "TEXT",
  "message":               "Happy Paws Ltd accepts…",
  "digest":                "0x3f1c…",
  "purpose":               "Wholesale terms acceptance for this quarter",
  "audience":              "MERCHANT",
  "status":                "pending_approval",
  "createdAt":             1787472000,
  "expiresAt":             1787558400,
  "expiresInSeconds":      86400,
  "reference":             "PS-2026-00417",
  "apiVersion":            "2026-09-05"
}

messageKind is TEXT or TYPED_DATA; send exactly one of message or typedData, matching the kind. Both hash with SHA3-256, never Ethereum's keccak — if a constant you copied came from an Ethereum example, it is wrong here. The typed-data domain type names networkId, not chainId, and documents larger than 64 KiB are refused.

The window is a fixed 24 hours unless you pass expiresInSeconds, which on a request is clamped into 5 minutes to 24 hours rather than refused — the response states the deadline you actually got. On an offer, anything above 3600 is refused outright. The two behave differently on purpose: a silently shortened QR is worse than a refusal you can read.

Idempotency-Key is required on both creates, not merely recommended: a retried create without one asks the business to sign the same message a second time, or mints a second QR the merchant cannot tell apart from the one the customer is holding.

Three events arrive on a signature stream — pending_approval, signed and refused — and none of them is attested, deliberately. The artefact is a signature over a message you wrote, it discloses nothing about the business, and your own check against the wallet contract is what makes it worth anything.

kyb.signature_request.signed
{
  "event":       "kyb.signature_request.signed",
  "event_id":    "01K3SG8P4Z0M5R7T9V2X6B4Y8A",
  "stream_id":   "01K3SG1A0000000000000000AA",
  "seq":         2,
  "timestamp":   1787472600,
  "api_version": "2026-09-05",
  "data": {
    "request_id":              "01K3SG1A0000000000000000AA",
    "business_wallet_address": "cb12…",
    "signature":               "0x0000000000000000000000000000000000000000000000000000000000000020…",
    "digest":                  "0x3f1c…",
    "message_kind":            "TEXT",
    "signed_at":               1787472600,
    "merchant_reference":      "PS-2026-00417",
    "verification": {
      "method":   "eip1271",
      "contract": "cb12…",
      "selector": "0x95f9a59b"
    }
  }
}

Checking a business signature #

A business signature is not a signature you can recover an address from. A business wallet is a contract; there is no key behind the business. What you receive is an ABI-encoded array of member signatures, and the only thing that can tell you whether that bundle carries the business's authority is the wallet contract itself.

Three consequences before you write any code:

  1. Recovering a public key from the blob returns nothing useful. The bytes are an ABI envelope, not a signature — an integration that applies the individual-signature recipe concludes that every business signature is forged.
  2. Counting the recovered signers yourself is not the check. Recovering three addresses tells you nothing about whether those three are current members with voting rights and enough combined weight. Only the contract knows the roster, the weights and the threshold, and only as of the block you ask about.
  3. The magic value is 0x95f9a59b, not the 0x1626ba7e of every EIP-1271 example on the internet — Core hashes with SHA3-256, so the answer is different. Derive it in code rather than pasting it.

The check is one read-only call at the latest block. It answers the magic value when the bundle meets the business's threshold and 0xffffffff otherwise, and it does not revert on a bad signature — so any other answer, including the empty return data you get from an address with no code, means “not signed”.

JavaScript
const corebc = require("@corepass/corebc");
const walletAbi = require("./abi/business/BusinessWallet.json");

// corebc.utils.id is SHA3-256, not keccak. This is "0x95f9a59b".
const MAGICVALUE = corebc.utils.id("isValidSignature(bytes32,bytes)").slice(0, 10);

const provider = new corebc.providers.JsonRpcProvider(process.env.RPC_URL);

async function isValidBusinessSignature(event) {
  const { business_wallet_address: wallet, digest, signature } = event.data;

  const contract = new corebc.Contract(wallet, walletAbi, provider);
  const answer = await contract.isValidSignature(digest, signature);

  return answer === MAGICVALUE;
}
Outcome When What to do
valid The contract answered the magic value. Enough current voting members signed this exact digest. Act on it.
invalid The contract answered anything else, including empty data. Not signed. Retrying will not change it.
unknown The node could not be reached. Not the same as invalid. Keep the signature, record that you could not check, and ask again.

Answer the webhook 2xx in all three cases. The delivery is genuine whatever the contract says, and a non-2xx makes CorePass retry an artefact that will not change.

Pricing and credit #

KYB data is prepaid, in Swiss francs. You top your credit up in the CorePass dashboard; requests draw on it. A data request reserves its full quote when it is created and spends only the fields whose outcome was actually released; a refusal, an expiry, your own cancellation and a platform failure all give the money back. A quote that does not fit in the available balance refuses the request with 402 insufficient_credit before any member is disturbed.

Tier CHF / field What is in it
A 0.50 Registry basics and the individual address components — legal name, trade name, registration number, incorporation country and date, company type and legal form, the six address components, and the registry status fields.
B 2.00 Identifiers, contact details and the address as a single block — tax id, legal / postal / trade address, website, email, phone, industry code, VAT number, registered capital, employee count, licence numbers.
C 5.00 Verification standing, ours and the provider's — verification status and level, verified-at and expiry, the registry answer, the provider's review status and verdicts, risk labels.
C2 12.00 Documents — incorporation, registry extract, articles of association, shareholder and directors registries, proof of address, good standing, power of attorney, trade licence and the rest.
D 5.00 Every verified director, UBO and party field. Derived from the subject rather than listed, so a person-level name added tomorrow is tier D the moment it exists. Personal data, and needs the PII capability.
E per item Screening from the provider's AML case: sanctions 8.00, PEP 8.00, adverse media 10.00, AML verdict 5.00, AML detail 20.00, ongoing monitoring 10.00.
NONE Not purchasable: the wallet address, the CNS domain and every declared field. Asking for one is a 400 field_not_priced.

An address costs differently depending on how you ask for it. The whole address as one block is tier B; the six components are tier A each. Ask for the block if you want the block. And a parent field is quoted at the highest tier among its members, but charged at the tier of the member that was actually released.

Quote before you ask — a quote reserves nothing and expires never:

POST /api/v1/kyb/data-requests/quote
curl --request POST \
  --url https://api-develop.corepassdev.eu/api/v1/kyb/data-requests/quote \
  --header 'Authorization: <api key>' \
  --header 'Content-Type: application/json' \
  --data '{"fields": ["BUSINESS_LEGAL_NAME", "BUSINESS_TAX_ID", "BUSINESS_VERIFIED"]}'
200 OK
{
  "totalCents": 750,
  "currency": "CHF",
  "lines": [
    { "field": "BUSINESS_LEGAL_NAME", "tier": "A",  "cents": 50 },
    { "field": "BUSINESS_TAX_ID",     "tier": "B",  "cents": 200 },
    { "field": "BUSINESS_VERIFIED",   "tier": "C",  "cents": 500 }
  ]
}

Two spellings exist for the money, and which you get depends on where you read it: webhook bodies are snake_case, API responses are camelCase. Same numbers, same structure, different member names — a client that models one and parses the other reads zeroes. price is what a request reserved and appears on the first event of the stream; charge is what the release cost and appears on the released event.

GET /api/v1/kyb/billing/account
{
  "sourceKey":      "kyb:2f1c9f4e-1111-2222-3333-444444444444",
  "currency":       "CHF",
  "totalChf":       100.00,
  "spentChf":       12.50,
  "frozenChf":      5.00,
  "availableChf":   82.50,
  "billingEnabled": true
}

The frozen amount is money reserved by requests still being voted on. It is neither spent nor available. Reading total minus spent as your headroom is the way to be surprised by your first 402 while several requests are open.

Ledger state What it means
RESERVED The quote is held against your credit. The vote is still open.
SPENT Fields were released and charged.
RELEASED The reservation went back to your credit. Nothing was charged.
FAILED The ledger operation itself failed.
DISABLED The deployment runs with billing off; the row records no money.

RELEASED in the ledger means the money was released, not the data — it is the outcome of a refusal, an expiry or a cancellation, which is the exact opposite of a released data request. The two words meet in one row and mean opposite things.

402 insufficient_credit
{
  "success": false,
  "error": {
    "code": 402,
    "reason": "insufficient_credit",
    "requiredChf": 7.50,
    "availableChf": 1.20,
    "quotedCents": 750,
    "message": "the quoted price does not fit in the available KYB credit"
  }
}

The three numbers are the point of that refusal: they let you put a figure on a top-up button instead of parsing an English sentence. Any of them may be absent if the platform could not state it — a missing one is missing, never a zero. There is a second 402, no_credit_account, which means there is no KYB credit account for your organisation at all rather than that yours has run low; the remedy differs, so branch on both.