> For the complete documentation index, see [llms.txt](https://docs.learncard.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.learncard.com/build/sending-xapi-statements.md).

# Record Learning Activity

After a user connects their LearnCard, log what they do — and let them see it in their Activity Feed.

You've connected a user's LearnCard to your platform — see [Connect a User's LearnCard](/build/create-a-consentflow.md). Now log what they do there. Each activity becomes an [xAPI statement](/understand/verifiable-credentials-vcs/xapi-data.md) stored in the user's own LearnCloud, and shows up in their LearnCard Activity Feed.

{% hint style="info" %}
**\~15 min** · After [Connect a User's LearnCard](/build/create-a-consentflow.md).
{% endhint %}

## 1. The consent `vp` is your permission slip

When the user accepted your contract, the `vp` in the redirect wasn't only proof of who consented. It contains a **delegate credential** the user issued to your DID granting `statementAccess: ['read', 'write']`. Send that same `vp` string as the `X-VP` header and LearnCloud will accept statements about that user from you.

So: store the `vp` per user, server-side, encrypted. It is a bearer credential — never log it or expose it to the browser.

{% hint style="info" %}
**Treat the `vp` like any bearer credential you hold on a user's behalf.** Check `verifyConsent` before each write and delete the stored `vp` when a user withdraws — the pattern below does both. Today the xAPI endpoint validates the `vp` itself rather than re-checking live consent, so this check is yours to make.
{% endhint %}

Save the blocks below together as `record-activity.mjs` next to the two helper files:

```javascript
import { initLearnCard } from '@learncard/init';
import { verifyConsentRedirect } from './consent-callback.mjs';
import { readUserData } from './read-user-data.mjs';

const { SECURE_SEED, CONTRACT_URI, CONSENT_VP: vp } = process.env;
if (!SECURE_SEED || !CONTRACT_URI || !vp) {
    throw new Error('Set SECURE_SEED, CONTRACT_URI, and CONSENT_VP');
}
const learnCard = await initLearnCard({ seed: SECURE_SEED, network: true });
const verified = await verifyConsentRedirect(learnCard, vp, CONTRACT_URI);
if (verified.status !== 'verified') throw new Error('Consent denied or abandoned');
const userDid = verified.userDid; // Verified holder, not the redirect's did parameter

const requireConsent = async () => {
    if ((await readUserData(learnCard, userDid, CONTRACT_URI)).length === 0) {
        throw new Error('No active consent');
    }
};
```

## 2. Send a statement

The `actor` is the **user** (their DID), not you. LearnCloud checks that the delegate credential inside `X-VP` was issued by that same DID and includes `write`; the statement is then stored under the user's account.

This example uses the standard `completed` verb. Replace the sample activity URI with a stable URI owned by your platform, following the [activity and verb naming rules](/understand/verifiable-credentials-vcs/xapi-data.md).

```javascript
const endpoint = 'https://cloud.learncard.com/xapi/statements';
const headers = {
    'Content-Type': 'application/json',
    'X-Experience-API-Version': '1.0.3',
    'X-VP': vp,
};
const actor = {
    objectType: 'Agent',
    name: userDid,
    account: { homePage: 'https://www.w3.org/TR/did-core/', name: userDid },
};
const statement = {
    actor,
    verb: {
        id: 'http://adlnet.gov/expapi/verbs/completed',
        display: { 'en-US': 'completed' },
    },
    object: {
        id: 'https://example.com/activities/lesson-1',
        definition: {
            name: { 'en-US': 'Lesson 1' },
            description: { 'en-US': 'An introductory learning activity.' },
        },
    },
    timestamp: new Date().toISOString(),
};

await requireConsent();
const sent = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify(statement),
});
if (!sent.ok) throw new Error(`Statement write failed: ${sent.status}`);
const ids = await sent.json();
console.log('Stored statement IDs:', ids);
```

The proxy copies the signed `contractUri` into `context.extensions['https://learncard.com/xapi/extensions/contractUri']` automatically. You do not need to modify or re-sign the presentation.

## 3. Read statements back

Use the same `vp` and actor with a `GET` request. Delegated reads require `read` instead of `write`, with the same issuer/actor and holder matching rules.

```javascript
await requireConsent();
const params = new URLSearchParams({ agent: JSON.stringify(actor), limit: '10' });
const received = await fetch(`${endpoint}?${params}`, { headers });
if (!received.ok) throw new Error(`Statement read failed: ${received.status}`);
const data = await received.json();
const contractExtension = 'https://learncard.com/xapi/extensions/contractUri';
const matching = data.statements.filter(
    item => item.context?.extensions?.[contractExtension] === CONTRACT_URI
);
console.log(
    'Found this statement:',
    matching.some(item => ids.includes(item.id))
);
```

The response contains `statements` and may contain a `more` pagination link. The query selects the user's activity; the local filter selects this contract. See [contract-scoped queries](/reference/learncloud-storage-api/xapi-reference.md#contract-scoped-xapi-statements) for more detail.

Run `node --env-file=.env record-activity.mjs` once. Re-running creates another activity record; production writes should come from authorized learning events with your own duplicate prevention, not from the consent callback GET.

## 4. What the user sees

In LearnCard, open **Manage Data Sharing**, select your contract, and open **xAPI Data Feed**. The Activity Feed shows the activity name, verb, timestamp, and any result data, with a raw JSON view for inspection. It filters by the contract extension injected above, so keeping the original consent VP also keeps the activity associated with the right contract.

## What you should see

* The POST returns a successful response containing the stored statement ID(s).
* The read prints `Found this statement: true` for the newly stored activity; if other activity fills the first page, follow pagination.
* The learner sees **completed · Lesson 1** in the contract's Activity Feed.
* After withdrawal, the consent guard stops further reads and writes with `No active consent`.

## Troubleshooting

For authentication failures, see [Common causes of 401](/reference/learncloud-storage-api/xapi-reference.md#common-causes-of-401), including the delegated-access exception to direct actor/holder matching. Do not substitute an API key or Partner Connect identity token for `vp`.

If a stored statement is missing from the contract feed, check its contract extension against the saved `CONTRACT_URI` and confirm you used the original consent VP. A missing or invalid actor account is a bad request, not evidence that you need another identity token.

## Next steps

* Add scores and completion results using the [xAPI concepts guide](/understand/verifiable-credentials-vcs/xapi-data.md).
* Explore [advanced statement queries](/reference/learncloud-storage-api/xapi-reference.md#advanced-xapi-statement-queries).
* [Issue a credential through the contract](/build/create-a-consentflow.md#5-issue-a-credential-through-the-contract) when an authorized completion event earns a badge.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.learncard.com/build/sending-xapi-statements.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
