Issuer API

Four endpoints: issue a certificate, look one up, change its expiry, revoke it. Everything is JSON over HTTPS.

If you are integrating for the first time, read How issuing works first. It walks one certificate through the whole system and explains what valid.ca signs on your behalf.

Base URL and authentication

https://valid.ca/api/v1
Authorization: Bearer valid_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

Your first key appears in the issuer portal once valid.ca approves your application and your membership starts. Create more at /portal/api-keys. The full key is shown once. Afterwards the portal lists only its first twelve characters, so store it the way you store any other secret and create a second key rather than trying to recover one.

A key identifies your issuer account. Every request is scoped to it: you only ever see and change your own credentials.

An issuer can hold several keys at once, which is how you rotate. Create the new key, deploy it, and stop using the old one.

Issue a certificate

POST /api/v1/credentials

curl https://valid.ca/api/v1/credentials \
  -H "Authorization: Bearer $VALID_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "source_ref": "WSS-2026-01482",
    "holder_name": "Jordan Blake",
    "holder_email": "[email protected]",
    "achievement_name": "Working at Heights",
    "achievement_description": "Fall protection training for work above 3 metres.",
    "criteria": "Completed 8 hours of instruction and passed the written evaluation.",
    "expires_at": "2029-04-30"
  }'
use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.valid.key'))
    ->acceptJson()
    ->post('https://valid.ca/api/v1/credentials', [
        'source_ref' => 'WSS-2026-01482',
        'holder_name' => 'Jordan Blake',
        'holder_email' => '[email protected]',
        'achievement_name' => 'Working at Heights',
        'achievement_description' => 'Fall protection training for work above 3 metres.',
        'criteria' => 'Completed 8 hours of instruction and passed the written evaluation.',
        'expires_at' => '2029-04-30',
    ])
    ->throw();

$certificate->update([
    'valid_credential_id' => $response->json('data.id'),
    'valid_verify_url' => $response->json('data.verify_url'),
]);
const response = await fetch('https://valid.ca/api/v1/credentials', {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${process.env.VALID_API_KEY}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
    },
    body: JSON.stringify({
        source_ref: 'WSS-2026-01482',
        holder_name: 'Jordan Blake',
        holder_email: '[email protected]',
        achievement_name: 'Working at Heights',
        achievement_description:
            'Fall protection training for work above 3 metres.',
        criteria:
            'Completed 8 hours of instruction and passed the written evaluation.',
        expires_at: '2029-04-30',
    }),
});

if (!response.ok) {
    throw new Error(
        `valid.ca returned ${response.status}: ${await response.text()}`,
    );
}

const { data } = await response.json();
console.log(data.id, data.verify_url);
import os

import requests

response = requests.post(
    "https://valid.ca/api/v1/credentials",
    headers={
        "Authorization": f"Bearer {os.environ['VALID_API_KEY']}",
        "Accept": "application/json",
    },
    json={
        "source_ref": "WSS-2026-01482",
        "holder_name": "Jordan Blake",
        "holder_email": "[email protected]",
        "achievement_name": "Working at Heights",
        "achievement_description": "Fall protection training for work above 3 metres.",
        "criteria": "Completed 8 hours of instruction and passed the written evaluation.",
        "expires_at": "2029-04-30",
    },
    timeout=30,
)
response.raise_for_status()

data = response.json()["data"]
print(data["id"], data["verify_url"])

Signing happens during the request and the holder's email goes out before it returns, so budget a second or two rather than treating this as a fast write. Call it from a queued job.

Request fields

Field Required Notes
source_ref Yes Your own certificate number. Unique per issuer, and the key that makes this call idempotent. Avoid slashes: the lookup endpoint takes this value in the path.
holder_name Yes The worker's name, as it should read on the credential.
holder_email Yes Receives the wallet email. Only its SHA-256 hash goes into the credential.
achievement_name In practice The course name. Optional in the validator, required to build a credential. Send it unless you are sending achievement_template_id.
achievement_description No One or two sentences about the course.
criteria No What the worker did to earn it. Defaults to "Completed the required training."
achievement_template_id No A template you own, from /portal/templates. Its name, description, and criteria override the fields above, and its years_valid sets the expiry when expires_at is absent. A template id belonging to another issuer is ignored.
expires_at No Any date your language formats sanely, such as 2029-04-30 or a full ISO 8601 timestamp. Omit it for a credential that never expires.
awarded_on No The day the worker earned the certificate, such as 2026-04-30. Signed into the credential as awardedDate and shown in the worker's wallet. Defaults to the day you call.
certificate_number No The number printed on your certificate, if it differs from source_ref or you want it shown. The worker's wallet holds it as a claim they choose whether to share.

