Receive the Issued Credential
When an issuance completes, the issuance service pushes a copy of the issued credential to your application. This is step 9 of the issuance flow, and configuring it is how you learn that a credential was delivered without polling.
Requirements
- An installed credential issuance service.
- At least one credential configuration, so there is something to issue.
1. Expose an endpoint
Your application needs an endpoint that accepts POST requests with a JSON body. The issuance service calls it
once per completed session.
Give it a secret and check it. The configuration below sends a bearer token with every call; verify it on your side before processing the body. Without that check, the endpoint is an unauthenticated way into your application for anyone who learns the URL.
2. Configure the callback
Set sessionCallback in the issuance service's server.json:
{
"port": "...",
"baseUrl": "...",
"database": {
"..."
},
"issuer": {
"..."
},
"signing": {
"..."
},
"sessionCallback": { // (1)!
"targetUrl": "https://example-application.com/issued-credential", // (2)!
"bearerToken": "81952788-6321-412c-91b9-8f61ee2a1e52" // (3)!
}
}
- Where the copy of each issued credential is pushed, and how the request authenticates itself.
- Required within this block. The URL of the endpoint from step 1. It must be reachable from inside the issuance service's container — not merely from your desktop.
- Optional, but configure it. Sent as
Authorization: Bearer 81952788-6321-412c-91b9-8f61ee2a1e52on every callback.
3. Restart the service
Configuration is read at startup. Stop the container and start it again with the updated file:
docker run -d \
-p 8079:8079 \
-w /app \
-v "$(pwd)/issuer/conf:/app/conf:ro" \
registry.gitlab.com/secata/platform/did/release/issuer-backend:latest
What you receive
curl -X POST 'https://example-application.com/issued-credential' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer 81952788-6321-412c-91b9-8f61ee2a1e52' \
-d '{
"issuance_successful": true,
"session_id": "7d4633b273f000ca2243e258863975907756b3afc29783c7777cf47839264722",
"revocation_uri": "https://issuer.example.org/credential-status/revoke/7d4633b273f000ca2243e258863975907756b3afc29783c7777cf47839264722",
"credential_response": {
"credentials": [
{ "credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9.eyJfc2QiOlsiUGdGQUtEeGRfOHI5SWhTbFpoSEhrZjJuN0N6R1laU3AxWXllT25GV01FYyI…~WyJ4cjRnako0N3FUeXJNbDBnaVNKa1dnIiwiZmlyc3RfbmFtZSIsIkplbnMiXQ~" }
]
}
}'
session_idmatches what you got back when you started the issuance. Use it to tie the result to the right holder.issuance_successfulis whether the credential reached the wallet.revocation_uriis where toPOSTto revoke this credential later, and is present only when the issuance succeeded. See Revocation.credential_response.credentialsholds the issued credentials themselves.
What is inside the credential
The credential value is the SD-JWT VC that now lives in the holder's wallet. Its header has dc+sd-jwt as its
typ, and carries your certificate chain as x5c.
{
"_sd": ["PgFAKDxd_8r9IhSlZhHHkf2n7CzGYZSp1YyeOnFWMEc", "…"],
"iat": 1704067200,
"nbf": 1704067200,
"exp": 4102444800,
"vct": "urn:example:id-card:1",
"cnf": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "…",
"y": "…"
}
},
"status": {
"status_list": {
"idx": 3,
"uri": "https://issuer.example.org/credential-status/status-list"
}
}
}
status.status_list.idxis the credential's index in your status list, which is what a verifier reads to decide whether the credential is still valid. Revoking goes throughrevocation_uriabove, so you do not need to record the index yourself.cnf.jwkis the public key the wallet proved it holds when it requested the credential. It is what binds the credential to that wallet. It is there only when the wallet sent a proof; without one the credential is bound to no key, and a verifier rejects it unless its verification query says otherwise.
The rest is set from the configuration and the session: vct is the credential type, iat the time of issuance,
and nbf and exp the valid_from and valid_until. There is no iss and no sub. The issuer is named by the
leaf certificate in x5c, which is also where a verifier finds its key, and the holder by the key in cnf.
The claim values do not appear in the payload directly — _sd holds hashes of them, and the values themselves are
the ~-separated disclosures appended after the signature. That is what makes selective disclosure possible: the
holder can send some disclosures and withhold others while the signature stays valid. A nested claim such as
address is itself a disclosure whose value carries an _sd of its own. The registered claims above are never
selectively disclosable, so a verifier always sees them.
Keep only what you need
The callback hands you a full copy of the credential, claims and all, in readable form. Recording the
session_id from the callback body is enough to reconcile the issuance and to revoke it later — keep the
revocation_uri alongside it if you would rather not rebuild the address. Nothing in the decoded payload
needs storing, so discard the rest rather than keeping a second copy of data the holder now carries.
Accept or reject the credential
The callback is sent before the wallet receives the credential, and the HTTP status your endpoint answers with decides whether it does:
| Your endpoint | Session status | The holder |
|---|---|---|
Answers with a 2xx status |
ISSUANCE_SUCCEEDED |
Receives the credential |
| Answers with any other status | DELIVERY_REJECTED |
Is refused the credential |
| Cannot be reached, or does not answer in time | DELIVERY_FAILED |
Is refused the credential |
Reject when your application cannot stand behind the credential — the holder's record changed since you started
the issuance, or you could not store the result. A rejected credential never reaches the wallet, so there is
nothing to revoke. The callback is sent once and is not retried, so answer with a 2xx only once you have taken
the result in. The holder's wallet waits while your endpoint runs, so answer within a few seconds and do any slow
work afterwards.
Answer with a JSON body that says why, especially when you reject:
{
"message": "The holder's enrollment has ended."
}
The issuance service stores the body you answer with verbatim on the session, for audit, both when you accept and when you reject. Only the status decides the outcome: the body is not interpreted, so a missing or non-JSON body changes nothing. The body is not returned by the status endpoint, and the wallet is not shown it. Keep personal data out of it — the credential you received does not belong in the answer.
Issuances that failed
An issuance that ends in ISSUANCE_FAILED is also sent to your endpoint, with issuance_successful: false
and no credential. There is nothing to accept, so your answer to that call is ignored and not stored.
If results do not arrive
Check, in order:
targetUrlis reachable from inside the container — not just from your machine.- Your endpoint returns a success status.
- The session actually completed:
GET /issuance/oid4vci/status/{session_id}and readerror_message.
More in Troubleshooting.
What's Next
- Revocation: What to do with the
revocation_uriyou just recorded. - Configuration reference: Every issuer setting.
- Going to production: Authenticating the callback endpoint, and what else has to change.