Skip to content
Apier.no
Guides

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:

Request a certificate
FieldMeaning
resultThe exact /verify or /authority answer, unchanged.
issuerWho issued the certificate (https://www.apier.no).
issued_atWhen it was issued — ISO 8601 with the Europe/Oslo offset.
kidThe signing key id — matches an entry in the public JWKS.
jwks_uriWhere the issuer publishes its public key. Informational: a verifier fetches keys from a key set it configured itself, never from this field.
rulebook_versionThe rulebook version behind result.
audit_log_refSHA-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.
signatureA 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}/verify and /api/v1/sandbox/company/{org}/authority) accepts ?certificate=true and returns the same envelope as production, with is_sandbox: true, kid apier-sandbox-cert-1 and a jwks_uri of https://www.apier.no/.well-known/sandbox-jwks.json. It is signed with a dedicated Ed25519 key (JWS alg EdDSA), so the jose example below verifies it once your test code points TRUSTED_JWKS_URL at the sandbox key set and accepts is_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 carries is_sandbox in production code.
  • The zero-auth public mirror (/api/v1/sandbox/public/company/999999999/verify and .../authority) ignores the flag and returns the bare result. There is no signature field, 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 match

If 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's typ: "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=true returns 503 CERTIFICATE_SIGNING_UNAVAILABLE and the bare-result path is unaffected; drop the flag to get the verdict.
Building with an LLM? Read llms.txt for agent-oriented integration guidance.