Response

201 the first time. 200 when that source_ref already exists, with the credential you issued originally.

{
    "data": {
        "id": "459d2cb7-46d6-44d2-8267-60b87b21090a",
        "verify_url": "https://valid.ca/c/459d2cb7-46d6-44d2-8267-60b87b21090a",
        "source_ref": "WSS-2026-01482",
        "holder_name": "Jordan Blake",
        "awarded_on": "2026-04-30",
        "certificate_number": "WSS-2026-01482",
        "revoked_at": null,
        "expires_at": "2029-04-30T00:00:00+00:00",
        "credential": { "comment": "signed Open Badges 3.0 credential" },
        "offer_pin": "603778"
    }
}
Field Notes
id Credential UUID. Use it for PATCH and revoke.
verify_url The public page, https://valid.ca/c/{id}. Print it or a QR code of it on the certificate.
source_ref Echoes what you sent.
holder_name Echoes what you sent.
awarded_on The award date, as YYYY-MM-DD.
certificate_number Echoes what you sent, or null.
revoked_at null, or an ISO 8601 timestamp.
expires_at null, or an ISO 8601 timestamp.
credential The signed Open Badges 3.0 document. See the full example.
offer_pin Six digits the holder types into a wallet when redeeming the OpenID4VCI offer in their email.

Every one of the four endpoints returns this same data object, so one response parser covers all of them.

Look up a certificate

GET /api/v1/credentials/{source_ref}

Addressed by your certificate number. Useful when you did not store what valid.ca returned, or to check whether a backfill already covered a record.

curl https://valid.ca/api/v1/credentials/WSS-2026-01482 \
  -H "Authorization: Bearer $VALID_API_KEY" \
  -H "Accept: application/json"
$response = Http::withToken(config('services.valid.key'))
    ->acceptJson()
    ->get('https://valid.ca/api/v1/credentials/'.urlencode($certificate->number));

$credential = $response->ok() ? $response->json('data') : null;
const response = await fetch(
    `https://valid.ca/api/v1/credentials/${encodeURIComponent(sourceRef)}`,
    {
        headers: {
            Authorization: `Bearer ${process.env.VALID_API_KEY}`,
            Accept: 'application/json',
        },
    },
);

const credential = response.ok ? (await response.json()).data : null;
response = requests.get(
    f"https://valid.ca/api/v1/credentials/{quote(source_ref)}",
    headers={
        "Authorization": f"Bearer {os.environ['VALID_API_KEY']}",
        "Accept": "application/json",
    },
    timeout=30,
)

credential = response.json()["data"] if response.ok else None

A source_ref you have never issued returns 404.

Change the expiry

PATCH /api/v1/credentials/{id}

Takes the credential UUID. Sending expires_at re-signs the credential with the new date. Sending null, or omitting the field, re-signs it with no expiry at all.

curl -X PATCH https://valid.ca/api/v1/credentials/459d2cb7-46d6-44d2-8267-60b87b21090a \
  -H "Authorization: Bearer $VALID_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"expires_at": "2030-04-30"}'
$response = Http::withToken(config('services.valid.key'))
    ->acceptJson()
    ->patch('https://valid.ca/api/v1/credentials/'.$certificate->valid_credential_id, [
        'expires_at' => '2030-04-30',
    ])
    ->throw();
