Verification

Checking a credential is free, public, and unauthenticated. No account, no app, no API key. Anyone is allowed to check anything, which is the point of the format.

Three ways to check

Open the link. Every credential has a page at /c/{id}, and that URL is what an issuer prints on the certificate. Opening it re-runs the full check: the signature, the issuer's revocation list, and the expiry date. Nothing is cached, so a credential revoked a minute ago reads as revoked.

Paste the credential. /verify takes a credential document. Workers get one from a wallet export; it is the JSON object with a proof on it. Use this when someone hands you a file instead of a link, and to check credentials from issuers that have nothing to do with valid.ca.

Scan a worker's wallet. Start a scan on the verify page and the worker scans the code with their wallet. The wallet signs the credential for that one check, with a key that never leaves their phone, so a copy of someone else's credential does not pass. Use it at a gate or a site office where the worker is standing in front of you. The result shows the worker's name, to check against their ID.

What the result means

Result What happened
Approved valid.ca issuer The signature is valid, the credential is not revoked or expired, and the issuer is approved and active on valid.ca.
Listed on CCTA The signature is valid and the issuer appears on another published trust list. The name is whichever list matched.
Signature valid, issuer not recognized The document is intact and genuinely signed by whoever it names. valid.ca has not approved that issuer and no other list recognizes them. Judge the issuer yourself.
Credential could not be verified The signature, the revocation check, or the expiry check failed. Treat the credential as unusable.

The third result is the one worth understanding. A credential valid.ca never touched can still be cryptographically perfect. "Not recognized" is a statement about the issuer, and the document itself is intact. Approval is what the trust registry adds on top of the mathematics.

The machine-readable path

POST /verify renders the verifier page for a person to read. Software has two JSON endpoints.

Checking a credential document

Post a credential document to /verify/credentials and the result comes straight back. This is for software that already holds the document, such as a file a worker uploaded.

curl -X POST https://valid.ca/verify/credentials \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"credential": { "...": "the credential document" }}'
$result = Http::acceptJson()
    ->post('https://valid.ca/verify/credentials', ['credential' => $credentialDocument])
    ->json();

if ($result['verified'] && $result['trust'] === 'approved') {
    // The credential is genuine, current, and from an issuer valid.ca approved.
}
{
    "verified": true,
    "trust": "approved",
    "trust_label": "Approved valid.ca issuer",
    "registry": null,
    "error": null,
    "issuer_did": "did:web:worksitesafety.ca",
    "revoked": false,
    "expired": false
}

Decide on verified first, then on trust, which is approved, listed, or unrecognized. trust_label is the same thing in words, ready to display. When verified is false, error says why in a sentence you can show a user, and revoked and expired tell you whether the cause was a withdrawn credential or an old one.

A document proves the credential is genuine. It does not prove the person handing it over is the one it was issued to. For that, scan their wallet.

Scanning a worker's wallet

Start a request. Nothing is required in the body.

curl -X POST https://valid.ca/verify/requests \
  -H "Accept: application/json"

The request is good for ten minutes.

{
    "id": "e092814b-7e79-442b-8461-893c6336d757",
    "request_uri": "https://valid.ca/verify/requests/e092814b-7e79-442b-8461-893c6336d757/request",
    "qr": "openid4vp://?client_id=decentralized_identifier%3Adid%3Aweb%3Avalid.ca&request_uri=https%3A%2F%2Fvalid.ca%2Fverify%2Frequests%2Fe092814b-7e79-442b-8461-893c6336d757%2Frequest",
    "result_token": "Jm4qT0x7…"
}

Render qr as a QR code and show it to the worker. Then poll GET /verify/requests/{id}/result every couple of seconds, sending result_token as a bearer token. It holds {"result": null} until the wallet answers, and the answer stays readable there for ten minutes.

Keep result_token to yourself. The id is inside the QR code, so anyone who sees the code has it, but without the token the result answers 404 as though the request did not exist. A photo of the screen reads nothing.

const { id, qr, result_token } = await (
    await fetch('https://valid.ca/verify/requests', {
        method: 'POST',
        headers: { Accept: 'application/json' },
    })
).json();

// Show qr as a QR code, then wait for the wallet.
let result = null;
while (result === null) {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    ({ result } = await (
        await fetch(`https://valid.ca/verify/requests/${id}/result`, {
            headers: { Authorization: `Bearer ${result_token}` },
        })
    ).json());
}

