How to verify an apier certificate
Request a signed verification certificate with ?certificate=true and verify its detached JWS offline against the public JWKS, with a copy-paste script.
[Cite this as: Apier.no Docs v0.1.0 — last updated 2026-10-14]
A verification certificate turns a /verify or /authority answer into a
self-verifiable, signed artifact. Instead of trusting that the JSON you got
really came from Apier, you can check a cryptographic signature against Apier's
public key, with no re-call to /verify or /authority. You still fetch
the public JWKS once (cache it and even that goes away), but the verdict itself
is checked locally. Useful when you need to hand a verdict to a third party (an
auditor, a counterparty, your own archive) who should be able to confirm it
independently.
Request a certificate
Add ?certificate=true to either read endpoint:
# Replace {org} with a real organisasjonsnummer; 999999999 exists only in the sandbox.
curl -H "Authorization: Bearer apr_free_<your_key_here>" \
"https://www.apier.no/api/v1/company/{org}/verify?certificate=true"The same flag works on /api/v1/company/{org}/authority?certificate=true.
Without the flag (or with certificate=false) the response is unchanged:
the bare verdict you already know. Any value other than true/false returns
400 VALIDATION_FAILED: the flag changes the response shape, so a typo fails
loudly rather than silently handing back the bare result.
The certificate is returned in the response data:
{
"success": true,
"data": {
"result": {
"org_number": "999999999",
"name": "Eksempel AS",
"verification_status": "pass",
"...": "..."
},
"issuer": "https://www.apier.no",
"issued_at": "2026-06-14T12:00:00+02:00",
"kid": "apier-signing-key-1",
"jwks_uri": "https://www.apier.no/.well-known/jwks.json",
"rulebook_version": "1.0.0",
"audit_log_ref": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"signature": "<base64url-header>..<base64url-signature>"
},
"_meta": { "...": "..." }
}Every field is reused from the underlying response; the certificate adds no
new verification claim. It only wraps the existing result in a signing
envelope:
| Field | Meaning |
|---|---|
result | The exact /verify or /authority answer, unchanged. |
issuer | Who issued the certificate (https://www.apier.no). |
issued_at | When it was issued — ISO 8601 with the Europe/Oslo offset. |
kid | The signing key id — matches an entry in the public JWKS. |
jwks_uri | Where the issuer publishes its public key. Informational: a verifier fetches keys from a key set it configured itself, never from this field. |
rulebook_version | The rulebook version behind result. |
audit_log_ref | SHA-256 response hash (sha256:…) of the wrapped result as issued, the same hash a plain response carries as _meta.response_hash. It equals the _meta.response_hash of the same query without ?certificate=true only when that plain response was produced in the same wall-clock second, because result carries a per-request timestamp (verification_timestamp on /verify, resolved_at on /authority). It is a fingerprint of the certified verdict, not a lookup key into any stored record. It hashes the bare verdict, not this certificate envelope, so it differs from this response's own _meta.response_hash. |
signature | A detached compact JWS over the canonical certificate (everything except signature itself). |
In the sandbox
Only a production certificate proves anything. The sandbox issues a sandbox certificate so you can rehearse the verification code before you hold a real key:
- The bearer sandbox (
/api/v1/sandbox/company/{org}/verifyand/api/v1/sandbox/company/{org}/authority) accepts?certificate=trueand returns the same envelope as production, withis_sandbox: true, kidapier-sandbox-cert-1and ajwks_uriofhttps://www.apier.no/.well-known/sandbox-jwks.json. It is signed with a dedicated Ed25519 key (JWSalgEdDSA), so thejoseexample below verifies it once your test code pointsTRUSTED_JWKS_URLat the sandbox key set and acceptsis_sandbox. The sandbox signing key is public and untrusted by design: anyone can sign a sandbox certificate, so it proves nothing about Apier. Its public key is never published in/.well-known/jwks.json, so code that selects keys from the production key set rejects every sandbox certificate. Reject any certificate that carriesis_sandboxin production code. - The zero-auth public mirror (
/api/v1/sandbox/public/company/999999999/verifyand.../authority) ignores the flag and returns the bare result. There is nosignaturefield, so check for one before you treat any response as certified.
A sandbox certificate is issued at the fixed sandbox instant, so it is byte-identical across calls.
What is signed
The signature is a detached JWS (RFC 7515 Appendix F): a base64url protected
header ({ "alg": "RS256", "kid": "…", "typ": "apier-verify-cert+jws" }), an
empty middle segment, and the base64url signature, joined by dots —
header..signature. Production certificates are signed with RS256; sandbox
certificates use alg EdDSA (Ed25519) instead, with everything else the
same. It covers the canonical JSON of the certificate with
the signature field removed: object keys sorted at every level, no whitespace,
UTF-8. To verify, you reproduce those exact bytes and check them against the
production key set you configured (see the example below), selecting the key by
kid.
Verify it offline
"Offline" here means the verdict is checked locally against Apier's public
key: there is no re-call to /verify or /authority. You do fetch the public
JWKS once to get that key, but it is cacheable (and selecting the key by kid
keeps old certificates verifiable across key rotation), so steady-state
verification needs no network round-trip. This example uses jose (install it
in your own project) and re-attaches the canonical payload so a standard compact
verify can run. It trusts only the production key set it names itself, never
the certificate's own jwks_uri: the sandbox signing key is public, so
anyone can sign a certificate and point its jwks_uri at a key they
control.
import { createRemoteJWKSet, compactVerify, base64url } from "jose";
// Configure the trusted issuer and key set in your own code.
const TRUSTED_ISSUER = "https://www.apier.no";
const TRUSTED_JWKS_URL = "https://www.apier.no/.well-known/jwks.json";
const JWKS = createRemoteJWKSet(new URL(TRUSTED_JWKS_URL));
// Canonical JSON — sorted keys, no whitespace (identical to the server).
const canon = (v) =>
JSON.stringify(v, (_, val) =>
val && typeof val === "object" && !Array.isArray(val)
? Object.fromEntries(
Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
)
: val,
);
// `cert` is the `data` object from a ?certificate=true response.
if (cert.is_sandbox || cert.issuer !== TRUSTED_ISSUER) {
throw new Error("not a production Apier certificate");
}
const { signature, ...claims } = cert;
const [header, , sig] = signature.split(".");
const jws = `${header}.${base64url.encode(canon(claims))}.${sig}`;
await compactVerify(jws, JWKS); // throws if the signature does not matchIf compactVerify resolves, the certificate is authentic and untampered. If any
field in claims was altered after signing (a flipped verification_status, a
swapped audit_log_ref), the canonical bytes no longer match and the verify
throws. Selecting the key by kid against the JWKS means a rotated key keeps
old certificates verifiable as long as its public half stays published. Apier
keeps every retired signing key published in the JWKS permanently, marked
with apier_retired_at (the date it stopped signing), so a certificate signed
under a prior kid stays verifiable for its whole life. The entry without
apier_retired_at is the current key; only that key signs new certificates. A
standard JWKS consumer ignores the extra member and selects a retired key by
kid exactly as it selects the current one.
Notes
- Same key as Maskinporten discovery. The certificate is signed with the key
whose public half Apier already publishes at
https://www.apier.no/.well-known/jwks.json. The protected header'styp: "apier-verify-cert+jws"distinguishes a certificate from any other artifact signed with that key. Because it is the Maskinporten client key, a Maskinporten key rotation is a certificate-key rotation too; the retired public key stays published as described above. - Nothing extra is stored. A certificate is generated per request from the verdict computed for this request; nothing is stored, and requesting one persists no new data.
- When signing is unavailable. If the signing key is not configured in an
environment,
?certificate=truereturns503 CERTIFICATE_SIGNING_UNAVAILABLEand the bare-result path is unaffected; drop the flag to get the verdict.
Inspecting actions with the audit trail
Agents have no dashboard. Use the signed receipt, the audit endpoint, correlation_id and initiated_by to reconstruct exactly what an action did.
How to check a receipt in the public receipt log
Every receipt Apier issues is logged as a hash in a public, append-only hash chain, anchored daily. Check that your receipt was logged, when, and that the log up to its daily anchor was not rewritten, with no API key.