Issuance Integration Guide
This guide covers a production-grade integration with Credential Factory: creating credential offers, configuring selective disclosure, presenting the QR code, tracking the offer to completion, and handling every failure mode.
If you haven't made your first call yet, start with Your First API Call (Credential Factory).
Integration overview
Step 1 — Decide your credential data and disclosure policy
credentialData holds the claims to embed; selectiveDisclosure.fields controls which of them the holder can later reveal individually (see SD-JWT & Selective Disclosure).
{
"authenticationMethod": "PRE_AUTHORIZED",
"credentialConfigurationId": "VerifiedPerson_vc+sd-jwt",
"credentialData": {
"givenName": "Alice",
"familyName": "Smith",
"dateOfBirth": "1990-01-15"
},
"externalId": "order-12345",
"offerTtlSeconds": 300,
"selectiveDisclosure": {
"fields": {
"givenName": { "sd": true },
"familyName": { "sd": true },
"dateOfBirth": { "sd": true }
}
}
}
Practical rules:
-
For
vc+sd-jwt, every claim is selectively disclosable by default. If you omitselectiveDisclosureentirely, the holder can disclose any field individually — which is the privacy-preserving default. SupplyselectiveDisclosure.fieldsonly when you want to make specific fields disclosable and force the rest to be always-visible. Disclosability is decided once, at issuance — you can't change it later. -
When you supply
selectiveDisclosure.fields, keys must exactly match keys incredentialData. Any mismatch returns400. Generate both maps from the same source object in your code so they can't drift:const credentialData = { givenName, familyName, dateOfBirth };const selectiveDisclosure = {fields: Object.fromEntries(Object.keys(credentialData).map((k) => [k, { sd: true }])),}; -
Set
externalIdto your own record ID (order ID, user ID, application ID) so every response and callback is self-correlating. Theissuance_completedwebhook carries it asuserId, and revocation webhooks don't carry it at all — see Using externalId for Correlation for each event. -
Pick the shortest
offerTtlSecondsyour UX tolerates. 300 seconds works well for an on-screen QR; longer TTLs widen the window in which a leaked offer URL could be claimed. -
Only create offers in authenticated contexts.
PRE_AUTHORIZEDmeans you have already verified the user — the wallet won't re-authenticate them.
Advanced: restricting a credential to a specific verifier
The optional audience field on the offer request binds the issued credential to a specific verifier. Any subsequent Verification Gateway presentation that doesn't match will be rejected with audience_mismatch.
{
"credentialConfigurationId": "VerifiedPerson_vc+sd-jwt",
"credentialData": { ... },
"audience": ["urn:iden2:id:network-member:202503:acme-verifier"]
}
audience is an array holding exactly one verifier Network Member URN. The value is embedded as the aud claim in the issued SD-JWT. Use this when you are issuing a credential that should only ever be presented to one known verifier — for example, an employee badge that is valid only at your company's own verification point.
Omit audience (the default) to allow the credential to be presented to any verifier in the trust network.
Step 2 — Create the offer
POST /api/v1/iaas/credential-offers with an OAuth Bearer token (full reference). On 201, persist offerId and expiresAt against your record.
Step 3 — Present the QR code
Render qrCodeDataUrl directly — it's a complete data:image/png;base64,... string:
<img src={offer.qrCodeDataUrl} alt="Scan with your wallet to receive your credential" />
On mobile, where the user can't scan their own screen, render offerUrl as a tappable deep link instead. More options in QR Code Rendering.
Step 4 — Track the offer to completion
Poll GET /api/v1/iaas/credential-offers/{offerId} every 3–5 seconds:
| Status | Stop polling? | Your action |
|---|---|---|
pending | No | Keep polling until expiresAt, then stop and treat as expired |
claimed | Yes | Success — the credential is issued. Record claimedAt and credentialId (you need it to revoke the credential later; it is the same registry ID returned at offer creation); update your UI. holderBinding is present when the wallet supplied one. The status can still become accepted or rejected when the wallet responds |
accepted | When claimedAt is present | Success once claimedAt and credentialId are present. Without them the holder accepted before issuance: keep polling — the credential can still be issued, or the offer can expire or fail |
rejected | Yes | Holder declined (failureReason wallet_rejected) — stop; create a new offer if they retry |
expired | Yes | Offer is dead. Create a new offer if the user wants to retry |
revoked | Yes | You revoked the credential, or the offer failed — check failureReason. Surface an appropriate message |
If webhooks are configured for your account, you can replace or supplement polling — see Polling vs. Webhooks.
Step 5 — Handle errors
| Status | Cause | Handling |
|---|---|---|
400 | Malformed JSON, missing required field, or disclosure/data key mismatch | Fix the request — don't retry as-is. The Problem detail names the offending field |
401 | Missing, invalid, or expired token | Re-acquire an access token and retry |
403 | Token missing the required scope, or not permitted to issue this credential type | Request the issuance.credential.create scope; contact iDen2 if your configuration needs updating |
404 | Unknown credentialConfigurationId (on create) or unknown/foreign offerId (on poll) | Verify the identifier |
500 / 503 | Platform or downstream error | Retry with exponential backoff |
All error bodies follow RFC 9457 Problem Details; the general strategy is covered in Error Handling.
Production checklist
- Credentials stored in a secrets manager, never in code (Security Best Practices)
- HTTPS enforced; production host configured (staging
iaas.stg.iden2.appis for testing only) -
externalIdset on every offer - Polling stops at
expiresAt -
expiredoffers produce a fresh offer, never a retry of the oldofferId -
offerUrl/qrCodeDataUrlare not logged or persisted beyond the offer TTL