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.
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 #
- 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.
- The approval window is a fixed 24 hours, not the 5–15 minutes of a KYC request, and it is not configurable per request.
- 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 raw —
Authorization: <api key>, never
Bearer <api key>; a prefixed key is refused
as apikey_invalid.
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-Keyis 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. -
purposeis 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. -
optionalFieldsare 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_entitledif 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.
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:
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 #
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:
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 #
- 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. - 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. - The signature verifies against the key named by
kid, from the gateway's/.well-known/kyb-jwks.json. - Issuer, audience and the two times hold, with a 60 second
skew leeway. The issuer defaults to
https://corepass.net/kyb; the audience is theattestationAudiencethe create response gave you. - 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.
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)
} 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 #
"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,UBOorPARTY. 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_SERVICEorDECLARED_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_releasedwhile the vote is open and410 payload_expiredonce 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.
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.
{
"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.
{
"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:
- 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.
- 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.
-
The magic value is
0x95f9a59b, not the0x1626ba7eof 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”.
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:
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"]}' {
"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.
{
"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.
{
"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.