API reference: sharing and consent
For a developer wiring up disclosure requests, grade reads, and permission management directly against the API.
The two articles above describe sharing from a browser: a consent link, a wallet page. This one is for wiring the same mechanism up directly — a reader asking for a dossier inside its own onboarding flow, a holder's back office managing what's been granted without opening a page by hand. Same three beats underneath either way: la demande (a reader asks, by name, for a reason), le consentement (the holder answers — yes, no, or less than that), and l'intersection (what actually crosses is requested ∩ permitted, recomputed on every single read).
Every route below except the catalogue read requires Authorization: Bearer <key>. A missing or invalid credential gets a 401 with a www-authenticate header; a credential that doesn't carry the scope a route needs gets a 403 naming the scope. There is no route that accepts a credential in a query string or a URL path — a token belongs in a header or nowhere.
The catalogue: what can be asked for
GET /v1/disclosure/catalogue — no scope required, returns the fixed list every request and every consent screen is built from. Deliberately coarse: a field list nobody outside the engine can read is not consent.
GET /v1/disclosure/catalogue
200
{
"groups": [
{ "group": "identity.core", "tier": "FREE", "refusable": false,
"label": "That your company exists and the certificate is current",
"discloses": "Registered name, registration number, country, and whether the certificate is valid." },
{ "group": "identity.grade", "tier": "GRADE", "refusable": true,
"label": "Your grade",
"discloses": "The band, both axes, and the qualifiers attached." },
{ "group": "identity.factors", "tier": "DOSSIER", "refusable": true, "...": "..." },
{ "group": "identity.evidence", "tier": "DOSSIER", "refusable": true, "...": "..." },
{ "group": "ownership.beneficial", "tier": "DOSSIER", "refusable": true, "...": "..." },
{ "group": "screening.detail", "tier": "DOSSIER", "refusable": true, "...": "..." },
{ "group": "monitoring.findings", "tier": "DOSSIER", "refusable": true, "...": "..." }
]
}
identity.core is the one group marked refusable: false — it's what the certificate already resolves publicly, so refusing it at consent would be theatre. Everything else the holder may grant, refuse, or narrow later.
The grade read: the cheap, high-volume case
GET /v1/certificates/:clientId/grade — scope dossier:grade. Band, both axes, the qualifiers, and how many findings are outstanding — as a count, not the findings themselves. This is what most integrations actually want: "may I trade with this company today."
GET /v1/certificates/adid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/grade
Authorization: Bearer <key>
200
{
"band": "B",
"identity": "CONFIRMED",
"trade": "WATCH",
"qualifiers": ["a watch hit on the restricted-party lists"],
"openFindings": 1
}
Unresolvable and not-permitted look identical: a 404 with the same body either way. That's deliberate — a reader who could tell "wrong identifier" from "real certificate I'm not allowed to see" could enumerate which identifiers exist, one guess at a time.
The full dossier: what a compliance officer files
GET /v1/certificates/:clientId/dossier — scope dossier:full. Everything the grade read has, plus whatever field groups are currently granted: the factors, the evidence manifest, the ownership chain, the screening detail, the open findings themselves. What actually comes back is filtered live through l'intersection — a grant agreed last month binds to whatever the holder permits right now, not to what they permitted when the grant was made.
GET /v1/certificates/adid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/dossier
Authorization: Bearer <key>
200
{
"band": "B", "identity": "CONFIRMED", "trade": "WATCH",
"factors": [ /* only present if identity.factors was granted */ ],
"evidence": [ /* only present if identity.evidence was granted */ ],
"beneficialOwners": [ /* only present if ownership.beneficial was granted */ ],
"disclosure": {
"granted": ["identity.core", "identity.grade", "identity.factors"],
"withheld": ["identity.evidence", "ownership.beneficial", "screening.detail", "monitoring.findings"]
}
}
Never in either read: the holder's own decision record — the internal rationale behind an approval or a decline. A counterparty is entitled to the facts and the verdict, not to the reasoning behind them. Every read here — grade or full — is written into the holder's own audit trail, visible to them, whichever tier it was.
Asking: la demande
POST /v1/disclosure/requests — scope cases:read. Named field groups and a purpose in the reader's own words; nothing is disclosed by this call alone.
POST /v1/disclosure/requests
Authorization: Bearer <key>
{
"clientId": "adid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"requested": ["identity.grade", "ownership.beneficial"],
"purpose": "Onboarding you as a supplier for our EU warehouse."
}
201
{
"requestId": "...",
"expiresAt": "...",
"answerLink": "/consent?t=...",
"next": "Nothing is disclosed until they answer. Narrowing is one of the answers."
}
The purpose field isn't decoration — it's shown to the holder verbatim on the consent page, and "why" is most of what makes a consent decision possible. The request is written into the holder's tenant, not the reader's: it's about them and answered by them, so a reader's own copy of it would be a claim rather than a record. The answerLink is returned once — send it to whoever can answer it.
Checking what's currently granted
GET /v1/disclosure/:clientId — scope cases:read. What this specific reader credential may see right now, computed live rather than read back from the original grant.
GET /v1/disclosure/adid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: Bearer <key>
200
{
"clientId": "adid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"granted": ["identity.core", "identity.grade"],
"withheld": ["ownership.beneficial"],
"meters": true,
"requestId": "..."
}
The holder side: permissions and the wallet
Two routes exist for a holder's own back office rather than for a reader:
- GET /v1/disclosure/:clientId/permissions
- Scope cases:read. Everything currently permitted on this certificate, across every reader who's been granted anything.
- DELETE /v1/disclosure/:clientId/permissions?group=...
- Scope cases:decide. Withdraws one field group. Takes effect on the next read, everywhere — it isn't scoped to one reader, because permission was never granted to one field forever, it was granted to whoever asks while it stands.
- GET /v1/wallet
- Scope cases:read. Both directions in one read: what this tenant's own certificates have granted and to whom (granted), and what other holders have granted to this caller (held). Everything in it is computed at read time through the same intersection the dossier read uses — a wallet that reported the grant as originally agreed would be exactly the drift the whole design exists to prevent.
Getting a reader credential in the first place
POST /v1/keys mints a derived key — the mechanism serving a counterparty, an employee, or a machine from one certificate's root credential. A key derived for a counterparty can read only the certificate it was derived from, even if the tenant holds several certificates; that binding is checked on every read, not assumed from the tenant alone. The secret comes back exactly once, in the response body — there is no route that returns it a second time, so it has to be stored by whoever received it.
See getting your own certificate for how a tenant ends up with a root credential to derive from, and managing what you've shared for the same permissions and wallet data, presented as a page rather than a response body.
Every route above is also described as an OpenAPI 3.0 document — enough to generate a client or point a request tool at, scoped deliberately to just this mechanism rather than the whole API. Served with an open CORS header, since it's the same public information either way.