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:
| Event | When |
|---|---|
lesson.completed | A learner completes a lesson (button, or the lesson's labs and quizzes done). |
course.completed | That 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_completed | That 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.issued | A 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.rated | A learner rated a course, or changed their rating. Sent once per save that changed something. |
lesson.reported | A learner reported a problem with a lesson, or changed their open report. Sent once per save that changed something. |
learner.merged | An admin merged two learners into one (from a learner's page under Admin → Learners). |
webhook.test | You 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; andlearner.user_id— your id for the person, when your metric ingest has sent it paired with one of their addresses (see below), otherwisenull.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, ornullwhile unsorted) andtags.nullfor 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_atand itsrevision_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.nullfor content with no publish record. (learner.mergedcarries norelease.)
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:
{
"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.
{
"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:
{
"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).
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:
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.
metricis required, and so is at least one ofemailanduser_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, soActivatedandactivatedare one metric. A new name is a new metric; nothing is set up in advance.user_idis whatever your product analytics keys people by (up to 200 characters, case kept). An event that carries bothuser_idandemailteaches 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 aslearner.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 (undatedcounts the events that arrived withoutoccurred_at),401for a missing or unknown token,400for invalid JSON,422with 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).
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.
| Field | Value |
|---|---|
account_id, crm_id, domain | How 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). |
name | Renames the account (names are unique within the academy). |
type | customer, partner, prospect, internal, or null for unsorted. |
tags | Replaces the account's tags. add_tags and remove_tags change them instead. |
owner_email | A staff member of the academy, or null. |
seats | A whole number of seats bought, or null. |
renews_on | YYYY-MM-DD, or null. |
certified_target | How 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:
{ "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:
| Export | Where | Contents |
|---|---|---|
learners.csv | Admin → Settings → Exports, or the Learners page | Every 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.csv | Admin → Settings → Exports, or Export accounts on the Accounts page | Every 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.csv | Admin → Settings → Exports, or the Certificates ledger | Every 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.csv | Admin → Settings → Exports | Every 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.csv | Admin → Reach | Every 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.csv | Admin → Reach | The search box's queries per day: searches, how many found nothing, how many opened a result. |
lab-events.csv | Admin → Reach | Each 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.csv | Admin → Quizzes | Every 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.csv | Admin → Settings → Exports, or Export every rating (CSV) on a course's page in Course analytics | Every 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.csv | Admin → Settings → Exports, or Export every report (CSV) on the Content freshness page | Every 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 funnel | The course's page in Course analytics | Per-lesson opened and completed counts, for the learners and the period the page is showing. |
| Lab difficulty | The course's page in Course analytics | One 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. |
| Waitlist | Course 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.