Skip to content

Issuance and wallets

There are two ways to get a credential into someone’s hands: issue it directly against a token you already have, or mint an offer the holder’s wallet redeems. Both end at the same signed credential.

POST /credentials/issue
Authorization: Bearer <access token>
Content-Type: application/json
{ "format": "sd-jwt-vc", "holderDid": "did:key:z6Mk…" }
{
"vc_id": "vc_…",
"type": "WorkCredential",
"format": "sd-jwt-vc",
"credential": "eyJ…~WyJ…",
"issuer": "did:web:acme.oauth.work",
"expires_at": 1757776000000
}

The type is chosen from the token, not from a parameter:

TokenCredential issued
A user token with the work_credential scopeWorkCredential
An agent or service tokenAgentIdentityCredential
A delegated token (carrying act)DelegationCredential

A user token without work_credential gets 403 insufficient_scope. Agent and delegation credentials do not require that scope — the token’s own nature is the authorization.

holderDid is optional but recommended. Passing a did:key binds the credential to a key the holder controls, so they can prove possession rather than presenting a bearer document that works for whoever holds a copy.

The token must be valid for this endpoint. A token audience-bound to some other resource is rejected with 401 invalid_token, “audience mismatch” — so a token minted for an MCP server cannot be redirected into minting credentials. If the token is DPoP-bound, a matching proof is required as well.

For a holder using a wallet, mint a pre-authorized offer and let them redeem it. Requires credentials:issue:

POST /v1/organizations/org_01J…/credential_offers
Authorization: Bearer sk_live_…
{ "user_id": "usr_01J…" }
{
"credential_offer": {
"credential_issuer": "https://acme.oauth.work",
"credential_configuration_ids": ["WorkCredential"],
"grants": {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": "pac_…"
}
}
},
"pre_authorized_code": "pac_…"
}

Render credential_offer as a QR code or a deep link. Offers are valid for 10 minutes and are single-use — redemption is arbitrated in the database, so two wallets racing to redeem the same offer cannot both succeed.

1. Discover the issuer.

GET /.well-known/openid-credential-issuer
{
"credential_issuer": "https://acme.oauth.work",
"credential_endpoint": "https://acme.oauth.work/credential",
"credential_configurations_supported": {
"WorkCredential": {
"format": "vc+sd-jwt",
"vct": "WorkCredential",
"cryptographic_binding_methods_supported": ["jwk", "did:key"],
"credential_signing_alg_values_supported": ["EdDSA"],
"proof_types_supported": { "jwt": { "proof_signing_alg_values_supported": ["EdDSA", "ES256"] } }
}
}
}

2. Redeem the code for a token.

POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code&pre-authorized_code=pac_…
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "work_credential",
"c_nonce": "",
"c_nonce_expires_in": 3600
}

3. Request the credential, proving possession of the holder key with the c_nonce:

POST /credential
Authorization: Bearer eyJ…
Content-Type: application/json
{ "format": "vc+sd-jwt", "proof": { "proof_type": "jwt", "jwt": "<holder proof signed over c_nonce>" } }

The c_nonce binds the holder’s proof to this specific token, so a proof captured from one issuance cannot be replayed into another.

Direct issuance suits a service acting for a user it has already authenticated — your backend holds the token, and the credential goes straight into your own storage or UI.

Offers suit a holder with a wallet you do not control. Nothing has to be transmitted to them except a QR code, and the wallet key never leaves their device.

Every issued credential is recorded with its type, format, holder, expiry, and status-list position. Issuance writes a vc_issued audit event and fires a credential.issued webhook.