For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.

~5 min to check · ~15 min for webhooks · After the Quickstart.

Ask: check a credential's status

npx @learncard/cli status

lists 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.mjs and 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:

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

If…
Then

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

Last updated

Was this helpful?