@learncard/partner-connect
v0.4.2
Published
LearnCard Partner Connect SDK - Promise-based cross-origin messaging for partner apps
Readme
@learncard/partner-connect
Promise-based JavaScript SDK for managing cross-origin messaging between partner apps and LearnCard
The LearnCard Partner Connect SDK transforms complex postMessage communication into clean, modern Promise-based functions. It handles the entire cross-origin message lifecycle, including request queuing, message validation, and timeout management.
Features
- 🔒 Secure: Origin validation for all messages
- 🎯 Type-safe: Full TypeScript support with comprehensive types
- ⚡ Promise-based: Modern async/await API
- 🧹 Clean: Abstracts away all postMessage complexity
- 📦 Lightweight: Zero runtime dependencies
- 🛡️ Robust: Built-in timeout handling and error management
Installation
npm install @learncard/partner-connectpnpm add @learncard/partner-connectyarn add @learncard/partner-connectQuick Start
import { createPartnerConnect } from '@learncard/partner-connect';
// Initialize the SDK
const learnCard = createPartnerConnect({
hostOrigin: 'https://learncard.app',
});
// Request user identity (SSO)
try {
const identity = await learnCard.requestIdentity();
console.log('User DID:', identity.user.did);
console.log('JWT Token:', identity.token);
} catch (error) {
if (error.code === 'LC_UNAUTHENTICATED') {
console.log('User is not logged in');
}
}Configuration
Options
interface PartnerConnectOptions {
/**
* The origin(s) of the LearnCard host
* Single string or array for query parameter whitelist
* @default 'https://learncard.app'
*/
hostOrigin?: string | string[];
/**
* Whether to allow native app origins (Capacitor/Ionic)
* @default true
*/
allowNativeAppOrigins?: boolean;
/**
* Protocol identifier (default: 'LEARNCARD_V1')
*/
protocol?: string;
/**
* Request timeout in milliseconds (default: 30000)
*/
requestTimeout?: number;
/**
* Controls automatic standalone mock mode.
* 'auto' (default) mocks only when no LearnCard host is present AND the
* page runs on a local dev host; 'standalone' mocks whenever no host is
* present, on any origin; true always mocks; false never mocks.
*/
mock?: boolean | 'auto' | 'standalone';
/**
* Fine-grained mock behavior (UI, logging, persistence, fake DID).
*/
mockOptions?: MockHostOptions;
/**
* How long (ms) to wait for the host presence probe when embedded in a
* frame whose parent can't be confirmed as LearnCard (default: 1500).
*/
hostProbeTimeout?: number;
}Dynamic Origin Configuration
The SDK uses a hierarchical approach to determine the active host origin:
1. Hardcoded Default (Security Anchor)
PartnerConnect.DEFAULT_HOST_ORIGIN; // 'https://learncard.app'2. Query Parameter Override (Staging/Testing)
// Your app URL: https://partner-app.com/?lc_host_override=https://staging.learncard.app
const learnCard = createPartnerConnect({
hostOrigin: ['https://learncard.app', 'https://staging.learncard.app'],
});
// Active origin: https://staging.learncard.app (from query param)
// ✅ Only accepts messages from: https://staging.learncard.app
// ✅ Sends messages to: https://staging.learncard.appHow the LearnCard Host Uses This:
- Production: Iframe URL has no
lc_host_overrideparameter - Staging: Iframe URL includes
?lc_host_override=https://staging.learncard.app - This allows testing against non-production environments without recompiling partner code
3. Configured Origin (Fallback)
const learnCard = createPartnerConnect({
hostOrigin: 'https://learncard.app',
});
// Active origin: https://learncard.app (configured)Origin Whitelist (Security Gate)
When providing multiple origins, they serve as a whitelist for the lc_host_override parameter:
const learnCard = createPartnerConnect({
hostOrigin: [
'https://learncard.app',
'https://staging.learncard.app',
'https://preview.learncard.app',
],
});
// Scenario 1: No query param
// → Uses: https://learncard.app (first in array)
// Scenario 2: Valid override
// URL: ?lc_host_override=https://staging.learncard.app
// → Uses: https://staging.learncard.app ✅
// Scenario 3: Invalid override (not in whitelist)
// URL: ?lc_host_override=https://evil.com
// → Uses: https://learncard.app (falls back to first) ⚠️
// → Logs warning about unauthorized overrideSecurity Model
STRICT Origin Validation:
Incoming Message Origin ≡ Configured Host OriginThe SDK enforces an exact match between incoming message origins and the active host origin:
- ✅ Secure: Even if a malicious actor adds
?lc_host_override=https://evil.com, messages fromevil.comwill be rejected - ✅ Cannot be spoofed: Browser security prevents malicious sites from faking their
event.origin - ✅ No wildcards: Only exact matches are accepted
// Active origin: https://staging.learncard.app
// ✅ Accepts: messages from https://staging.learncard.app
// ❌ Rejects: messages from https://learncard.app
// ❌ Rejects: messages from https://evil.com
// ❌ Rejects: messages from any other originStandalone / Mock Mode
The SDK only works when embedded inside a LearnCard host — that's the host that
answers its postMessage requests. When you run your app on its own (local dev,
Storybook, a preview deploy, CI), there is no host. Standalone calls that aren't
mocked reject immediately with LC_NOT_EMBEDDED (rather than hanging until the
request timeout), and the SDK logs a one-time hint pointing you to mock mode.
Mock mode fixes this automatically in local development. Whenever no
LearnCard host is present and your app runs on a local dev host (localhost,
127.0.0.1, [::1], *.localhost, *.local) — plain local dev or a local
Storybook — the SDK simulates the host locally:
- Every method shows a branded toast describing what would happen once embedded — e.g.
sendCredential→ "✅ In LearnCard, the user would receive [name] here.",incrementCounter→ "Counter coins → 10.",launchFeature→ "Would open /wallet." So you get strong, visible feedback for every call, not just console logs. requestConsent(...)auto-grants and shows a "mock consent" toast;incrementCounter/getCounter/getCounterspersist tolocalStorageso values survive reloads.- Identical or polled calls coalesce into a single toast with a ×N counter, so nothing spams the screen.
requestIdentity, notifications, learner context, sync status, etc. all resolve with sensible fake data.- Every simulated interaction is also logged to the console with a
[LearnCard SDK · MOCK]prefix.
No code changes, no environment flags in local dev. Your app is fully buildable and demo-able locally, and behaves identically against the real host once embedded.
// Mocks in local dev when standalone; real host when embedded in LearnCard.
const learnCard = createPartnerConnect({ hostOrigin: 'https://learncard.app' });
const res = await learnCard.sendCredential({ templateAlias: 'course-completion' });
// Local dev, standalone: resolves with a mock URI + shows a toast.
// Embedded: goes to the real LearnCard host.'auto' is deliberately scoped to local dev hosts. A standalone page on a
production or remote preview origin never auto-mocks — otherwise a real user
opening your app's URL directly would receive a fabricated identity and
auto-granted consent. For remote deploy previews (Netlify, Lovable, Vercel, …)
that should demo standalone anywhere but go real once embedded, opt in with
mock: 'standalone'; for CI and tests that should always mock, use
mock: true. Every mocked call shows a labeled toast and a
[LearnCard SDK · MOCK] console log, so it's clear the SDK is simulating
rather than talking to a real host.
| mock | Standalone, local dev | Standalone, remote origin | Embedded in LearnCard |
| ------------------ | --------------------- | ----------------------------- | --------------------- |
| 'auto' (default) | mock | fail fast (LC_NOT_EMBEDDED) | real host |
| 'standalone' | mock | mock | real host |
| true | mock | mock | mock |
| false | fail fast | fail fast | real host |
Unrelated iframes don't fool it. If your app is embedded in something that
isn't LearnCard (a cross-origin Storybook canvas, a preview shell), calls no
longer hang: the SDK mocks on local dev hosts and otherwise rejects fast with
LC_NOT_EMBEDDED. When the parent can't be identified (Firefox, or a
same-origin localhost wrapper), the SDK sends a one-time, side-effect-free
presence probe and only mocks if no host answers within hostProbeTimeout
(default 1500 ms).
For a production build that's meant to run only inside LearnCard, set
mock: false; standalone calls then reject immediately with LC_NOT_EMBEDDED
instead of showing simulated data.
Override the default behavior when needed:
// Mock wherever no host is present (remote previews), real host when embedded:
createPartnerConnect({ mock: 'standalone' });
// Always mock, even while embedded (CI, tests):
createPartnerConnect({ mock: true });
// Never mock (standalone calls reject fast with LC_NOT_EMBEDDED):
createPartnerConnect({ mock: false });
// Configure mock behavior:
createPartnerConnect({
mock: 'auto',
mockOptions: {
ui: true, // show toasts/banners (default true)
log: true, // console logging (default true)
persist: true, // localStorage-backed counters (default true)
did: 'did:web:example.com:me', // fake identity DID
namespace: 'my-app-mock', // localStorage namespace for mock state
},
});Check whether an instance is currently mocking:
if (learnCard.isMocked()) {
console.log('Running against the local mock host.');
}Coherent state: reads reflect writes
The mock keeps a small session store so it behaves like a real host, not a set of disconnected stubs. Anything you do in a session shows up in later reads:
await learnCard.sendCredential({ templateAlias: 'course-completion' });
// Now reflects the credential you just issued:
await learnCard.checkUserHasCredential({ templateAlias: 'course-completion' }); // { hasCredential: true, ... }
await learnCard.requestLearnerContext(); // raw.credentials includes itThis means happy-path UI (e.g. a "you already earned this" banner) actually
lights up standalone. Counters persist to localStorage; issued credentials and
identity live for the session (a reload re-applies your seeds — see below). Mock
credentials are clearly marked (_mock: true) and never cryptographically valid,
so they can't be mistaken for real ones.
Seeding data for demos
To demo a pre-populated state (a returning user who already has credentials, a
starting coin balance) without performing actions first, seed via mockOptions:
createPartnerConnect({
mockOptions: {
identity: { did: 'did:web:example.com:me', name: 'Ada' },
credentials: [
{ templateAlias: 'course-completion', name: 'Algebra 101' },
// Model a credential you issued to someone else:
{ boostUri: 'lc:boost:team-badge', recipient: 'alice', status: 'pending' },
],
counters: { coins: 50 },
},
});Seeded credentials feed checkUserHasCredential, getTemplateRecipients,
getTemplateIssuanceStatus, requestLearnerContext, and askCredentialSearch.
Seeded counters are applied only when a counter has no persisted value yet, so
incremented values survive reloads.
Detecting the Embed Context
Use isEmbedded() to branch your own logic based on whether your app is running
inside a LearnCard iframe or as a standalone page — no need to write your own
frame detection.
import { isEmbedded, createPartnerConnect } from '@learncard/partner-connect';
if (isEmbedded()) {
// Inside LearnCard — hide your standalone header, enable SDK-backed features.
} else {
// Standalone — show an "Open in LearnCard" prompt, or lean on mock mode.
}It is also available as a static and instance method:
PartnerConnect.isEmbedded(); // static
createPartnerConnect().isEmbedded(); // instanceisEmbedded() returns false during server-side rendering (no window) and is
safe to call anywhere.
API Reference
requestIdentity()
Request user identity information (Single Sign-On).
const identity = await learnCard.requestIdentity();
// Returns: { token: string, user: { did: string, ... } }Error Codes:
LC_UNAUTHENTICATED: User is not logged in to LearnCardLC_TIMEOUT: Request timed outLC_NOT_EMBEDDED: The app is not embedded in a LearnCard host (standalone, not mocking)
sendCredential(credential)
Send a verifiable credential to the user's LearnCard wallet.
const response = await learnCard.sendCredential({
'@context': ['https://www.w3.org/2018/credentials/v1'],
type: ['VerifiableCredential', 'AchievementCredential'],
credentialSubject: {
id: identity.user.did,
achievement: {
name: 'JavaScript Expert',
description: 'Mastered advanced JavaScript concepts',
},
},
});
console.log('Credential ID:', response.credentialId);Returns: { credentialId: string } (raw credential mode) or { credentialUri: string, boostUri: string } (template mode).
Template mode example with duplicate prevention:
const response = await learnCard.sendCredential({
templateAlias: 'achievement',
templateData: { score: 95 },
preventDuplicateClaim: true,
});
if (response.alreadyClaimed) {
console.log('User already has this credential:', response.credentialUri);
}checkUserHasCredential(input)
Silently check whether the current user already has a credential for a given app boost template.
const result = await learnCard.checkUserHasCredential({
templateAlias: 'achievement',
});
if (result.hasCredential) {
console.log('Already earned:', result.credentialUri, result.receivedDate);
} else {
console.log('Not earned yet');
}You can also query directly by boost URI:
await learnCard.checkUserHasCredential({
boostUri: 'lc:network:network.learncard.com/trpc:boost:abc123',
});Returns: { hasCredential: boolean, credentialUri?: string, receivedDate?: string, status?: 'pending' | 'claimed' | 'revoked' }
getTemplateIssuanceStatus(input)
Check if the current user has issued/sent a specific template to someone. Returns issuance status including sent date and claim status.
const status = await learnCard.getTemplateIssuanceStatus({
templateAlias: 'achievement-badge',
recipient: 'user123', // Can be a profileId or DID (did:web:...)
});
if (status.sent) {
console.log('Issued on:', status.sentDate);
console.log('Status:', status.status); // 'pending', 'claimed', or 'revoked'
if (status.claimedDate) {
console.log('Claimed on:', status.claimedDate);
}
}You can also use a DID as the recipient:
await learnCard.getTemplateIssuanceStatus({
boostUri: 'lc:network:network.learncard.com/trpc:boost:abc123',
recipient: 'did:web:network.learncard.com:users:user456',
});Returns: { sent: boolean, credentialUri?: string, sentDate?: string, claimedDate?: string, status?: 'pending' | 'claimed' | 'revoked' }
getTemplateRecipients(input)
Get the list of all recipients for a specific template/boost. Useful for dashboards showing who has received a credential.
const recipients = await learnCard.getTemplateRecipients({
templateAlias: 'achievement-badge',
limit: 10,
});
console.log(`Found ${recipients.records.length} recipients`);
recipients.records.forEach(r => {
console.log(`${r.recipientDisplayName}: ${r.status}`);
});
// Paginate if more results available
if (recipients.hasMore) {
const nextPage = await learnCard.getTemplateRecipients({
templateAlias: 'achievement-badge',
limit: 10,
cursor: recipients.cursor,
});
}Returns: { records: TemplateRecipientRecord[], hasMore: boolean, cursor?: string, total?: number }
sendNotification(input)
Send a notification to the current user from this app. The notification appears in the user's LearnCard notification inbox, even after they leave the app.
await learnCard.sendNotification({
title: 'Sprint Bonus!',
body: '+10 coins from Sprint 42',
actionPath: '/',
category: 'reward',
});Parameters:
title(optional): Notification titlebody(optional): Notification body textactionPath(optional): Deep link path within the app (e.g.'/prizes'). Must be an absolute pathname starting with/. This path is appended to the app's configured embed URL when the user taps the notification. For example, if your embed URL ishttps://myapp.comandactionPathis'/challenges/42', the app will open athttps://myapp.com/challenges/42. Hash routes (e.g.'/#/page') are not supported — use pathname-based routing.category(optional): Grouping category (e.g.'reward','announcement','status')priority(optional):'normal'(default) or'high'. Affects visual styling of the notification card and toast. Does not change delivery priority or ordering.
At least one of title or body is required.
Returns: { sent: boolean }
Note: This method sends a notification to the current user (self-notification) via the
send-notificationapp event. For server-to-server notifications to arbitrary users, use thePOST /app-store/listing/{listingId}/notifybrain-service route directly from your app backend.
incrementCounter(key, amount)
Increment or decrement an app-scoped counter for the current user. Counters are scoped to (user, app, key). If the counter does not exist, it is created with the given amount as its initial value.
// Add 10 coins
const result = await learnCard.incrementCounter('coins', 10);
console.log(result.newValue); // 10
// Spend 5 coins
const spent = await learnCard.incrementCounter('coins', -5);
console.log(spent.newValue); // 5Parameters:
key(required): Counter name. Must match[a-zA-Z0-9_-]+, max 64 characters.amount(required): Integer value to add. Use a negative integer to decrement.
Returns: { key: string, previousValue: number, newValue: number }
Limits:
- Max 50 distinct counter keys per user per app
- Max 100 writes per user per app per minute
- Amount must be a finite integer
getCounter(key)
Read the current value of an app-scoped counter. Returns { value: 0 } if the counter does not exist.
const { value } = await learnCard.getCounter('coins');
console.log('Balance:', value);Parameters:
key(required): Counter name (same format asincrementCounter)
Returns: { key: string, value: number, updatedAt: string | null }
getCounters(keys?)
Read multiple app-scoped counters at once. If keys is omitted, returns all counters for this app.
// Specific keys
const { counters } = await learnCard.getCounters(['coins', 'spins', 'streak']);
counters.forEach(c => console.log(c.key, c.value));
// All counters
const all = await learnCard.getCounters();Parameters:
keys(optional): Array of counter names to fetch (max 50). Omit to return all.
Returns: { counters: Array<{ key: string, value: number, updatedAt: string | null }> }
launchFeature(featurePath, initialPrompt?)
Launch a feature in the LearnCard host application.
await learnCard.launchFeature(
'/ai/topics?shortCircuitStep=newTopic&selectedAppId=null',
'Explain the postMessage security model'
);Parameters:
featurePath: Path to the featureinitialPrompt: Optional initial data or prompt
askCredentialSearch(verifiablePresentationRequest)
Request credentials from the user's wallet using a Verifiable Presentation Request.
const response = await learnCard.askCredentialSearch({
query: [
{
type: 'QueryByTitle',
credentialQuery: {
reason: 'We need to verify your teamwork skills',
title: 'Capstone',
},
},
],
challenge: `challenge-${Date.now()}`,
domain: window.location.hostname,
});
if (response.verifiablePresentation) {
const credentials = response.verifiablePresentation.verifiableCredential;
console.log(`Received ${credentials.length} credential(s)`);
}Returns: { verifiablePresentation?: { verifiableCredential: unknown[], ... } }
askCredentialSpecific(credentialId)
Request a specific credential by ID.
const response = await learnCard.askCredentialSpecific('credential-id-123');
if (response.credential) {
console.log('Received credential:', response.credential);
}Error Codes:
CREDENTIAL_NOT_FOUND: Credential doesn't existUSER_REJECTED: User declined to share
requestConsent(contractUri)
Request user consent for permissions.
const response = await learnCard.requestConsent(
'lc:network:network.learncard.com/trpc:contract:abc123'
);
if (response.granted) {
console.log('User granted consent');
} else {
console.log('User denied consent');
}Returns: { granted: boolean }
initiateTemplateIssue(templateId, draftRecipients?)
Initiate a template-based credential issuance flow (e.g., Send Boost).
const response = await learnCard.initiateTemplateIssue(
'lc:network:network.learncard.com/trpc:boost:xyz789',
['did:key:z6Mkr...', 'did:key:z6Mks...']
);
if (response.issued) {
console.log('Template issued successfully');
}Error Codes:
UNAUTHORIZED: Not an admin of this templateTEMPLATE_NOT_FOUND: Template doesn't exist
destroy()
Clean up the SDK and remove event listeners. Call this when unmounting your component or closing your app.
learnCard.destroy();Complete Example
Here's a complete example showing how to refactor a manual postMessage implementation to use the SDK:
Before (Manual postMessage)
// Manual setup - verbose and error-prone
const LEARNCARD_HOST_ORIGIN = 'https://learncard.app';
const PROTOCOL = 'LEARNCARD_V1';
const pendingRequests = new Map();
function sendPostMessage(action, payload = {}) {
return new Promise((resolve, reject) => {
const requestId = `${action}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
pendingRequests.set(requestId, { resolve, reject });
window.parent.postMessage(
{
protocol: PROTOCOL,
action,
requestId,
payload,
},
LEARNCARD_HOST_ORIGIN
);
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject({ code: 'LC_TIMEOUT', message: 'Request timed out' });
}
}, 30000);
});
}
window.addEventListener('message', event => {
if (event.origin !== LEARNCARD_HOST_ORIGIN) return;
const { protocol, requestId, type, data, error } = event.data;
if (protocol !== PROTOCOL || !requestId) return;
const pending = pendingRequests.get(requestId);
if (!pending) return;
pendingRequests.delete(requestId);
if (type === 'SUCCESS') {
pending.resolve(data);
} else if (type === 'ERROR') {
pending.reject(error);
}
});
// Usage
const identity = await sendPostMessage('REQUEST_IDENTITY');After (With SDK)
import { createPartnerConnect } from '@learncard/partner-connect';
// Clean, one-line setup
const learnCard = createPartnerConnect({
hostOrigin: 'https://learncard.app',
});
// Usage - same result, much cleaner
const identity = await learnCard.requestIdentity();Error Handling
All methods return Promises that reject with a LearnCardError object:
interface LearnCardError {
code: string;
message: string;
}Common Error Codes:
LC_TIMEOUT: Request timed outLC_NOT_EMBEDDED: Not embedded in a LearnCard host (standalone, not mocking)LC_UNAUTHENTICATED: User not logged inUSER_REJECTED: User declined the requestCREDENTIAL_NOT_FOUND: Credential doesn't existUNAUTHORIZED: User lacks permissionTEMPLATE_NOT_FOUND: Template doesn't existSDK_NOT_INITIALIZED: SDK initialization failedSDK_DESTROYED: SDK was destroyed before completion
Example:
try {
const identity = await learnCard.requestIdentity();
// Success
} catch (error) {
switch (error.code) {
case 'LC_UNAUTHENTICATED':
console.log('Please log in to your LearnCard account');
break;
case 'LC_TIMEOUT':
console.log('Request timed out. Please try again.');
break;
default:
console.error('An error occurred:', error.message);
}
}Integration with Astro
---
// src/pages/index.astro
const config = {
learnCardHostOrigin: import.meta.env.PUBLIC_LEARNCARD_HOST || 'https://learncard.app',
};
---
<script>
import { createPartnerConnect } from '@learncard/partner-connect';
const config = window.__LC_CONFIG;
const learnCard = createPartnerConnect({
hostOrigin: config.learnCardHostOrigin,
});
async function init() {
try {
const identity = await learnCard.requestIdentity();
console.log('Logged in as:', identity.user.did);
} catch (error) {
console.error('Not authenticated:', error);
}
}
init();
</script>Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
Requires postMessage API and Promise support.
Security
The SDK implements multiple security layers:
1. Strict Origin Validation
- Messages must come from the exact active host origin
- No wildcards, no pattern matching, no exceptions
- Mathematical equivalence:
event.origin === activeHostOrigin
2. Query Parameter Whitelist
lc_host_overridevalues are validated against configuredhostOriginarray- Invalid overrides are rejected and logged
- Falls back to first configured origin on validation failure
3. Anti-Spoofing Protection
Even if a malicious actor injects ?lc_host_override=https://evil.com:
- The SDK may adopt
evil.comas the active origin (if not whitelisted) - BUT messages from
evil.comwill only be accepted ifevent.origin === 'evil.com' - Browser security prevents
evil.comfrom spoofing another domain's origin - Malicious messages are silently rejected
4. Additional Security Layers
- Protocol Validation: Messages must match the expected protocol identifier
- Request ID Tracking: Only tracked requests with valid IDs are processed
- Timeout Protection: Requests automatically timeout to prevent hanging
- Explicit targetOrigin: Never uses
'*'in postMessage calls
Example Attack Scenario (Prevented)
// Attacker adds malicious query param
// URL: https://partner-app.com/?lc_host_override=https://evil.com
// SDK configuration
const learnCard = createPartnerConnect({
hostOrigin: ['https://learncard.app', 'https://staging.learncard.app'],
});
// What happens:
// 1. SDK detects lc_host_override=https://evil.com
// 2. Validates against whitelist: NOT FOUND
// 3. Falls back to: https://learncard.app
// 4. Sends messages to: https://learncard.app
// 5. Only accepts messages from: https://learncard.app
// 6. Attacker's messages from evil.com: REJECTED ❌TypeScript
The SDK is written in TypeScript and includes comprehensive type definitions:
import type {
PartnerConnectOptions,
IdentityResponse,
SendCredentialResponse,
VerifiablePresentationRequest,
CredentialSearchResponse,
ConsentResponse,
LearnCardError,
} from '@learncard/partner-connect';Learner Context Sync Readiness
Apps that need a complete learner snapshot can ask LearnCard to wait for background data sync:
const context = await learnCard.requestLearnerContext({
includeCredentials: true,
waitForSync: true,
format: 'structured',
});
if (context.status === 'syncing') {
const unsubscribe = learnCard.onSyncComplete(async () => {
const readyContext = await learnCard.requestLearnerContext({
includeCredentials: true,
waitForSync: true,
format: 'structured',
});
unsubscribe();
console.log(readyContext.raw?.credentials);
});
}Use learnCard.getSyncStatus() to render your own progress UI:
const syncStatus = await learnCard.getSyncStatus();
console.log(syncStatus.status, syncStatus.progress);License
MIT
Contributing
Contributions are welcome! Please see the main LearnCard repository for contribution guidelines.
Support
For issues and questions:
- GitHub Issues: https://github.com/learningeconomy/LearnCard/issues
- Documentation: https://docs.learncard.com
