Partner API v1 · Draft, not yet live
Partner API v1: overview
Draft — not yet live. This describes the proposed v1 partner contract (version 1.0.0-rc.1). No endpoint on this page is deployed, and no API key can be issued yet.
Version 1 is the partner-facing layer over the Crown Intelligence data service. It adds API keys with scopes, metering, batch matching with a clear status for every query, webhooks, monitoring of public list entries and evidence packs. This page explains the rules that every operation follows.
Status of this API
The v1 contract is a draft, version 1.0.0-rc.1. It is not deployed, not published and not billed. The addresses below are proposed; they do not resolve to a running service yet.
| Live (proposed) | https://api.crownintelligence.net |
|---|---|
| Sandbox (proposed) | https://sandbox.api.crownintelligence.net, the same public registers, never billed |
| Machine-readable contract | crown-intelligence-v1.yaml (OpenAPI 3.1.0, draft) |
API keys and scopes
Send the key in the X-Api-Key header on every request except GET /v1/status, which needs no key.
- Live keys start with
ci_live_. Sandbox keys start withci_test_. A sandbox key never reaches a live route and never creates a billable order. - The secret is shown once, when the key is created. Crown Intelligence keeps only a hash and the visible prefix.
- Each key carries explicit scopes. A key without the scope a route needs gets 403
forbidden_scope. - Revocation is immediate and final. A revoked key returns 401
key_revoked. Keys are retired, never deleted. - A live key must name the subscription order it is billed under (
order_id).
| Scope | Covers |
|---|---|
match:read | POST /v1/match, and reconciliation |
search:read | Search, records, entities and collections |
sources:read | Sources, programmes and collection statistics |
exports:read | Register downloads, where the licence allows |
webhooks:manage | Webhook endpoints and their delivery logs |
monitors:manage | Monitors of public list entries |
cases:read, cases:write | Cases, dispositions and evidence packs |
usage:read | Usage in units |
keys:manage | Creating and revoking keys |
Try it: a keyed request
curl:
curl https://api.crownintelligence.net/v1/sources \
-H "X-Api-Key: $CI_API_KEY" Node (20 or later):
const res = await fetch("https://api.crownintelligence.net/v1/sources", {
headers: { "X-Api-Key": process.env.CI_API_KEY }
});
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json(); Health check without a key:
curl https://api.crownintelligence.net/v1/status Billing reference: internal calls only
Every cross-service call carries a billing reference, sent in the X-Billing-Ref header as compact ASCII JSON of at most 1,024 bytes. It records who the call is for, so that usage can be billed and cost recorded.
{"source_service":"partner-api","source_account":"acct_demo_bank",
"order_id":"ord_2026_1041","type":"ORDER","parent_call":null,"invoice_line_id":null} - Partner calls may leave the header out. The gateway fills
source_serviceandsource_accountfrom the authenticated key, and takes the order from the key'sorder_id. A partner cannot set these itself. If a sent header disagrees with its key, the call is refused with 400billing_ref_invalid. - Internal calls must send it. A Crown service calling Crown Intelligence without the header is refused with 400
billing_ref_missing. The object passes down a chain unchanged, and each hop setsparent_callto the call that triggered it. typeisORDERwhen a customer pays for the call. It isOVERHEADfor system traffic, with no invoice and cost still recorded. AnORDERmust carryorder_id; anOVERHEADcarriesorder_id: null.- No other fields are allowed. The API never shows money: usage is reported in units only, and prices are set on the invoice.
// Internal Crown service calling match: pass the caller's billing_ref unchanged in X-Billing-Ref.
const billingRef = {
source_service: "your-registered-service-id",
source_account: null,
order_id: null,
type: "OVERHEAD",
parent_call: incomingBillingRefId, // id of the call that triggered this one, or null at the top
invoice_line_id: null
};
const res = await fetch("https://api.crownintelligence.net/v1/match", {
method: "POST",
headers: {
"X-Api-Key": process.env.CI_INTERNAL_KEY,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
"X-Billing-Ref": JSON.stringify(billingRef)
},
body: JSON.stringify({ queries: [{ query_ref: "svc-1", entity_type: "Company", name: "Northwind Trading LLC" }] })
}); Open items: the source identifier partner-api is not yet registered in the service catalogue, and the owner has not yet decided whether usage rows carry a price reference. See the contract, sections 4 and 16.
Match statuses: never clear by default
Every query in a match response has one of four statuses. The rule is simple: a query is reported as clear only when every register it should cover was searched and nothing matched. The word clear is not a value anywhere in the API.
| Status | Meaning | Can you treat it as no match? |
|---|---|---|
hit | At least one match at or above min_band | No. Review the matches. |
no_hit_searched | Every listed register was searched. Nothing matched. The response lists each register with loaded_at. | Yes. This is the only negative outcome. |
not_searched | Nothing was checked, for example an identifier no loaded register covers. The reason is given. | Never. Not checked is not clear. |
error | The check failed. Retry it. | Never. A failed check is not clear. |
A status of not_searched or error must never be shown as clear, stored as clear or passed on as clear. Build your decision logic so that only no_hit_searched can ever produce a negative result.
Match bands
Each match carries a band instead of a raw threshold. Bands are derived from the score (0 to 1) and the identifiers that agree.
| Band | Rule | Returned when |
|---|---|---|
STRONG | Score 0.90 or more, or an exact IMO, MMSI or LEI | Always, if at or above min_band |
POSSIBLE | Score 0.75 to 0.89 | The default min_band |
WEAK | Score 0.60 to 0.74 | Only when min_band is WEAK |
Nothing below 0.60 is returned. The band floors are proposed, not yet calibrated against a labelled set of pairs, so do not build a fixed rule on them until calibration is published. Matcher versions will be exposed on each response.
Rate limits and quotas
These defaults are proposed. Plan limits are placeholders until the owner sets them.
| Limit | Proposed default |
|---|---|
| Requests per minute, per key | 120 |
| Query units per minute, per key | 600 |
| Queries per batch | 100 |
| Export requests per minute, per key | 10 |
| Monthly units | Set per plan. Exceeding it returns 429 quota_exceeded. |
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Metered calls also carry X-Usage-Units. A 429 response carries Retry-After.
Idempotency and versions
Every POST accepts an Idempotency-Key of up to 255 characters. Repeating the same key with the same body within 24 hours returns the stored response with no second charge. Reusing the key with a different body returns 409 idempotency_mismatch.
The version is in the path (/v1). Changes within v1 are additive. A breaking change moves to /v2, with at least six months' notice of any deprecation.
Errors
Every error is application/problem+json (RFC 9457). Each error carries a stable code, a request_id to quote to support, and its message in English (messages.en) and French (messages.fr).
{"type":"https://api.crownintelligence.net/problems/invalid_request","title":"Invalid request",
"status":400,"code":"invalid_request","detail":"queries[3].name must be at least 2 characters",
"messages":{"en":"queries[3].name must be at least 2 characters",
"fr":"queries[3].name doit comporter au moins 2 caractères"},
"request_id":"req_01J9Z3K4M6","errors":[{"pointer":"/queries/3/name","code":"too_short",
"message":{"en":"Use at least 2 characters.","fr":"Utilisez au moins 2 caractères."}}]} | HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | The body or a parameter fails the schema |
| 400 | billing_ref_missing, billing_ref_invalid | An internal call has no billing reference, or its fields disagree with the key |
| 400 | personal_data_not_accepted | A monitor body carries a personal field |
| 401 | unauthenticated, key_revoked | The key is missing, unknown or revoked |
| 403 | forbidden_scope, forbidden_environment | The scope is missing, or a sandbox key calls a live route |
| 404 | not_found | Also used where a record is not visible to the account |
| 409 | conflict, idempotency_mismatch | A duplicate, or a reused Idempotency-Key with a different body |
| 413 | payload_too_large, batch_too_large | Over 1 MB, or more than 100 queries |
| 429 | rate_limited, quota_exceeded | See rate limits above. Wait for Retry-After. |
| 503 | register_unavailable, unavailable | A register or the service is down. Retry with backoff. |
| 500 | internal_error | Report it with the request_id |
In a batch, a single query can fail with an error status. The batch itself still returns 200, and that query is never reported as clear.
const res = await fetch(url, { headers: { "X-Api-Key": process.env.CI_API_KEY } });
if (!res.ok) {
const problem = await res.json(); // application/problem+json
console.error(problem.code, problem.request_id, problem.messages.en);
if (res.status === 429 || res.status === 503) {
const wait = Number(res.headers.get("Retry-After") ?? 5); // back off, then retry the same request
}
}