Skip to main content

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 omit selectiveDisclosure entirely, the holder can disclose any field individually — which is the privacy-preserving default. Supply selectiveDisclosure.fields only 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 in credentialData. Any mismatch returns 400. 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 externalId to your own record ID (order ID, user ID, application ID) so every response and callback is self-correlating. The issuance_completed webhook carries it as userId, and revocation webhooks don't carry it at all — see Using externalId for Correlation for each event.

  • Pick the shortest offerTtlSeconds your 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_AUTHORIZED means 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:

StatusStop polling?Your action
pendingNoKeep polling until expiresAt, then stop and treat as expired
claimedYesSuccess — 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
acceptedWhen claimedAt is presentSuccess 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
rejectedYesHolder declined (failureReason wallet_rejected) — stop; create a new offer if they retry
expiredYesOffer is dead. Create a new offer if the user wants to retry
revokedYesYou 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​

StatusCauseHandling
400Malformed JSON, missing required field, or disclosure/data key mismatchFix the request — don't retry as-is. The Problem detail names the offending field
401Missing, invalid, or expired tokenRe-acquire an access token and retry
403Token missing the required scope, or not permitted to issue this credential typeRequest the issuance.credential.create scope; contact iDen2 if your configuration needs updating
404Unknown credentialConfigurationId (on create) or unknown/foreign offerId (on poll)Verify the identifier
500 / 503Platform or downstream errorRetry 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.app is for testing only)
  • externalId set on every offer
  • Polling stops at expiresAt
  • expired offers produce a fresh offer, never a retry of the old offerId
  • offerUrl / qrCodeDataUrl are not logged or persisted beyond the offer TTL