Live on Base with Ewance

See the certificates
Integration guides

Webhooks

Receive LearnCoin events on your backend — batch lifecycle, credential revocation, GDPR erasure — with signed deliveries and automatic retries.

Webhooks let you react to LearnCoin events without polling. They're the recommended way to know when a batch has anchored, a credential has been revoked, or a recipient has exercised erasure.

Registering an endpoint

POST /v1/webhooks
Authorization: Bearer lrn_test_…
Content-Type: application/json
{
  "url": "https://your-backend.example.edu/learncoin/webhook",
  "events": ["batch.anchored", "credential.revoked", "credential.erased"],
  "description": "Production event receiver"
}

Response:

{
  "id": "whk_01HXYZ…",
  "url": "https://your-backend.example.edu/learncoin/webhook",
  "events": ["batch.anchored", "credential.revoked", "credential.erased"],
  "signing_secret": "whsec_AbCdEf1234567890AbCdEf1234567890AbCdEf12",
  "active": true
}

Store signing_secret immediately. It's only shown in this response. If you lose it, delete the webhook and create a new one.

Event envelope

Every event your endpoint receives has this shape:

{
  "id": "evt_01HXYZ…",
  "type": "batch.anchored",
  "created_at": "2026-04-23T12:00:58.101Z",
  "tenant_id": "tnt_01HXYZ…",
  "data": {
    /* event-specific payload — see catalog below */
  }
}

Plus these HTTP headers:

X-LearnCoin-Event-Id: evt_01HXYZ…
X-LearnCoin-Signature: t=1713873658,v1=c5e9a7b4…                   # see signing below
X-LearnCoin-Delivery-Attempt: 1                                   # 1 on first try
X-Request-Id: req_01HXYZ…

Signing & verification

Every delivery is HMAC-SHA256 signed with the webhook's signing_secret. Verify every event before trusting its contents — unsigned or mismatched events should be dropped.

The signature header format:

X-LearnCoin-Signature: t=<unix-timestamp>,v1=<hex-hmac>

The signed string is <t>.<raw-request-body>.

TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
const secret = process.env.LEARNCOIN_WEBHOOK_SECRET!;

export function verifyLearnCoinWebhook(
  rawBody: string,
  signatureHeader: string | null,
): boolean {
  if (!signatureHeader) return false;

  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
  );
  const t = parseInt(parts.t ?? "", 10);
  const v1 = parts.v1 ?? "";
  if (!t || !v1) return false;

  // Reject stale events (replay protection)
  if (Date.now() - t * 1000 > MAX_AGE_MS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length) return false;

  return timingSafeEqual(a, b);
}

Python

import hmac, hashlib, time

MAX_AGE = 5 * 60  # 5 minutes

def verify_learncoin_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header:
        return False
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False

    if time.time() - t > MAX_AGE:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Critical: verify against the raw request body bytes, not a re-serialized JSON object. Pretty-printing, key reordering, or JSON parsing+re-stringifying will break the HMAC.

Event catalog

batch.created

Fires the moment a batch is accepted.

{
  "id": "evt_01HXYZ…",
  "type": "batch.created",
  "created_at": "2026-04-23T12:00:01.234Z",
  "tenant_id": "tnt_01HXYZ…",
  "data": {
    "batch_id": "bat_01HXYZ…",
    "credentials_count": 1,
    "environment": "test"
  }
}

batch.signed

Fires when every credential in the batch is signed. Batch has a Merkle root but anchoring hasn't landed yet.

{
  "type": "batch.signed",
  "data": {
    "batch_id": "bat_01HXYZ…",
    "merkle_root": "0x7c2f…91a8",
    "signed_at": "2026-04-23T12:00:03.890Z"
  }
}

batch.anchored

Fires when the Merkle root lands on-chain. Credentials are now independently verifiable. This is the event most integrators care about.