const response = await fetch(
    `https://valid.ca/api/v1/credentials/${credentialId}`,
    {
        method: 'PATCH',
        headers: {
            Authorization: `Bearer ${process.env.VALID_API_KEY}`,
            'Content-Type': 'application/json',
            Accept: 'application/json',
        },
        body: JSON.stringify({ expires_at: '2030-04-30' }),
    },
);
response = requests.patch(
    f"https://valid.ca/api/v1/credentials/{credential_id}",
    headers={
        "Authorization": f"Bearer {os.environ['VALID_API_KEY']}",
        "Accept": "application/json",
    },
    json={"expires_at": "2030-04-30"},
    timeout=30,
)
response.raise_for_status()

The new document replaces the old one everywhere valid.ca serves it, including verify_url. A copy a wallet already downloaded still carries the old date, so a verifier who sees the old copy sees the old expiry. When you extend a credential and the worker's phone copy has passed its old date, valid.ca's own scan still reports the copy as expired, but adds that you have renewed it, with the new date and the worker's name. Other verifiers see only the old date. For a correction that matters, revoke and reissue.

Revoke a certificate

POST /api/v1/credentials/{id}/revoke

Takes the credential UUID. Call it when a certificate is withdrawn, voided, or deleted on your side.

curl -X POST https://valid.ca/api/v1/credentials/459d2cb7-46d6-44d2-8267-60b87b21090a/revoke \
  -H "Authorization: Bearer $VALID_API_KEY" \
  -H "Accept: application/json"
Http::withToken(config('services.valid.key'))
    ->acceptJson()
    ->post('https://valid.ca/api/v1/credentials/'.$certificate->valid_credential_id.'/revoke')
    ->throw();
const response = await fetch(
    `https://valid.ca/api/v1/credentials/${credentialId}/revoke`,
    {
        method: 'POST',
        headers: {
            Authorization: `Bearer ${process.env.VALID_API_KEY}`,
            Accept: 'application/json',
        },
    },
);
response = requests.post(
    f"https://valid.ca/api/v1/credentials/{credential_id}/revoke",
    headers={
        "Authorization": f"Bearer {os.environ['VALID_API_KEY']}",
        "Accept": "application/json",
    },
    timeout=30,
)
response.raise_for_status()

The credential itself is not rewritten. One bit flips in your Bitstring Status List, and every verifier that checks the list reports the credential as revoked from then on. The response has revoked_at set. Revoking an already-revoked credential succeeds and changes nothing.

Two identifiers

The one thing that trips people up: lookup uses your certificate number, while changing and revoking use the UUID.

Call Identifier
POST /credentials None. source_ref is in the body.
GET /credentials/{source_ref} Your certificate number
PATCH /credentials/{id} Credential UUID
POST /credentials/{id}/revoke Credential UUID

Passing a certificate number where a UUID belongs returns 404. Store data.id next to your own record at issue time and this never comes up.

Errors

Status Meaning
401 No bearer token, or a key valid.ca does not recognize.
402 Your membership has ended. Keys work again once it restarts.
403 Your issuer account is suspended. Keys stop working for as long as it is.
404 Unknown source_ref, unknown UUID, or a UUID belonging to another issuer.
422 The body failed validation.
500 No usable achievement name. Send achievement_name, or a template id you own.

Validation errors come back in Laravel's shape, one array of messages per field:

{
    "message": "The holder email field must be a valid email address.",
    "errors": {
        "holder_email": [
            "The holder email field must be a valid email address."
        ]
    }
}

Retry a 500 or a network timeout with the same source_ref. If the first attempt got far enough to issue, you get that credential back with a 200. If it did not, you get a fresh 201. Either way you do not end up with two credentials for one certificate.

Wallet delivery

This is how a wallet collects a credential after you issue it. You do not need these endpoints to create one.

The holder's email contains a QR code holding an openid-credential-offer:// link, and a six-digit code. Redeeming them is standard OpenID for Verifiable Credential Issuance 1.0, with a pre-authorized code. The credential issuer is https://valid.ca/i/{issuer-slug}, and a wallet finds everything else from there:

GET  /.well-known/openid-credential-issuer/i/{issuer-slug}
GET  /.well-known/oauth-authorization-server/i/{issuer-slug}
GET  /i/{issuer-slug}/offers/{pre-authorized-code}
POST /i/{issuer-slug}/token
POST /i/{issuer-slug}/credential

