How issuing works

You add a certificate by posting its facts as JSON. You do not upload a PDF, and you do not sign anything yourself.

valid.ca takes the worker's name, the course, and your own certificate number, builds an Open Badges 3.0 document, and signs it with your key under your did:web. One certificate, start to finish, is below.

Before you can issue

  1. Apply at /issuers/apply.
  2. valid.ca staff approve the application. Approval generates your Ed25519 signing key, your DID, and your revocation list, creates your issuer portal account, and emails your contact a link to sign in.
  3. Start your membership at /portal/billing. It's a flat monthly fee, paid by card through Stripe. When it starts, the portal shows your first API key. The key is shown once, starts with valid_, and goes in an Authorization header.

An application on its own cannot issue. Neither can a suspended issuer: the key stays in the database and every call returns 403. An issuer whose membership has ended gets 402 until it restarts.

Send the certificate

One request. Replace the values with your own.

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"
  }'

source_ref is your certificate number, and it is how valid.ca recognizes a certificate you have already sent. Post the same source_ref twice and you get the first credential back untouched, with a 200 instead of a 201. Retries, replays, and a backfill that overlaps are all safe.

achievement_name names the course. It is the one field the validator calls optional that you should always send. The only alternative is achievement_template_id for a template you created in the portal, and with neither one the request fails.

The API reference has every field, the other three endpoints, and the same calls in PHP, JavaScript, and Python.

What comes back

{
    "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",
        "revoked_at": null,
        "expires_at": "2029-04-30T00:00:00+00:00",
        "credential": {
            "comment": "the signed Open Badge, shown in full below"
        },
        "offer_pin": "603778"
    }
}

Two values are worth storing against your own record.

id is the credential UUID. Revoking and changing an expiry both address a credential by that UUID.

verify_url is the public page for this certificate. Print it, or a QR code of it, on the certificate you hand the worker. Anyone who opens it sees the course, the holder, your name as the issuer, and a live signature and revocation check.

The holder gets an email at the same time. It carries the verify_url, a link that attaches the credential to a valid.ca wallet account, a QR code for any OpenID4VCI wallet, and the six digits in offer_pin.

The signed credential

This is data.credential: an Open Badges 3.0 credential, which is a W3C Verifiable Credential 2.0 document.

{
    "@context": [
        "https://www.w3.org/ns/credentials/v2",
        "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
    ],
    "id": "https://valid.ca/c/459d2cb7-46d6-44d2-8267-60b87b21090a",
    "type": ["VerifiableCredential", "OpenBadgeCredential"],
    "issuer": {
        "id": "did:web:worksitesafety.ca",
        "type": ["Profile"],
        "name": "Worksite Safety"
    },
    "validFrom": "2026-10-02T19:29:43+00:00",
    "validUntil": "2029-04-30T00:00:00+00:00",
    "credentialStatus": {
        "id": "https://worksitesafety.ca/status/1#0",
        "type": "BitstringStatusListEntry",
        "statusPurpose": "revocation",
        "statusListIndex": "0",
        "statusListCredential": "https://worksitesafety.ca/status/1"
    },
    "credentialSubject": {
        "type": ["AchievementSubject"],
        "name": "Jordan Blake",
        "identifier": [
            {
                "type": "IdentifierEntry",
                "identifier": "3d846733f693bdb28487918fa449994496f6ce72ad2258d96e7805a67518f7bd",
                "identifierType": "emailAddress"
            }
        ],
        "achievement": {
            "id": "https://valid.ca/achievements/worksitesafety-ca/working-at-heights",
            "type": ["Achievement"],
            "name": "Working at Heights",
            "description": "Fall protection training for work above 3 metres.",
            "criteria": {
                "narrative": "Completed 8 hours of instruction and passed the written evaluation."
            }
        }
    },
    "proof": {
        "type": "DataIntegrityProof",
        "created": "2026-10-02T19:29:43Z",
        "verificationMethod": "did:web:worksitesafety.ca#z6Mkk9guhMoR8WHqcpRNCcFbXyAe9vUKkfo4zAcTEXS2iyp3",
        "cryptosuite": "eddsa-rdfc-2022",
        "proofPurpose": "assertionMethod",
        "proofValue": "z5pcu2Lq147ZpkvoZzv9BqtL2Bdj1qjUUrZUYFH6bDvrsM98wSyS2iPRYSsizTTJxR6kovKucEgLMC4LRhF7WguM1"
    }
}

The holder's email address is not in the credential. Its SHA-256 hash is, so a verifier who already knows the worker's address can confirm the match, and one who does not learns nothing.

credentialStatus points at a Bitstring Status List on your own domain. That one document answers "is this revoked" for every credential you have ever issued, without telling valid.ca who is asking.

The worker's wallet can also collect the same record as an SD-JWT VC, where the holder chooses whether to reveal their name and email. That copy is signed when the wallet collects it and bound to a key on the worker's phone, so it is not in the issue response. It is the one a worker shows when someone scans their wallet on site.

Why this is still a W3C credential

Three things make a document a W3C Verifiable Credential: it names an issuer, it carries a proof, and that proof verifies against a key the named issuer's identifier authorizes. All three hold.

  • issuer.id is did:web:worksitesafety.ca, the training provider's own identifier.
  • proof.verificationMethod is a key listed in that DID document, served from https://worksitesafety.ca/.well-known/did.json.
  • A verifier resolves that document from your domain, checks the signature against it, then checks your revocation list, also on your domain. Neither fetch touches a valid.ca URL.

did:web:valid.ca signs exactly one thing, the trust registry at /.well-known/valid-issuers.json, which is a list of who valid.ca has approved. It never signs a training credential.

Nothing in the spec says which machine has to compute the signature. Posting claims to an API that signs on your behalf produces the same credential as running a signer yourself. What would break the model is the reverse of what happens here: naming you as the issuer while signing with a key your DID does not list, or taking a finished credential from somewhere else and filing it as one of yours. The endpoint validates a fixed list of claim fields and discards the rest, so an issuer, proof, or credential in your request body is ignored rather than trusted.

A credential valid.ca never issued still verifies at /verify. It comes back as "signature valid, issuer not recognized" instead of approved, which is the honest answer and the reason the trust registry exists.

Who holds the key

valid.ca generates your key at approval and keeps it encrypted. Every signature runs through valid.ca's signing service, so valid.ca is technically able to sign as you.

That is the tradeoff of a hosted issuer, and your DID document discloses it rather than hiding it:

"service": [
  {
    "id": "did:web:worksitesafety.ca#valid",
    "type": "CredentialIssuanceOperator",
    "serviceEndpoint": "https://valid.ca"
  }
]

A verifier reading your DID can see who operates issuance for you. If you leave, valid.ca hands over the public key material your old credentials need, and keeping the DNS record is the only thing required to keep them verifying.

Change or revoke later

An issued credential is signed, so it is never edited in place.

Changing the expiry re-signs the document: PATCH /api/v1/credentials/{uuid} with a new expires_at.

Revoking leaves the credential alone and flips one bit in your status list: POST /api/v1/credentials/{uuid}/revoke. Every verifier that checks the list, including the public page, reports it as revoked from then on. Revoking twice changes nothing.

A second POST of the same source_ref does not update a holder name, course, or expiry. It returns the credential you already issued. When the facts of a certificate genuinely change, revoke it and issue a new one under a new source_ref.