{
  "type": "batch.anchored",
  "data": {
    "batch_id": "bat_01HXYZ…",
    "merkle_root": "0x7c2f…91a8",
    "anchor_transaction": {
      "chain": "base-sepolia",
      "hash": "0xabcd…ef12",
      "block_number": 14582301,
      "explorer_url": "https://sepolia.basescan.org/tx/0xabcd…ef12"
    },
    "anchored_at": "2026-04-23T12:00:58.101Z",
    "credentials": [
      {
        "id": "crd_01HXYZ…",
        "recipient_id": "urn:uuid:4e0c7f2e-…",
        "verify_url": "https://learncoin.me/c/crd_01HXYZ…"
      }
    ]
  }
}

batch.failed

Terminal failure. A human at LearnCoin has been paged.

{
  "type": "batch.failed",
  "data": {
    "batch_id": "bat_01HXYZ…",
    "error_code": "anchoring_chain_unavailable",
    "error_message": "Base RPC returned 503 after 5 retries.",
    "failed_at": "2026-04-23T12:05:00Z"
  }
}

credential.revoked

Fires when POST /v1/credentials/{id}/revoke is called and committed.

{
  "type": "credential.revoked",
  "data": {
    "credential_id": "crd_01HXYZ…",
    "batch_id": "bat_01HXYZ…",
    "revoked_at": "2026-04-23T13:45:00Z",
    "reason": "Superseded by re-issued version.",
    "reason_code": "reissued"
  }
}

credential.erased

Fires when GDPR erasure is applied.

{
  "type": "credential.erased",
  "data": {
    "credential_id": "crd_01HXYZ…",
    "erased_at": "2026-04-23T13:46:00Z",
    "verification_status_after_erasure": "verifiable"
  }
}

webhook.test

Synthetic event from POST /v1/webhooks/{id}/test — use to validate signature verification without issuing real credentials.

{
  "type": "webhook.test",
  "data": {
    "sent_at": "2026-04-23T14:00:00Z",
    "note": "This is a test event. Nothing happened."
  }
}

Retries

If your endpoint returns anything other than 2xx, or doesn't respond within 10 seconds, LearnCoin retries:

AttemptDelay after previous
1
21 min
35 min
430 min
52 h
66 h
712 h
824 h

After 8 failed attempts (~48 h total), the event is dropped and your webhook is flagged inactive. You'll receive an email. Re-activate via the admin console.

Every retry carries an incremented X-LearnCoin-Delivery-Attempt header.

Idempotency on your side

Your handler can receive the same event more than once (network retries, re-queues). Deduplicate on event.id — ULIDs are sortable and unique.

const alreadyHandled = await redis.set(
  `lc:webhook:${event.id}`,
  "1",
  "EX",
  60 * 60 * 24 * 7, // 7 days is safely longer than the retry window
  "NX",
);
if (!alreadyHandled) return Response.json({ ok: true }); // quietly ack

Sample receiver — Next.js Route Handler

// app/api/learncoin/webhook/route.ts
import { verifyLearnCoinWebhook } from "@/lib/learncoin";

export async function POST(req: Request) {
  const raw = await req.text();
  const ok = verifyLearnCoinWebhook(
    raw,
    req.headers.get("x-learncoin-signature"),
  );
  if (!ok) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(raw);

  // Dedupe on event.id (see above)
  // …

  switch (event.type) {
    case "batch.anchored":
      await onBatchAnchored(event.data);
      break;
    case "credential.revoked":
      await onCredentialRevoked(event.data);
      break;
    // …
  }

  return Response.json({ ok: true });
}

Testing locally

  1. Expose your local endpoint via ngrok http 3000 (or similar) to get a public URL.
  2. Register it as a webhook with events: ["webhook.test"].
  3. Call POST /v1/webhooks/{id}/test and watch the delivery.
  4. Once signature verification is working, add real event types.

Checklist

  • Endpoint verifies the HMAC signature on every request
  • Endpoint compares signature with timingSafeEqual (constant-time)
  • Endpoint rejects events older than 5 minutes (replay protection)
  • Endpoint deduplicates on event.id
  • Endpoint returns 2xx within 10 seconds (do slow work async)
  • Endpoint handles at-least-once delivery idempotently

On this page