Wallets built on earlier drafts can read the same two metadata documents at /i/{issuer-slug}/.well-known/openid-credential-issuer and /i/{issuer-slug}/.well-known/oauth-authorization-server.

Issuer metadata advertises both formats:

{
    "credential_issuer": "https://valid.ca/i/worksitesafety-ca",
    "credential_endpoint": "https://valid.ca/i/worksitesafety-ca/credential",
    "nonce_endpoint": "https://valid.ca/i/worksitesafety-ca/nonce",
    "display": [{ "name": "Worksite Safety", "locale": "en-CA" }],
    "credential_configurations_supported": {
        "OpenBadgeCredential": {
            "format": "ldp_vc",
            "credential_definition": {
                "@context": [
                    "https://www.w3.org/ns/credentials/v2",
                    "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
                ],
                "type": ["VerifiableCredential", "OpenBadgeCredential"]
            },
            "credential_signing_alg_values_supported": ["eddsa-rdfc-2022"]
        },
        "SafetyTrainingSdJwt": {
            "format": "dc+sd-jwt",
            "vct": "https://valid.ca/vct/safety-training",
            "credential_signing_alg_values_supported": ["EdDSA"],
            "cryptographic_binding_methods_supported": ["jwk"],
            "proof_types_supported": {
                "jwt": {
                    "proof_signing_alg_values_supported": ["ES256", "EdDSA"]
                }
            }
        }
    }
}

Each configuration also carries credential_metadata, left out above, which tells a wallet how to draw the card and what to call each claim. Both formats share the card. The Open Badge's looks like this:

{
    "display": [
        {
            "name": "Safety training certificate",
            "locale": "en-CA",
            "logo": {
                "uri": "https://valid.ca/icon-512.png",
                "alt_text": "valid.ca"
            },
            "background_color": "#002332",
            "text_color": "#FFFFFF"
        }
    ],
    "claims": [
        {
            "path": ["credentialSubject", "achievement", "name"],
            "display": [{ "name": "Training", "locale": "en-CA" }]
        },
        {
            "path": ["credentialSubject", "name"],
            "display": [{ "name": "Name", "locale": "en-CA" }]
        },
        {
            "path": ["validUntil"],
            "display": [{ "name": "Expires", "locale": "en-CA" }]
        }
    ]
}

The SD-JWT is the format a worker presents in person, so it is bound to a key the wallet holds, and only that wallet can present it. The wallet proves it holds the key when it collects the credential, as below. The Open Badge is the copy a worker shares as a file or link, and needs no proof.

Each issuer is its own authorization server, and this is where a wallet finds the token endpoint:

{
    "issuer": "https://valid.ca/i/worksitesafety-ca",
    "token_endpoint": "https://valid.ca/i/worksitesafety-ca/token",
    "grant_types_supported": [
        "urn:ietf:params:oauth:grant-type:pre-authorized_code"
    ],
    "pre-authorized_grant_anonymous_access_supported": true,
    "token_endpoint_auth_methods_supported": ["none"],
    "response_types_supported": []
}

The offer behind the QR code names both formats and asks for the six-digit code:

{
    "credential_issuer": "https://valid.ca/i/worksitesafety-ca",
    "credential_configuration_ids": [
        "OpenBadgeCredential",
        "SafetyTrainingSdJwt"
    ],
    "grants": {
        "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
            "pre-authorized_code": "VxS0Xq...",
            "tx_code": {
                "input_mode": "numeric",
                "length": 6,
                "description": "Enter the 6-digit code from your valid.ca email"
            }
        }
    }
}

An offer that can no longer be redeemed answers 410 before the wallet asks for the code: offer_used, offer_locked, or offer_unavailable once the credential is revoked or the issuer suspended. An unknown code is 404.

Exchange the code and the six digits for an access token. The six digits are tx_code, the offer_pin from the issue response. Send the body form-encoded, as OAuth specifies; JSON works too.

curl -X POST https://valid.ca/i/worksitesafety-ca/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code \
  -d pre-authorized_code=VxS0Xq... \
  -d tx_code=603778

The token lasts ten minutes.