The result is the same as for a document, plus the claims the worker's wallet disclosed. claims is null unless verified is true.

{
    "result": {
        "verified": true,
        "trust": "approved",
        "trust_label": "Approved valid.ca issuer",
        "registry": null,
        "error": null,
        "issuer_did": "did:web:worksitesafety.ca",
        "revoked": false,
        "expired": false,
        "claims": {
            "achievement": "Working at Heights",
            "issuer_name": "Worksite Safety",
            "awarded_on": "2026-04-30",
            "holder_name": "Jordan Blake"
        }
    }
}

Check holder_name against the person in front of you. The wallet proves the credential is on the phone it was issued to, not who is holding the phone. A worker can keep their name back, in which case claims has everything but holder_name.

A copy on a phone keeps the expiry it was signed with. When that date has passed but the issuer has since extended the credential, the result is still verified: false and expired: true, since an expired credential cannot be accepted. renewal then holds the issuer's current expires_at (null for no expiry), and claims are included so you can check the worker's name. Otherwise renewal is null.

A worker who says no shows up as verified: false with the error "The wallet declined to share a credential." Starting a request and posting to one are each limited to 30 a minute from one address.

What the wallet does

The rest is between the wallet and valid.ca, under OpenID4VP 1.0. It is here for wallet developers.

The wallet fetches request_uri and gets a request object, a JWT of type oauth-authz-req+jwt signed by did:web:valid.ca. The key is in https://valid.ca/.well-known/did.json, so the wallet can show the worker who is asking. The payload asks for a valid.ca safety training SD-JWT, and for its achievement, issuer_name, awarded_on, and holder_name claims:

{
    "client_id": "decentralized_identifier:did:web:valid.ca",
    "response_type": "vp_token",
    "response_mode": "direct_post",
    "response_uri": "https://valid.ca/verify/requests/e092814b-7e79-442b-8461-893c6336d757",
    "nonce": "5vbHV6xBJtRKyeDHOJ13htdemb3haxev",
    "dcql_query": {
        "credentials": [
            {
                "id": "training",
                "format": "dc+sd-jwt",
                "meta": {
                    "vct_values": ["https://valid.ca/vct/safety-training"]
                },
                "claims": [
                    { "path": ["achievement"] },
                    { "path": ["issuer_name"] },
                    { "path": ["awarded_on"] },
                    { "path": ["holder_name"] }
                ]
            }
        ]
    }
}

The wallet answers with a form post to response_uri. vp_token is JSON keyed by the query id, holding one presentation: the SD-JWT, the disclosures the worker agreed to, and a key binding JWT.

curl -X POST https://valid.ca/verify/requests/e092814b-7e79-442b-8461-893c6336d757 \
  --data-urlencode 'vp_token={"training":["eyJhbGciOiJFZERTQSIs...~WyJhMWIy...~eyJ0eXAiOiJrYitqd3Qi..."]}'

The key binding JWT has typ kb+jwt and is signed with the key in the SD-JWT's cnf claim. Its payload carries the request's nonce, the client_id as aud, an iat within the last five minutes, and sd_hash, the SHA-256 of everything before it. A presentation that fails any of those is answered and recorded as unverified.

Each request takes one answer. Anything after the first gets 400 invalid_request, so nobody else who can see the QR code can answer it. An answer valid.ca cannot read at all, such as one without the training key, also gets 400 and leaves the request open. A wallet whose worker declines posts error=access_denied instead.

What a verifier fetches

Verification deliberately does not depend on valid.ca being trusted, or even reachable.

  1. Read issuer.id from the credential, for example did:web:worksitesafety.ca.
  2. Resolve the public key from https://worksitesafety.ca/.well-known/did.json.
  3. Check the proof against the key that document lists.
  4. Fetch the revocation list named in credentialStatus, on that same domain, and read the one bit belonging to this credential. For an SD-JWT, fetch the uri in its status claim with Accept: application/statuslist+jwt, check the issuer signed the token, and read bit idx, counting from each byte's lowest bit.
  5. Compare validUntil to now.

Only then does valid.ca enter the picture, and only to answer one question: is this issuer approved? That answer is a signed document at /.well-known/valid-issuers.json, which anyone can keep a copy of and check themselves.

Each check is recorded with the credential, the issuer, the result, and the requester's IP address. Issuers see a daily count per credential in their portal. Holders are not notified, and neither issuers nor holders are shown who checked.