Live on Base with Ewance

See the certificates
Integration guides

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:

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:

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 file

They 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:

  1. Upgrade to the Starter tier ($149/mo) via /pricing or email.
  2. You'll receive an lrn_live_… key.
  3. Swap the key in your env. No other code changes needed — same endpoints, same request shapes.
  4. Register production-scoped webhooks (separate from testnet webhooks).
  5. Your did:web:learncoin.me#tenant-… is the same across environments, so verifiers see a consistent issuer identity.

Going further

On this page