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_secretimmediately. 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:
| Attempt | Delay after previous |
|---|---|
| 1 | — |
| 2 | 1 min |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | 6 h |
| 7 | 12 h |
| 8 | 24 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 ackSample 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
- Expose your local endpoint via
ngrok http 3000(or similar) to get a public URL. - Register it as a webhook with
events: ["webhook.test"]. - Call
POST /v1/webhooks/{id}/testand watch the delivery. - 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
2xxwithin 10 seconds (do slow work async) - Endpoint handles at-least-once delivery idempotently