MastriaDocs

Integrations & exports

Mastria connects to the rest of your stack in four places, all of them plain and portable: courses can live in your GitHub repo, completion and certificate events flow out through signed webhooks, your product's own metrics and account facts flow in through two small APIs, and every table exports as CSV. Nothing here needs a native app or a marketplace, and all of it is on every tier.

A fifth door runs the other way: a lab you host can be framed inside a lesson, receive a signed token naming the learner, and report results back so they count like any lab here — see Embedded labs.

Courses as code

Keep courses in a GitHub repo and review changes as pull requests. Connect the repo in Admin → Settings (owner/name, branch, the folder holding course folders; a token only for private repos), and enable publish on merge so a merged PR publishes itself — or press Sync now.

Each course is a folder with a course.yaml manifest and markdown lesson files. The manifest carries the structure (stable section and lesson slugs, titles, order) plus optional keys for everything the storefront shows, so the look versions with the content: description, image, level, duration and tags for the course card, seo for search & social overrides, and next for the recommended next course. Synced lessons validate and publish exactly like admin publishes — invalid labs are reported, never shipped — and every version lands in the same history, so a bad merge rolls back like any other publish.

Merging is releasing. Every sync ships the lessons that changed as one release in course history, with the commit message as the release note — your course versions read like your changelog. A sync reads every file at the commit it records, so a push landing mid-sync never mixes two versions under one release. The course's Review & publish screen doubles as the sync report: what the last sync published, what failed validation, and a Sync now button.

Publish on merge is a GitHub webhook: enabling it in Settings gives you a signing secret; add a webhook on the repo (Settings → Webhooks) with payload URL https://mastria.dev/api/github-webhook, content type application/json, push events only, and that secret. Pushes to other branches are ignored; several academies can sync from one repo.

Important

For synced courses the repo is the source of truth: the next sync overwrites admin edits to them. Every published version stays in history, but unpublished draft edits are lost — to keep a change, put it in the repo. Access and completion settings stay editable in the admin: who may see a course and whether it certifies are operational calls, not content.

Version diffs are built in: compare any snapshot, the draft, and the live version from the lesson's history.

Removing a synced course. A sync never deletes or restores a course: deleting a folder from the repo only stops its updates (the course stays as it was), and lessons dropped from a manifest are reported as orphaned, never removed. To retire a synced course, delete it on the Courses page (or in its settings) — it moves to Deleted like any other course and can be restored from there. While it sits in Deleted, syncs skip it and say so in the sync report; remove its folder from the repo when you're done with it (a never-published course deletes for good, and a folder still in the repo would recreate it as a draft). To take it off the academy without deleting anything, unpublish it from its sync report or hide it in Access.

Outbound webhooks

Set an https endpoint in Admin → Settings → Outbound webhook. The first save mints a signing secret (shown on the page; rotate it any time). From then on Mastria POSTs one JSON event per occurrence:

