Know When a Credential Is Claimed
Check whether a credential you sent was claimed — by asking, or by being told the moment it happens.
Sending a credential doesn't tell you what happened to it. There are two ways to find out. Ask — every send has an activityId, and you can look up its history any time. Or be told — give the send a webhook URL and LearnCard POSTs to your server the moment it's delivered and again when it's claimed.
Start with asking. It needs nothing but the SDK. Move to webhooks when you're sending at volume or need to react within seconds.
Ask: check a credential's status
npx @learncard/cli statuslists your recent sends with their current state. npx @learncard/cli status <activityId> shows one credential's full history. In code:
import { initLearnCard } from '@learncard/init';
const activityId = process.argv[2];
if (!activityId) throw new Error('Usage: node --env-file=.env status.mjs <activityId>');
const learnCard = await initLearnCard({ seed: process.env.SECURE_SEED, network: true });
const chain = await learnCard.invoke.getActivityChain({ activityId });
const latest = chain.at(-1);
for (const event of chain) console.log(`${event.timestamp} ${event.eventType}`);
console.log(latest?.eventType === 'CLAIMED' ? 'Claimed.' : 'Not claimed yet.');send() returns the activityId; store it next to your own record of the send. The events are CREATED → DELIVERED → CLAIMED, or EXPIRED / FAILED. For lists, filters, and claim-rate stats, see the Credential Activity reference.
Polling this every minute from a cron job is a perfectly good integration. The rest of this page is for when it isn't enough.
Be told: webhooks
The one-line version
Starts a receiver on port 8787, sends a demo credential with webhookUrl set, and prints each event as it arrives. Expose the port first (ngrok http 8787) and pass the tunnel URL. It writes the receiver as webhook.mjs — the same code Step 1 below walks through.
Prerequisites
The Quickstart, "Own your keys" path — this tutorial extends
send.mjsand reuses its.env(SECURE_SEED,PROFILE_ID)ngrok (or another tunnel) to expose your local server
Send with a webhook URL
Pass options.webhookUrl when you send a credential to an email or phone number. LearnCard POSTs a notification to that URL twice: once when the credential is delivered (ISSUANCE_DELIVERED), and again when the recipient claims it (ISSUANCE_CLAIMED).
Step 1: Start a receiver
Save this next to send.mjs as webhook.mjs. It verifies that each request really came from the LearnCard Network (a DID-signed bearer token), acknowledges fast, logs the fields you care about, and de-duplicates by ${type}:${issuanceId} — LearnCard retries on failure, so you will occasionally see the same event twice.
It listens on port 8787 (set PORT to change).
Step 2: Expose it with ngrok
Copy the https:// forwarding URL ngrok prints. That's your webhookUrl for the next step.
Step 3: Send with a webhook URL
Save this next to send.mjs as send-with-webhook.mjs. It's the quickstart's script with one addition: options.webhookUrl.
Run it with a real email you can open and the ngrok URL from Step 2:
Step 4: Watch it arrive
Your listener logs ISSUANCE_DELIVERED immediately. status: 'PENDING' means a claim email is on its way; status: 'ISSUED' means the recipient already had a LearnCard account and the credential was delivered straight to their wallet (in which case there's nothing left to claim — skip to Troubleshooting).
Step 5: Claim it
Open the claim email, tap Claim, and sign in or create an account — same as in the quickstart. Your listener logs ISSUANCE_CLAIMED, with claimedBy set to the DID of the account that just claimed it.
What's in the Payload
ISSUANCE_DELIVERED
Fired at send time. This example is the PENDING case (new recipient, claim email sent):
If the recipient already has a verified LearnCard account, data.inbox.status is "ISSUED" instead, and data.inbox.recipient.learnCardId is set to their DID — the credential landed directly in their wallet.
ISSUANCE_CLAIMED
Fired when a PENDING credential is claimed:
to.did on this event isn't populated correctly today — it resolves to an empty profile segment instead of your issuer DID. Don't key off it. from.did is reliable (always the network's own DID), and data.inbox.issuanceId — the same value returned as result.inbox.issuanceId from your send() call — is what ties this back to the credential you sent.
A third event, ISSUANCE_ERROR, fires instead of ISSUANCE_CLAIMED if claiming fails on the network's side (for example, a signing authority that stopped responding). Same shape, with the failure reason in message.body.
Verify the Request Is From LearnCard
Every webhook request carries Authorization: Bearer <token> — a DID-JWT proving the request came from the LearnCard Network's own DID. There's no separate signing secret and no X-LearnCard-Signature header; the bearer token is the proof.
You don't need a seed to check it — a seedless instance can still verify signatures:
Add the check at the top of your handler, before you trust the body:
That confirms the request is signed by some DID — decode the token to see which one:
The production LearnCard Network signs as did:web:network.learncard.com — no :users: segment; that's reserved for profiles. If you're pointed at a different environment, pin the DID you see on the first delivery and compare against it on every request after that.
Retries and Duplicates
LearnCard waits 6 seconds for your response. Slower than that counts as a failed delivery.
A 4xx response (other than 408, 425, or 429) is treated as a definitive rejection — it will not be retried.
Anything else that fails (5xx, timeout, network error) is retried through a queue, up to 3 attempts total, before it's dropped into a dead-letter queue.
Delivery is at-least-once, not exactly-once. You can receive the same notification more than once even when nothing went wrong on your end.
There's no server-side de-duplication, so always key idempotency on ${type}:${issuanceId} — the pattern webhook.mjs uses above. Responding quickly reduces duplicates; it doesn't eliminate them.
Other events: profile webhooks
Everything above is scoped to one send(). For events about your profile — connection requests, boosts accepted, consent-flow activity, guardian approvals — set a standing webhook with updateProfile({ notificationsWebhook }). It uses the same authentication and retries, but it never receives ISSUANCE_DELIVERED, ISSUANCE_CLAIMED, or ISSUANCE_ERROR; those only go to the per-send options.webhookUrl. Setup and every payload shape: Notifications & Webhooks.
What You Should See
Terminal running send-with-webhook.mjs:
Terminal running webhook.mjs, immediately after:
Open the claim email, tap Claim, and sign in or create an account. Back in the listener:
Troubleshooting
Webhook never arrives
Confirm the ngrok URL you passed as options.webhookUrl is still running — ngrok URLs change every restart on the free tier.
Notification webhook transport failed with 404
Your server is reachable, but nothing is listening on the path ngrok is forwarding to. Match webhookUrl to where webhook.mjs actually listens.
No ISSUANCE_CLAIMED after claiming
The recipient already had a LearnCard account, so the credential was auto-delivered as ISSUED at send time — there's nothing left to claim. Check for that in ISSUANCE_DELIVERED instead.
Duplicate webhooks
Expected — delivery is at-least-once, not a sign your server responded too slowly. De-duplicate on ${type}:${issuanceId} as shown above.
401s in your own logs
verifyLearnCardRequest rejected the token. Check you're reading the Authorization header — there's no separate signature header to fall back to.
Next Steps
Send & Issue Credentials — the full
send()reference, includingsuppressDeliveryandguardianEmail.Notifications & Webhooks — profile-level webhooks and the payload for every notification type.
Go to Production — checklist before you rely on this for real traffic.
Last updated
Was this helpful?