{
    "access_token": "Qp3f...",
    "token_type": "Bearer",
    "expires_in": 600,
    "c_nonce": "pX0wQ2...",
    "c_nonce_expires_in": 300
}

c_nonce is for wallets built on draft 13, which have no nonce endpoint. Wallets on 1.0 fetch one from the nonce endpoint instead.

The code is single use. Errors follow OAuth, with an error_description a wallet can show the worker:

Status error When
400 invalid_request grant_type, pre-authorized_code, or tx_code is missing.
400 unsupported_grant_type Any grant but the pre-authorized code.
400 invalid_grant The code is unknown or another issuer's, the offer was used or locked, the credential was revoked, or the six digits are wrong.
429 slow_down More than 20 token requests a minute from one address.

A wrong code says how many tries are left. The tenth wrong code locks the offer for good, so someone holding a forwarded QR code cannot guess their way in:

{
    "error": "invalid_grant",
    "error_description": "That code is not right. You have 9 tries left.",
    "attempts_remaining": 9
}

Then collect the credential with that token, naming the format with credential_configuration_id. The Open Badge needs nothing more:

curl -X POST https://valid.ca/i/worksitesafety-ca/credential \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"credential_configuration_id": "OpenBadgeCredential"}'

The SD-JWT needs a proof that the wallet holds its key. Fetch a nonce first. It works once, for five minutes, and needs no token:

curl -X POST https://valid.ca/i/worksitesafety-ca/nonce
{ "c_nonce": "pX0wQ2..." }

Sign a JWT with the wallet key, ES256 or EdDSA, and put the public key in its header as jwk:

{
    "typ": "openid4vci-proof+jwt",
    "alg": "ES256",
    "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
{
    "aud": "https://valid.ca/i/worksitesafety-ca",
    "iat": 1791475200,
    "nonce": "pX0wQ2..."
}

aud is the credential issuer identifier, and iat has to be within the last five minutes. Send the proof with the request:

curl -X POST https://valid.ca/i/worksitesafety-ca/credential \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"credential_configuration_id": "SafetyTrainingSdJwt", "proofs": {"jwt": ["eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIs..."]}}'

The SD-JWT is signed there and then, with the wallet's public key in its cnf claim. valid.ca does not keep a copy. Its public claims are achievement, issuer_name, awarded_on (YYYY-MM-DD), and exp when the credential expires. holder_name, holder_email, verify_url, and certificate_number (when the issuer sent one) are disclosures the worker chooses whether to share. The token works for its whole ten minutes, so a wallet can collect both formats with it.

{
    "credentials": [{ "credential": "eyJhbGciOiJFZERTQSIs..." }]
}

Wallets built on draft 13 name a format instead (ldp_vc, dc+sd-jwt, or vc+sd-jwt), or send nothing for the Open Badge, and get back the single {"credential": ...} they expect. They send their proof as "proof": {"proof_type": "jwt", "jwt": "..."}.

Status error When
401 invalid_token The token is missing, unknown, expired, or another issuer's.
400 unknown_credential_configuration A credential_configuration_id this issuer does not offer.
400 unsupported_credential_format A draft 13 format this issuer does not offer.
400 invalid_credential_request Both credential_configuration_id and format.
400 unknown_credential_identifier A credential_identifier. This issuer hands none out.
400 credential_request_denied The credential was revoked, or the issuer suspended, after the token was granted.
400 invalid_proof The SD-JWT was asked for without a proof, or the proof is not valid.
400 invalid_nonce The proof's nonce is missing, unknown, spent, or over five minutes old.

Both proof errors carry a fresh c_nonce, so the wallet can sign a new proof and try again straight away.

The pre-authorized code is not in the issue response. It only ever reaches the holder, in their email, which is what keeps wallet delivery between valid.ca and the worker. The code does not expire. OpenID4VCI expects a short-lived code, but there is no resend yet: posting the same source_ref again returns the existing credential without mailing anything. A worker who lost the email, or locked the offer, can still use verify_url, which is the part employers and inspectors actually ask for.

Checking a credential

Verifying is a separate, public, unauthenticated surface. No API key is involved, because anyone is allowed to check anything. See Verification.