Issue & Refresh a Managed Credential
Issue a credential you can update later, publish the update, and pick it up in the recipient's wallet.
Some credentials change after you issue them: a provisional transcript becomes final, a certification gains an endorsement, a license renews. Managed refresh lets you publish a new version of a credential you already sent, and lets the recipient's wallet replace its copy in place instead of receiving a duplicate.
This page walks through the whole loop with three small scripts. For what's happening underneath, see Credential Refresh.
The send call hides the machinery: it allocates the refresh service, injects it (with its JSON-LD context) before signing, signs, and delivers — the same steps the lower-level path below performs explicitly.
Before you start
A seed and profile from the Quickstart, in
.envasSECURE_SEEDandPROFILE_IDThe recipient's profile ID (they need a LearnCard account; refresh delivers to a specific person)
The scripts below use node --env-file=.env. Install @learncard/init in the folder where you run them.
For an existing boost template, refresh uses the same issuance permissions as an ordinary send, including delegated issue roles. The issuing profile owns the refresh record and controls subsequent updates; creating the template alone does not grant access to another issuer’s refresh records.
1. Issue a credential that can be refreshed
One call does it. send() with refresh: true takes an ordinary credential template — no refresh fields, no hand-copied JSON-LD context — and handles the refresh setup, signing, and holder-only encrypted delivery. Profile/DID recipients use immediate issuance. Email and phone recipients use deferred Universal Inbox signing; see Refresh through Universal Inbox below.
The script writes refresh.json — the issuance receipt:
refreshId/refreshService— the managed service that serves your updatescredentialId/issuerDid/holderDid— the identity every version must reusecredentialStatus— the revocation status descriptor to preserve on every versioncredentialUri/activityId— where the credential lives and how to track delivery
The receipt is metadata only — it never contains the credential's claims. Keep it alongside your own record of the credential (the student row, the license number): publishing an update needs it, and you can't read the credential back afterwards. The network stores it encrypted to the recipient only, so neither the network nor you can decrypt it once sent.
If your template includes the BoostCredential type, also keep result.uri (the boost URI). send() embeds it as boostId before signing; include the same boostId when rebuilding each update so the recipient can verify the boost's authenticity. This is distinct from result.credentialUri, which identifies the issued credential.
The recipient sees Provisional Transcript in their LearnCard app once they claim it.
Retrying a refreshable send safely
Issuing a refreshable credential takes several network steps (prepare, sign, deliver). Pass an idempotencyKey that is stable for the logical send — for example your own enrollment or award ID — and retry the same call after any failure:
Retries with the same key reuse the same boost and refresh allocation and return the original result once delivery has succeeded. Reusing a key for a different recipient, template, template data, integration, contract, or explicitly supplied credential ID is rejected with CONFLICT. Keys are scoped to your profile and only apply to refresh: true sends.
For manually signed credentials, idempotencyKey requires an earlier boost.prepareRefreshableSend call with that key. That preparation procedure is tRPC-only; the SDK handles it automatically when signing locally. Direct REST callers supplying signedCredential should omit the key and retry the exact same signed credential with the same templateUri: the refresh allocation's binding already prevents duplicate delivery. A key without prior preparation is rejected with BAD_REQUEST before any send state is created.
sendBoost also issues refreshable credentials
For a guided demonstration without writing SDK code, the CLI offers learncard demo refresh. It creates demo issuer and recipient accounts, sends a provisional certificate through sendBoost, publishes a final version, and refreshes the recipient's copy. Press Enter between steps to present the before and after. It defaults to the local network at http://localhost:4000/trpc; use --network staging for a staging network with managed refresh and LC-2198 deployed. Use a CLI build containing this command. The updated copy is displayed in the terminal, not saved to the app. Demo records remain on the selected network; account keys stay in memory for the session.
To demonstrate the recipient experience in the local LearnCard app, start its local development stack and run learncard demo refresh --ui (or, from this repository, bun --cwd packages/learn-card-cli start demo refresh --ui). Follow the printed demo sign-in link, then press Enter to send. In the app, open Alerts → Claim → Accept. Press Enter in the terminal to publish the update, then open the new update notification in the app. Press Enter once more to confirm the final certificate was saved. The CLI waits for the app to save each version; it does not claim or refresh on the app's behalf. Open the full certificate to compare Provisional Results — Final grade: Pending with Final Results — Final grade: A after the update. Both its title and description change. Keep the sign-in link private: it controls a disposable demo account. This mode is interactive and local-only; --app-url selects a different local app port.
If you already work boost-first — create the boost template, then send it to many people — sendBoost takes an opt-in flag instead. With literal { enableRefresh: true } it returns the credential URI and the receipt; without it, the plain URI string, exactly as before:
Publishing works the same way: rebuild the update from your own claims plus sent.refresh, and pass it to publishCredentialRefresh as shown in the next section. Like send(), enableRefresh requires a profile or DID recipient on your network, a network with refresh enabled, and — for API-token callers — a token with credentials:write in addition to boosts:write.
2. Publish an update
An update is a complete, signed credential with the same id, the same issuer, the same refreshService, and a validFrom no earlier than the current version.
What you should see
versioncounts from 1 (the original). Run it again with the sameidempotencyKeyand you getversion: 2back, not a third version.notificationisqueuedwhen the content changed in a way the recipient would notice,suppressedwhen it didn't (or you passednotifyHolder: false), andnot-applicablewhen the recipient hasn't claimed yet.
You can publish before the recipient claims. Nothing is served or announced until they do; on claim they get the newest version.
Let the network sign for you
If a signing authority signs your credentials, send unsigned claims instead:
See what you've published
History is metadata only. It never includes credential content.
The lower-level path
Everything send({ refresh: true }) does is also available as explicit steps — useful when one service signs and another delivers, or when you need the allocation record before issuing:
You can also sign first and hand the signed credential to send({ …, signedCredential, refresh: true }). The signed credential must already contain its allocated managed refresh service — the network never adds refresh to an already-signed credential, and it rejects a refresh send whose credential carries no suitable allocation rather than issuing a second one.
Publishing from this path is identical: keep refreshId, refreshService, and the signed identity with your records, and follow the next section.
3. Refresh from the recipient's side
The LearnCard app does this automatically: it checks refreshable credentials when it opens (at most once a day per credential) and immediately when the user taps a "credential updated" notification. The credential is replaced in place; the previous version stays viewable under View Previous Versions.
If you're building your own wallet, this is the whole primitive:
What you should see
Before publishing: Up to date: Provisional Transcript. After: Updated to version 2: Final Transcript.
refreshCredential verifies the credential you hold, contacts the service once, verifies what comes back (valid proof, same issuer, same ID, not older than what you have), and returns it. It never writes to storage; where the new version goes is your wallet's decision. It also refuses to talk to non-HTTPS or private-network addresses unless you opt in for local development.
The LearnCard app's development server handles local testing automatically when both the app and its configured Brain service use loopback addresses (localhost, 127.0.0.1, or [::1]). It trusts that configured network for Boost verification and permits HTTP refresh requests only to that backend's origin, with redirects disabled. Signature, issuer, holder, and credential identity checks still run. No extra flag or custom Vite configuration is needed. Set VITE_CREDENTIAL_REFRESH_LOCAL_QA=false to opt out. These local exceptions are always disabled in production and staging builds, even if the flag is set to true; standalone SDK clients retain their existing explicit opt-in behavior.
Refresh through Universal Inbox
Use this path when you know an email address or phone number but the recipient does not yet have a LearnCard account. A registered signing authority is required: the credential remains unsigned until the verified recipient claims it. Phone delivery retains the existing trusted-issuer requirement.
For the easiest visual walkthrough, enable the local email demo configuration, then run:
Enter an address you can access. The demo school emails a provisional certificate; open the email on the computer running the app, sign in with that address, finish account setup if prompted, and claim it. The app preserves the claim link through setup and returns you to it afterward. Return to the terminal to publish final results. View the updated certificate through the in-app notification or the update email's View Updates link. The email includes the issuer and credential title when available; grades and credential contents stay in the app. The CLI checks the issuer's bound receipt and recipient profile without accessing your account keys or personal saved records. If an older demo claimed before account setup, complete setup and start a fresh demo; that earlier delivery cannot receive update notifications.
This walkthrough deliberately publishes only after claim, so you can see the change. Publishing before the recipient creates an account also works; the recipient gets the latest pending version at claim, as described below.
For an optional disposable-account demonstration with no actual email, run learncard demo refresh --inbox --ui. It demonstrates the pre-claim update separately. Omit --ui for terminal-only testing (add --lca-url http://localhost:5200/trpc for the E2E stack). The UI mode reads service URLs from the running app's tenant configuration.
The equivalent REST request is POST /api/inbox/issue with refresh: true. API tokens require both inbox:write and credentials:write. Reusing an issuance key with the same request reuses the inbox record and refresh allocation; using it for a different request conflicts. Each request without a key creates a separate issuance.
To publish before claim, rebuild the unsigned content from your template and the allocation receipt. Keep the credential ID, issuer, refresh service, status descriptors, and subject identity fields unchanged. The inbox binds the subject ID when the recipient claims.
Publication replaces the pending encrypted content. No update notification is sent before a holder exists. Claim signs and delivers the newest content with the same credential ID and refresh URL. Earlier pending revisions retain metadata in issuer history; they are not signed, holder-downloadable credentials. Claim and publication use a version check, so an update racing with claim cannot silently deliver stale content. Expired or guardian-blocked inbox records cannot be claimed.
The temporary unsigned content uses the existing encrypted, expiring inbox escrow, which the service can decrypt for signing. At successful claim, the escrow is erased and the signed version is stored encrypted for the holder (and their authorized managers). This differs from immediate managed sends, which never need inbox escrow.
After claim, fetch the metadata-only receipt to learn the bound DID, then publish normally:
getInboxCredential requires inbox:read. Historical publication keys remain replayable after claim. If publication conflicts because claim just bound the holder, fetch this receipt and rebuild using its holder DID. A verified existing recipient is delivered the signed credential immediately and follows the normal acceptance/refresh lifecycle.
The unified SDK send path also supports email/phone recipients:
This uses the issuer's primary registered signing authority, including for seed-based SDK callers. The deferred allocation receipt is at sent.inbox.refresh; the existing sent.refresh signed-delivery receipt remains the profile/DID contract. Preserve boostId: boostUri when rebuilding boost content for publication. Pre-signed inbox credentials, multiple subjects, and pre-claim consent-contract linking are not supported with refresh. Remote-DID federation remains outside this path.
Troubleshooting
Credential refresh is not available
Refresh isn't enabled on this network
Check which network you're connected to; contact us if you need it enabled
Profile did not allocate this credential refresh
Publishing with a different seed than the one that sent the credential
Use the issuer seed that ran issue-refreshable.mjs
Credential issuer does not match the allocated refresh
The update was signed with a different DID than version 1
Rebuild the update from issuerDid in refresh.json
publishCredentialRefresh rejects the update
id or refreshService differs from the original, or validFrom is older
Rebuild the update from refresh.json; set validFrom to now
notification: "not-applicable"
The recipient hasn't claimed yet
Nothing to do; they'll receive the newest version when they claim
refreshCredential returns UNSAFE_ENDPOINT
The service URL isn't HTTPS or resolves to a private address
For local development only, pass { allowInsecureHttp: true, allowPrivateAddresses: true }
refreshCredential returns REVOKED
You revoked the credential
Expected; the recipient keeps their local copy and history
refreshCredential returns ROLLBACK
The service returned something older than what the wallet holds
Publish a version with a newer validFrom
Limits to know about
Refresh has to be set up before the credential is signed. You can't add it to a credential that's already out.
The app checks in the foreground only (open, resume, notification tap). There's no background polling yet.
Revoking stops the network from serving any version. It doesn't reach into the recipient's wallet to delete what they already have.
Next steps
Credential Refresh: how the pieces fit and what the network can and can't see
Revoke or Update a Credential: for changes that don't need in-place refresh
Who Signs Your Credentials?: to publish updates in signing-authority mode
Last updated
Was this helpful?