EventWhen
lesson.completedA learner completes a lesson (button, or the lesson's labs and quizzes done).
course.completedThat completion finished the course: every published lesson is now complete. Once per learner and course, ever — completing a lesson again, or a lesson published later, doesn't send it twice.
account.first_course_completedThat finish is the first finished course anyone at the learner's account has recorded — an onboarding milestone for your CRM. Sent with the course.completed it accompanies, with the same learner, account and course.
certificate.issuedA certificate is issued — once per learner and course (or learning path), ever. Certificates issued to earlier finishers when you switch a course's certificate on are sent too, a few minutes later rather than at once (they go out with the next delivery sweep, so a large back-catalogue never arrives in one burst).
course.ratedA learner rated a course, or changed their rating. Sent once per save that changed something.
lesson.reportedA learner reported a problem with a lesson, or changed their open report. Sent once per save that changed something.
learner.mergedAn admin merged two learners into one (from a learner's page under Admin → Learners).
webhook.testYou pressed Send a test event. It carries only id, event, occurred_at and academy.name.

Every learning event — lesson, course, account, certificate, rating and report events — carries what a warehouse needs to build on:

  • id — the event's id. Retries and resends send the same id, so store it and skip what you've already processed.
  • occurred_at — when it happened: the completion, the issue, the merge. Never when it was sent, so an event retried a day later still carries the original time.
  • learner.id — the learner's id at the academy, stable across email changes; learner.email — their current address; and learner.user_id — your id for the person, when your metric ingest has sent it paired with one of their addresses (see below), otherwise null.
  • learner.account — the company account the learner is in when the event fires: id, name, domains (every email domain the account claims), crm_id (your CRM's id for it, when set — the key to join your CRM's account on), type (customer, partner, prospect, internal, or null while unsorted) and tags. null for an Individual (a personal email address, or someone an admin moved out of an account).
  • release — the published content the learner saw. A lesson event names the lesson's live publish: published_at and its revision_id. A course event names the course's latest publish; a certificate names the publish it was issued for, a rating the publish that was live when the learner rated, and a report the lesson version it was made on. null for content with no publish record. (learner.merged carries no release.)

Lesson and course events carry course.slug/course.title (a learning-path certificate carries path instead); lesson events add lesson, and — when the lesson has quiz questions — quiz: how the learner did, as { "questions": 6, "right_first_try": 4, "revealed": 1 } (right on the first answer, and answers shown); certificate events add certificate — url is the verification page, credential_url the Open Badges 3.0 document beside it:

JSON
{
  "id": "0b6f5f0e-6a3c-4c1e-9f0e-3f5d2c8a7b14",
  "event": "certificate.issued",
  "occurred_at": "2026-08-16T18:01:01.859Z",
  "learner": {
    "id": "2d0299ad-aab6-4b6c-89f8-b55c2e29594c",
    "email": "ada@datacraft.io",
    "user_id": "u_1042",
    "account": {
      "id": "5b1e9c4d-2f7a-4d3e-8b6a-1c0d9e8f7a65",
      "name": "DataCraft",
      "domains": ["datacraft.io"],
      "crm_id": "0015g00000Xyz12",
      "type": "customer",
      "tags": ["enterprise", "eu"]
    }
  },
  "course": { "slug": "sql-fundamentals-duckdb", "title": "SQL Fundamentals with DuckDB" },
  "release": { "published_at": "2026-08-11T09:33:00.000Z" },
  "certificate": {
    "id": "cd66f288-98da-4e61-a3b0-31b052f79850",
    "url": "https://demo.mastria.dev/certificates/cd66f288-98da-4e61-a3b0-31b052f79850",
    "credential_url": "https://demo.mastria.dev/certificates/cd66f288-98da-4e61-a3b0-31b052f79850/credential",
    "credential": "SQL Fundamentals with DuckDB",
    "tier": "assessed",
    "issued_at": "2026-08-16T18:01:01.859Z"
  }
}

course.rated carries course and the learner's rating as saved (see Ratings). stars is 1 to 5; reasons are fixed keys — outdated, too-basic, too-advanced, broken (Something didn't work), unclear (Hard to follow) — and only appear on ratings of 3 stars or fewer; comment is the learner's note or null; lessons_completed of lessons_total is how far through the course they were. A changed rating sends a new event: its first_rated_at is earlier than its occurred_at. Key ratings by learner.id and course.slug, and keep the latest.

JSON
{
  "id": "4e8a1c2d-7b3f-4a9e-8c1d-2f6b0e9a7c35",
  "event": "course.rated",
  "occurred_at": "2026-10-05T14:22:08.512Z",
  "learner": { "id": "8f3b2a1c-6d4e-4f7a-9b2c-1e0d5a6f7b48", "email": "grace.h@gmail.com", "user_id": null, "account": null },
  "course": { "slug": "sql-fundamentals-duckdb", "title": "SQL Fundamentals with DuckDB" },
  "release": { "published_at": "2026-09-12T08:10:00.000Z" },
  "rating": {
    "stars": 2,
    "reasons": ["outdated"],
    "comment": "Lesson 4 uses the old CLI flags.",
    "lessons_completed": 6,
    "lessons_total": 8,
    "first_rated_at": "2026-10-05T14:22:08.512Z"
  }
}

lesson.reported carries course, lesson and the report (see Learner reports): reason is a fixed key — outdated, broken (Something doesn't work), unclear (It's hard to follow), typo — note is the learner's text or null, and first_reported_at is earlier than occurred_at when the learner changed an open report. Publishing the lesson again clears its reports; the next report on the new version names the new release.

learner.merged tells you to re-key: learner is the identity that remains, and merged_from is the one that no longer exists — its id and every address it signed in with, so rows you keyed by either can move over:

JSON
{
  "id": "9c2d1b7e-1f4a-4e7b-8a6c-2b1d0e9f8a37",
  "event": "learner.merged",
  "occurred_at": "2026-09-25T10:12:44.120Z",
  "learner": { "id": "2d0299ad-aab6-4b6c-89f8-b55c2e29594c", "email": "ada@newco.io", "user_id": null, "account": null },
  "merged_from": { "id": "71c3e0a2-5d8b-4f6e-9a1c-0e4b7d2f6a58", "emails": ["ada@datacraft.io"] }
}

Verifying the signature

Verify authenticity with the X-Mastria-Signature header: the hex-encoded HMAC-SHA256 of the raw request body, keyed with your secret. Compare it in constant time before trusting the payload. Each request also carries X-Mastria-Event (the event type), X-Mastria-Event-Id (the same id as in the body) and X-Mastria-Delivery-Attempt (1 for the first try).

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

const expected = createHmac("sha256", process.env.MASTRIA_WEBHOOK_SECRET)
  .update(rawBody)
  .digest("hex");
const received = Buffer.from(signatureHeader ?? "");
const ok =
  received.length === expected.length &&
  timingSafeEqual(Buffer.from(expected), received);

Tip

Embedded labs sign their results with the same scheme, so one verifier covers both directions — see Reporting a result.

Retries and the delivery log

The first attempt happens the moment the event does, without ever slowing the learner down. A delivery counts when your endpoint answers with a 2xx status within 10 seconds. Otherwise it is retried after 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours — 8 attempts over about two days — and then marked failed. Every retry and every resend carries the same event id.

Redirects aren't followed: point the webhook at the final address. The endpoint must be a public https address; a name that resolves to a private network address is refused at connection time.

Admin → Settings → Outbound webhook → Delivery log lists every event of the last 30 days, newest first: the event, who it was about, whether it was delivered, how many attempts it took, your endpoint's last answer, and the exact body. Resend on any row sends it again now (same body, same id); a failed event starts a fresh schedule. While an attempt is being sent, Resend is refused — reload in a few seconds and try again. Send a test event POSTs a webhook.test event so you can check the endpoint and your signature check before a real learner finishes anything. Retries go to the endpoint saved at the time of the attempt — fix a wrong URL and the next retry goes to the right place.

Metric ingest

Impact's answers ("people who finished the onboarding course rose 17 points on created pipeline, relative to coworkers who hadn't finished") read your product's own metrics before and after each learner finished — see Dashboard & analytics. Send those metrics keyed by the person's email, by your own user id, or both; Mastria matches them to learners, including addresses a learner has since changed away from.

Generate a token on Admin → Impact → Manage data (the button reads Connect your product until your first event arrives), then POST to the platform host:

Shell
curl -X POST https://mastria.dev/api/ingest \
  -H "Authorization: Bearer <your ingest token>" \
  -H "Content-Type: application/json" \
  -d '{"user_id":"u_42","email":"dev@customer.com","metric":"activated","occurred_at":"2026-09-24T09:30:00Z"}'
  • Send one event as an object, or a batch as an array of up to 1000.
  • metric is required, and so is at least one of email and user_id. Metric names are letters, digits, _, ., -, starting with a letter or digit (up to 100 characters) — activated, pipelines.created, tickets_filed — and are lower-cased as they arrive, so Activated and activated are one metric. A new name is a new metric; nothing is set up in advance.
  • user_id is whatever your product analytics keys people by (up to 200 characters, case kept). An event that carries both user_id and email teaches Mastria which address the id belongs to — the most recent pairing wins — so later events can send the id alone. An event keyed only by an id is matched through that pairing whenever it's read: send the pairing tomorrow and today's events still count. An id that has never been paired can't be matched to a learner until it is. Outbound webhooks carry the pairing back as learner.user_id.
  • occurred_at (ISO 8601 with offset) is when it happened in your product. An event without it is stored undated — kept and exported, but never used in an Impact answer, because a before and after needs to know when. (Mastria doesn't stamp it with the time it arrived: a monthly export sent in one go would land a month of events on one day.)
  • value (a number) is optional; a missing value counts as one occurrence.
  • Responses: {"ingested": N, "undated": M} on success (undated counts the events that arrived without occurred_at), 401 for a missing or unknown token, 400 for invalid JSON, 422 with the field errors for a malformed event.

Tip

Send your history too. Impact measures each person's 30 days before they finished, so events from before you connected are what make those numbers complete — and the page warns you where its data begins.

No engineering time to spare? The same page accepts a CSV paste for a monthly export from your warehouse or helpdesk. With a header row, columns are read by name in any order — email, user_id, metric, value, occurred_at (other columns are ignored). Without one, lines are email,metric[,value[,occurred_at]]. Include occurred_at; the paste says how many lines arrived undated. Regenerating the token invalidates the old one immediately.

The accounts API

Keep each account's details current from your CRM without a native app: your script or reverse-ETL job posts what it knows, and Mastria applies it. It uses the same token as the metric ingest (generated on Admin → Impact → Manage data).

Shell
curl -X POST https://mastria.dev/api/accounts \
  -H "Authorization: Bearer $MASTRIA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain": "acme.com", "crm_id": "0015g00000Xyz12", "type": "customer",
       "tags": ["enterprise", "eu"], "seats": 40, "renews_on": "2027-03-31",
       "owner_email": "dana@yourcompany.com"}'

Send one account or an array of up to 1000. Each one is found by account_id (Mastria's id, from accounts.csv or a webhook), then by crm_id (an account already holding it), then by domain (the account that claims it). A domain no account holds creates the account, named name or after the domain, and gathers anyone who already signed in from it.

FieldValue
account_id, crm_id, domainHow the account is found; at least one is required. A crm_id on an account found by id or domain is set on it (one account per CRM id).
nameRenames the account (names are unique within the academy).
typecustomer, partner, prospect, internal, or null for unsorted.
tagsReplaces the account's tags. add_tags and remove_tags change them instead.
owner_emailA staff member of the academy, or null.
seatsA whole number of seats bought, or null.
renews_onYYYY-MM-DD, or null.
certified_targetHow many certified people the account should have (a partner tier), or null.

A field left out stays as it is; null clears it. The response counts what happened and names any account that couldn't apply — the rest go through:

JSON
{ "created": 1, "updated": 37, "unchanged": 412, "errors": ["account 3: owner_email ana@other.com isn't a staff member of this academy"] }

The Accounts page takes the same fields as a CSV paste (Update accounts from a CSV), where an empty cell leaves a field alone and tags in one cell are separated by ;.

CSV exports

Every table walks out the door as plain CSV, staff-only, from the admin. Every learner-keyed file names the learner's account as it is today (account_id, the key the webhooks carry too — empty for an Individual), so a warehouse or CRM joins on the account without rebuilding it from email domains:

ExportWhereContents
learners.csvAdmin → Settings → Exports, or the Learners pageEvery learner: email, name, account ("Individual" when none), lessons started and completed, courses completed, last active, joined, and how they first arrived — source (the sign-up channel), referrer, utm_source, utm_medium, utm_campaign, landing_page, signup_page (empty for learners who signed up before sources were recorded) — then account_id.
accounts.csvAdmin → Settings → Exports, or Export accounts on the Accounts pageEvery account, one row each: account_id, name, domains (space-separated), learners, active_30d (active in the last 30 days, today included), courses_completed, certificates (held by the account's learners today), last_active, created, then the account's own fields: type, tags (;-separated), crm_id, owner_email, seats, renews_on, certified_target, and certified (learners holding a certificate). The same numbers as the Accounts table, and the same column names the accounts API and the CSV paste take, so the file can go back in.
certificates.csvAdmin → Settings → Exports, or the Certificates ledgerEvery credential: holder, name at issue, renamed_since_issue, course or path, credential, tier, issued/refreshed/emailed dates, verification URL, Open Badges document URL, and its reach since counting began — views (by anyone but the holder), linkedin_clicks, start_clicks — then the holder's account_id and account.
metric-events.csvAdmin → Settings → ExportsEvery ingested metric event (newest 100k): email and user_id as sent, resolved_email (the address a user_id-only event is paired with), metric and value as sent, occurred_at as sent (empty when undated), received_at, source (webhook or csv), and counts_as (the metric Impact counts the event under: the one its name was merged into, else its own name).
reach.csvAdmin → ReachEvery daily count (newest 100k): day, metric (page_view, lab_run, linkedin_click, start_click), audience (visitor or learner), page kind, page id and title, count. No visitor identity exists to export.
searches.csvAdmin → ReachThe search box's queries per day: searches, how many found nothing, how many opened a result.
lab-events.csvAdmin → ReachEach signed-in learner's Run presses and hint reveals (newest 100k): time, email, course, lesson, lab, exercise, event, hint number, whether the run finished without an error, a staff flag, and the learner's account_id and account.
quiz-answers.csvAdmin → QuizzesEvery quiz answer, reveal and flag (newest 100k): time, email, course, lesson, quiz, question, question_version (the answer key it was judged by — a new version means the right answer changed), event (answer, reveal, flag), attempt number, the response as the learner gave it (option letters, the typed text, or the lines in order), whether it was right, a staff flag, a flag's reason (wrong-key, also-right, unclear, outdated; empty for flags raised before reasons existed), and the learner's account_id and account.
course-ratings.csvAdmin → Settings → Exports, or Export every rating (CSV) on a course's page in Course analyticsEvery course rating (newest 100k), one row per learner and course with their latest rating: rated_at, first_rated_at, email, name, course_slug, course_title, stars, reasons (;-separated keys: outdated, too-basic, too-advanced, broken, unclear), comment, lessons_completed and lessons_total when they rated, course_published_at (the course publish they rated — earlier than the course's latest means they saw an earlier version), a staff flag, and the learner's account_id and account. A name or comment starting with =, +, - or @ gets a leading ', so a spreadsheet shows it as text instead of running it as a formula.
lesson-reports.csvAdmin → Settings → Exports, or Export every report (CSV) on the Content freshness pageEvery lesson report (newest 100k): reported_at (last changed), first_reported_at, email, name, course_slug, lesson_slug, reason (outdated, broken, unclear, typo), note, status (open — listed on the Content freshness page; reviewed — someone pressed Mark as reviewed; superseded — the lesson was published again since; unpublished — the lesson or its course was deleted or unpublished), reviewed_at, a staff flag, and the learner's account_id and account. A name or note starting with =, +, - or @ gets a leading '.
Course funnelThe course's page in Course analyticsPer-lesson opened and completed counts, for the learners and the period the page is showing.
Lab difficultyThe course's page in Course analyticsOne row per lab exercise, in lesson order: lesson title, lab and exercise (ids and titles), learners, attempts, learners who passed, solutions applied (checkpoints_used), Run presses, learners who opened a hint (both empty where they weren't recorded yet), and the check that fails most with its count — for the learners and the period the page is showing.
WaitlistCourse settings → Access → Download CSV (while a course is coming soon, and afterwards while it has signups)Signups with dates.

Full content export — your entire academy as markdown, assets, and structure in one click — is on the roadmap; courses as code already gives repo-synced academies that portability today.