Issuing your first credential
End-to-end walkthrough — from API key to a verifiable credential URL you can share with a recipient.
Assumes you've completed the Quickstart and have a Developer-tier test key.
Total time: 10-15 minutes.
1. Set up the environment
export LEARNCOIN_API_KEY="lrn_test_01HXYZ…"
export LEARNCOIN_BASE_URL="https://api.learncoin.me/v1"2. Generate a pseudonymous recipient ID
Every recipient needs a stable, pseudonymous ID. Use a UUID v4 — keep it in your database alongside the recipient's real identity. Do not send email addresses or legal names as the recipient.id field.
import { randomUUID } from "node:crypto";
const recipientId = `urn:uuid:${randomUUID()}`;
// e.g. "urn:uuid:4e0c7f2e-6b1a-4f5a-9c8e-8a1b2c3d4e5f"
// Persist the mapping in your own database:
await db.recipients.insert({
id: recipientId,
email: "[email protected]",
legal_name: "Ada Lovelace",
learncoin_tenant: "tnt_01HXYZ…",
});This mapping row is what a GDPR erasure request deletes later. Once deleted, the urn:uuid in the signed credential can't be re-associated with the recipient — which is the whole point.
3. Define the achievement
Use the Open Badges 3.0 AchievementSubject shape:
const achievement = {
id: "https://yourschool.edu/credentials/intro-ml",
name: "Introduction to Machine Learning",
description: "Completed the 12-week introductory course with distinction.",
criteria: {
narrative:
"Passing grade (≥ 70%) on the final project plus peer-reviewed code submission.",
},
alignment: [
{
targetFramework: "ESCO",
targetCode: "S1.4.0",
targetName: "Machine learning",
targetUrl:
"https://esco.ec.europa.eu/en/classification/skill?uri=http%3A%2F%2Fdata.europa.eu%2Fesco%2Fskill%2FS1.4.0",
},
],
};Field notes:
id— use a stable URL under your domain. This identifies the achievement definition and doesn't have to resolve; a 404 is fine.alignment[]— optional but high-value. ESCO and O*NET codes let EU and US systems automatically map the skill to their own frameworks.
4. POST the batch
const response = await fetch(`${process.env.LEARNCOIN_BASE_URL}/batches`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LEARNCOIN_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
credentials: [
{
recipient: {
id: recipientId,
name: "Ada Lovelace",
email: "[email protected]",
},
achievement,
issuanceDate: new Date().toISOString(),
},
],
}),
});
const batch = await response.json();
console.log(batch);
// {
// id: 'bat_01HXYZABCDEF…',
// status: 'pending',
// credentials_count: 1,
// created_at: '2026-04-23T12:00:01.234Z',
// environment: 'test'
// }Why Idempotency-Key? If your process crashes between sending the request and reading the response, retrying with the same key returns the original batch — you won't accidentally issue the same credential twice.
5. Wait for anchoring
Two paths, pick one:
a) Webhook (recommended for production)
Register a webhook for batch.anchored and let LearnCoin call you. See Webhooks. This is strictly better than polling for real-world traffic.
b) Poll (fine for scripts + testing)
async function waitForAnchor(batchId: string, timeoutMs = 120_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(`${process.env.LEARNCOIN_BASE_URL}/batches/${batchId}`, {
headers: { Authorization: `Bearer ${process.env.LEARNCOIN_API_KEY}` },
});
const batch = await res.json();
if (batch.status === "anchored") return batch;
if (batch.status === "failed") throw new Error(`Batch failed: ${batch.error}`);
await new Promise((r) => setTimeout(r, 5_000)); // 5s between polls
}
throw new Error("Timed out waiting for anchor");
}
const anchored = await waitForAnchor(batch.id);
const credentialId = anchored.credentials[0].id;
const verifyUrl = anchored.credentials[0].verify_url;
console.log(`Credential ${credentialId} is verifiable at:\n ${verifyUrl}`);
// e.g. https://learncoin.me/c/crd_01HXYZ…Testnet anchoring typically completes within 30-60 seconds.
6. Deliver the credential to the recipient
Three options, in increasing order of friction:
Magic-link email (simplest)
Set recipient.email on the credential request. LearnCoin sends a branded email from your tenant identity with a link to the verification page. Zero work on your end.
Your own app
Embed the verification URL somewhere in your existing UX. Most integrators do this — the URL is a shareable, permanent, publicly-verifiable proof.
<a href={credential.verify_url} target="_blank">
View your credential →
</a>Download the signed JSON-LD
For recipients who want to archive the signed document themselves (the strongest self-sovereignty posture):
const res = await fetch(
`${process.env.LEARNCOIN_BASE_URL}/credentials/${credentialId}`,
{ headers: { Authorization: `Bearer ${process.env.LEARNCOIN_API_KEY}` } },
);
const { signed_credential } = await res.json();
// Send signed_credential to the recipient as a .json fileThey can then verify offline using any Blockcerts-compatible tool — see /docs/concepts/verification.
7. Verify it worked
Open the verify_url in a browser. You should see:
- Your tenant name + logo at the top
- The recipient's display name
- The achievement name + description
- A green "Anchored" status badge
- A clickable anchor-transaction hash (opens Basescan)
- The "Cryptographically verified by LearnCoin · Powered by Blockcerts" attribution
If any of that is missing or wrong, compare your payload to the POST /v1/batches reference and double-check required fields.
Error recovery patterns
The POST returned 202 but my webhook never fired
Check the batch's status directly: GET /v1/batches/{id}. If it says failed, the error field tells you why. If it says anchored, your webhook endpoint probably returned a non-2xx (check X-LearnCoin-Delivery-Attempt logs in the admin console).
The recipient says the verification page shows "Pending"
The batch is signed but not yet anchored on-chain. Typical on testnet if Base Sepolia is slow. Wait a minute and refresh. If it's still "Pending" after 5 minutes, that's worth an email to [email protected] with the batch ID.
I issued a credential with the wrong achievement name
You can't edit an issued credential — the proof would break. Instead: revoke the wrong one and issue a new one.
// Revoke the wrong credential
await fetch(`${URL}/credentials/${wrongId}/revoke`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
reason: "Superseded after grading correction.",
reason_code: "reissued",
}),
});
// Issue the corrected credential (new POST /v1/batches)Moving to production
When you're ready:
- Upgrade to the Starter tier ($149/mo) via
/pricingor email. - You'll receive an
lrn_live_…key. - Swap the key in your env. No other code changes needed — same endpoints, same request shapes.
- Register production-scoped webhooks (separate from testnet webhooks).
- Your
did:web:learncoin.me#tenant-…is the same across environments, so verifiers see a consistent issuer identity.
Going further
- Webhooks — replace polling with events
- GDPR erasure — when a recipient asks to be forgotten
- Concepts → Credentials — what's actually inside the signed document
- Concepts → Verification chain — how third parties confirm your credentials without calling LearnCoin