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 (a third deletes a learner on request), every table exports as CSV, and all of your content downloads as one zip. 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 → Integrations → Courses as code (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, which asks first (the repo's version replaces edits made in the admin to synced courses) and shows the last sync's result right under it. Owners and admins set the repo up; anyone on the team can 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 the same Sync now, which asks the same question.

Publish on merge is a GitHub webhook: turning it on in the same card gives you a signing secret (hidden until Reveal; Copy works either way); 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.

Content export

Download .zip under Export all content, at the foot of Admin → Courses (or Admin → Settings → Exports), gives you every course, template and the Lesson Library in one zip. It's staff-only, on every tier, and works even while your academy has no plan. The zip is a courses-as-code repo:

  • courses/ — one folder per course: course.yaml and a markdown file per lesson, the same format as Courses as code. A synced course keeps its repo folder and file names.
  • templates/ and library/ — the same format.
  • academy.json — what course.yaml doesn't hold: publish state, visibility, certificate settings, audience (account ids match account_id in accounts.csv), About pages, working titles and transcripts.
  • README.md — what's where.

A lesson's file is what learners see, and course.yaml lists only those. Unpublished edits and lessons never published are in the course's drafts/ folder. A course with no published lesson lists its drafts in course.yaml. The About page is about.md (an unsaved draft, drafts/about.md), video transcripts are in transcripts/. An exam is a lesson like any other, its rules in its file's ```exam fence, and course.yaml marks it kind: exam (a sync creates it as an exam).

Labs and quizzes are fenced blocks inside the lessons, so they come along, answer keys included. Images and videos are links to where you host them; Mastria stores no files, so there's nothing else to download. Deleted courses and lessons aren't included, and learner data is in the CSV exports.

To bring an export back, push courses/ to a GitHub repo and connect it under Courses as code with courses as the folder. A sync creates each course unpublished: set its access and certificate (from academy.json), then publish it. From then on the repo is the source of truth for those courses.

Outbound webhooks

Set an https endpoint in Admin → Settings → Integrations → Outbound webhook (owners and admins; the rest of the team sees only its host). The first save mints a signing secret (on the page, hidden until Reveal; rotate it any time). From then on Mastria POSTs one JSON event per occurrence:

EventWhen
learner.joinedSomeone signs in to your academy for the first time — through an emailed sign-in link, or from your product (a sign-in link or your login). Once per learner, ever.
course.startedA learner starts a course: the first lesson they open in it, or the first lab they run or quiz question they answer on its course page. Once per learner and course, ever. Learners who started a course before this event existed don't get one for it.
lesson.completedA learner completes a lesson (button, the lesson's labs and quizzes done, or its exam passed).
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.
path.completedThat finish completed a learning path: the learner has now finished every published, listed course on it. Once per learner and path — unless the path gains a course, which completes it again when the learner finishes that one too.
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.
exam.gradedAn exam attempt is graded, passed or failed: when the learner submits, when their time limit runs out, or — for a learner who closed the tab — at the hourly sweep. Once per attempt.
exam.closedA learner can't start an exam any more without your team: they used their last attempt and failed it (out_of_attempts), or their days to pass ended without a pass (out_of_time). Sent again if it closes again after Give another attempt or Give more time.
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, within the hour 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).
learner.deletedAn admin deleted a learner (from their page under Admin → Learners, or through the API below).
webhook.testYou pressed Send a test event. It carries only id, event, occurred_at and academy.name.

Every learning event — learner, lesson, course, path, account, exam, 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 first sign-in, the start, the completion, the grading, the issue, the merge. Never when it was sent, so an event retried a day later still carries the original time. Events can arrive out of order (a retry, or two events of the same moment), so order them by occurred_at, not by arrival.
  • 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, a report the lesson version it was made on, and exam.graded the exam version the attempt drew its questions from. null for content with no publish record. (learner.joined, path.completed, learner.merged and learner.deleted carry no release.)

Lesson, course and exam events carry course.slug/course.title (path.completed and a learning-path certificate carry path instead); lesson events add lesson, and — when the lesson has quiz questions (an exam's never count) — 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"
  }
}

learner.joined adds source: how they first arrived, as the sign-in form recorded it — the same facts as learners.csv, by the same names, except the CSV's source column, which is channel here. channel is direct, certificate (they came from a shared certificate), campaign:<utm_source> or referrer:<host>; null when nothing was recorded (someone who signed in from your product).

JSON
{
  "id": "7a1d3c9e-2b4f-4e8a-9c6d-0f5e1b2a3c47",
  "event": "learner.joined",
  "occurred_at": "2026-11-07T09:14:02.318Z",
  "learner": { "id": "2d0299ad-aab6-4b6c-89f8-b55c2e29594c", "email": "ada@datacraft.io", "user_id": null, "account": null },
  "source": {
    "channel": "campaign:newsletter",
    "referrer": null,
    "utm_source": "newsletter",
    "utm_medium": "email",
    "utm_campaign": "november",
    "landing_page": "/courses/sql-fundamentals-duckdb",
    "signup_page": "/courses/sql-fundamentals-duckdb"
  }
}

course.started carries course, and lesson: the lesson they started with. Store it once per learner.id and course.slug — the pair is your "registered" record, the moment to start an onboarding sequence or a CRM task. path.completed carries path.slug/path.title.

exam.graded carries course, lesson (the exam) and attempt: number (1 for their first), allowed (attempts they may have, extra ones your team gave included; null for unlimited), started_at and submitted_at, timed_out (the time limit ran out before they submitted), correct of total, percent (rounded down, so it's at least pass_percent exactly when it passed), and sections, the score per ## section of the exam (one untitled section when it has none). The questions and answers are never sent.

JSON
{
  "id": "5c8e2a7b-9d1f-4b3e-8a6c-4e2d0f9b1a76",
  "event": "exam.graded",
  "occurred_at": "2026-11-07T10:41:19.004Z",
  "learner": { "id": "2d0299ad-aab6-4b6c-89f8-b55c2e29594c", "email": "ada@datacraft.io", "user_id": null, "account": null },
  "course": { "slug": "sql-fundamentals-duckdb", "title": "SQL Fundamentals with DuckDB" },
  "lesson": { "slug": "exam", "title": "Final exam" },
  "release": { "published_at": "2026-11-02T08:00:00.000Z", "revision_id": "b1f0c2d3-4e5a-4b6c-9d7e-8f0a1b2c3d4e" },
  "attempt": {
    "id": "e3c1a9f2-6b7d-4c8e-9a0b-1d2e3f4a5b6c",
    "number": 2,
    "allowed": 3,
    "started_at": "2026-11-07T10:02:11.551Z",
    "submitted_at": "2026-11-07T10:41:19.004Z",
    "timed_out": false,
    "correct": 7,
    "total": 10,
    "percent": 70,
    "pass_percent": 80,
    "passed": false,
    "sections": [
      { "title": "Joins", "correct": 4, "total": 5 },
      { "title": "Window functions", "correct": 3, "total": 5 }
    ]
  }
}

exam.closed carries course, lesson (the exam) and closed: reason (out_of_attempts or out_of_time), attempts_taken, attempts_allowed (null for unlimited) and ends_at, when the learner's days to pass end (null for an exam without them). Out of attempts is sent with the exam.graded of the attempt that used the last one; out of time when their days end — at the hourly sweep, or when an attempt still running then is graded. Your team brings the learner back from Admin → Exams or the learner's page.

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"] }
}

learner.deleted tells you to erase: learner is the learner as they were just before the deletion, and deleted.emails is every address they confirmed, so you can erase what you keyed by their id or by any of them (a former address that is now someone else's sign-in address is left out: what's under it is that person's). The deletion also removes the delivery log's earlier events about them, and retries still queued are never sent:

JSON
{
  "id": "1e6b9c8c-87ab-4a44-8891-c1eb361ce975",
  "event": "learner.deleted",
  "occurred_at": "2026-10-27T16:52:59.535Z",
  "learner": { "id": "2d0299ad-aab6-4b6c-89f8-b55c2e29594c", "email": "ada@datacraft.io", "user_id": "u_1042", "account": null },
  "deleted": { "emails": ["ada@datacraft.io", "ada.home@example.dev"] }
}

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. Events the hourly exam sweep causes — an attempt graded for a learner who closed the tab, and days to pass that ran out — are sent with the next retry run instead, a few minutes later. A delivery counts when your endpoint answers with a 2xx status within 10 seconds. Otherwise it is retried after 1 hour, 3 hours, 6 hours, 12 hours and 24 hours — 6 attempts over about two days — and then marked failed. Retries go out once an hour, so each can come up to an hour later. 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 → Integrations → 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 → Settings → Integrations → API token (owners and admins; Admin → Impact → Manage data links there), 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.
  • event_id (up to 200 characters) is optional: your id for the event — a ticket id, an analytics event id. An id already received for the same metric is skipped, so a retried request or a history import that overlaps what you already sent never counts twice. A repeat changes one thing: an event received without occurred_at takes the repeat's date, so sending undated events again with their dates fixes them. Without it, every event you send is stored.
  • Responses: {"ingested": N, "undated": M, "duplicates": D, "dated": F} on success (ingested counts the events stored, undated those of them without occurred_at, duplicates the ones skipped for an event_id already received, dated the repeats that gave a date to an event received without one), 401 for a missing or unknown token, 400 for invalid JSON, 422 with the field errors for a malformed event (nothing in the batch is stored), 429 past 300 requests a minute (counted together with the accounts API), with a Retry-After header in seconds, and 503 with a Retry-After if the events couldn't be stored: send the batch again. Send events in batches rather than one request each.

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 takes a CSV import for a monthly export from your warehouse or helpdesk: drop the file, choose it, or paste the lines, up to 10,000 at a time. The first line names the columns, read by name in any order — email, user_id, metric, value, occurred_at, event_id (Event ID reads as event_id; other columns are ignored, and a quoted cell may hold commas); Download the template has them all. (Lines without a header still read as email,metric[,value[,occurred_at]].) Check says what an import would do before anything is stored: how many events, any metric name the academy has never received (a typo is a new metric), how many lines are undated, which are repeats or would date an undated event, and every line it can't read, with its line number. Import then stores them, and says the same of what it did, so a bigger export can go in parts. Regenerating the token invalidates the old one immediately.

Zendesk tickets

Impact can answer "do people who finished a course file fewer support tickets than coworkers who hadn't?" once your tickets arrive as a metric. Zendesk sends each new ticket itself, through a webhook and a trigger you create in its Admin Center: no app to install, on any Zendesk Support plan. Admin → Impact → Manage data → Zendesk tickets holds the values to copy.

  1. In Zendesk's Admin Center, open Apps and integrations → Webhooks → Webhooks, press Create webhook, pick Trigger or Automation, then Next.

  2. Name it (say, Mastria), and set:

    • Endpoint URL: https://mastria.dev/api/ingest
    • Request method: POST, Request format: JSON
    • Authentication: Bearer token, with your API token (see Metric ingest).

    Press Create webhook.

  3. Open Objects and rules → Business rules → Triggers and create a trigger (say, Send new tickets to Mastria). Under Conditions, Meet ALL, add Ticket is Created. Under Actions, add Notify webhook, choose the webhook, and paste this body:

    JSON
    {
      "event_id": "{{ticket.id}}",
      "email": "{{ticket.requester.email}}",
      "metric": "support_ticket",
      "occurred_at": "{{ticket.created_at_with_timestamp}}"
    }
    

    Save it, then return to the webhook and press Finish setup.

  4. Create a ticket. Within seconds, Manage data's Zendesk card shows it as the Last ticket, and Recent events lists it as a support ticket.

  5. On Impact, set up the support_ticket metric: Less is better, and Average total per person (tickets per person, rather than the share who filed any).

The ticket's id is its event_id, so a webhook Zendesk delivers twice (it says it may) counts once, and one Mastria couldn't store for a moment (a 503) is sent again by Zendesk. A ticket whose requester has no email address (a phone call, an anonymous chat), or one Mastria doesn't take, is refused with a 422, which Zendesk's webhook activity log shows; there is nobody to match it to.

Send your history too. Impact compares the 30 days before and after each finish, so the tickets from before you connected are what make the first answers complete. This script reads the last 180 days through Zendesk's incremental export and sends them the same way. Tickets the trigger already sent are skipped by their id, so it is safe to run after connecting, or twice. It needs curl, jq, and an OAuth client to read from Zendesk (Zendesk no longer creates API tokens, and the ones left stop working on April 30, 2027):

  1. In Zendesk's Admin Center, open Apps and integrations → APIs → OAuth clients and press Add OAuth client.
  2. Name it (say, Mastria history), set Client kind to Confidential, and press Save.
  3. Copy the Secret that appears (Zendesk shows it in full only once), press Save again, and put it and the Identifier in the script's first lines. The script asks Zendesk for a read-only token; once your history is in, you can delete the client.
Shell
ZENDESK="yourcompany.zendesk.com"
ZENDESK_CLIENT_ID="mastria_history"
ZENDESK_CLIENT_SECRET="YOUR_OAUTH_CLIENT_SECRET"
MASTRIA_TOKEN="YOUR_MASTRIA_API_TOKEN"

# Prints the response body and succeeds on a 2xx; waits out a 429 or 503.
send() {
  while :; do
    OUT=$(curl -s -w '\n%{http_code}' "$@")
    CODE=${OUT##*$'\n'}
    case $CODE in
      429|503) echo "Busy ($CODE), waiting a minute" >&2; sleep 60 ;;
      2??) echo "${OUT%$'\n'*}"; return 0 ;;
      *) echo "$CODE ${OUT%$'\n'*}"; return 1 ;;
    esac
  done
}

# A read-only Zendesk token for two days, the longest Zendesk gives.
ZENDESK_TOKEN=""
if GRANT=$(send -X POST "https://$ZENDESK/oauth/tokens" -d grant_type=client_credentials \
    --data-urlencode "client_id=$ZENDESK_CLIENT_ID" --data-urlencode "client_secret=$ZENDESK_CLIENT_SECRET" \
    -d scope=read -d expires_in=172800); then
  ZENDESK_TOKEN=$(echo "$GRANT" | jq -r .access_token)
else
  echo "Zendesk refused the OAuth client ($GRANT). Check the subdomain, identifier and secret."
fi

# Tickets from the last 180 days, up to 1,000 a page (Zendesk allows 10
# pages a minute). A stopped run prints the RESUME_URL to set.
URL="${RESUME_URL:-https://$ZENDESK/api/v2/incremental/tickets/cursor.json?include=users&start_time=$(( $(date +%s) - 180*86400 ))}"
EVENTS_FILE=$(mktemp)
while [ -n "$ZENDESK_TOKEN" ]; do
  PAGE=$(send -H "Authorization: Bearer $ZENDESK_TOKEN" "$URL") \
    || { echo "Zendesk refused ($PAGE). To pick up here, set RESUME_URL='$URL' and run it again."; break; }
  # Deleted tickets and requesters without an address Mastria takes are
  # left out, so one odd address never costs a page.
  echo "$PAGE" | jq '
    def mastria_email: length <= 254 and test("^(?!\\.)(?!.*\\.\\.)[a-z0-9_\u0027+.-]*[a-z0-9_+-]@([a-z0-9][a-z0-9-]*\\.)+[a-z]{2,}$");
    (.users | map({(.id | tostring): .email}) | add // {}) as $email
    | [.tickets[] | select(.status != "deleted")
       | {event_id: (.id | tostring), email: ($email[.requester_id | tostring] // "" | ascii_downcase),
          metric: "support_ticket", occurred_at: .created_at}
       | select(.email | mastria_email)]' > "$EVENTS_FILE"
  if [ "$(jq length "$EVENTS_FILE")" -gt 0 ]; then
    send -X POST https://mastria.dev/api/ingest -H "Authorization: Bearer $MASTRIA_TOKEN" \
      -H "Content-Type: application/json" --data-binary @"$EVENTS_FILE" \
      || { echo "Stopped. To pick up here, set RESUME_URL='$URL' and run it again."; break; }
  fi
  [ "$(echo "$PAGE" | jq -r .end_of_stream)" = "true" ] && break
  URL=$(echo "$PAGE" | jq -r .after_url)
  sleep 6
done
rm -f "$EVENTS_FILE"

Each page answers with its counts ({"ingested": 412, "undated": 0, "duplicates": 3, "dated": 0}). When Zendesk or Mastria is busy, the script waits a minute and tries again; anything else stops it, with the RESUME_URL that picks up at the same page. No engineer to hand? A CSV with event_id (the ticket id), email, metric (support_ticket on every line) and occurred_at (ISO 8601) columns imports on Manage data.

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 → Settings → Integrations → API token).

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"] }

Requests count against the same 300 a minute as metric ingest; past it, the answer is 429 with a Retry-After header in seconds.

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 ;.

Deleting learners

When someone asks your company to erase their data, your privacy tooling can delete them from the academy without opening the admin. It uses the same token as the metric ingest, and does what Delete learner on a learner's page does (see Deleting a learner).

Shell
curl -X POST https://mastria.dev/api/learners/delete \
  -H "Authorization: Bearer $MASTRIA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@datacraft.io"}'

Send either email or learner_id (the learner.id of a webhook event), one person per request:

  • By email, Mastria finds the learner behind any address they confirmed: their sign-in address, a recovery or a former one. With no learner behind it, the call still erases the product metrics you sent for that address, for someone who never signed in to the academy.
  • The answer is {"deleted": true, "learner_id": "2d0299ad-…"} when a learner was deleted, and {"deleted": false, "learner_id": null} when there was none. Calling it again for the same person answers false, so retrying is safe.
  • A deleted learner sends learner.deleted to your webhook, as above.
  • Metric events you send for them afterwards are stored again: stop sending them, and leave them out of a history import (a Zendesk history run sends their old tickets again).

A body with neither field, or both, gets a 422. Requests count against the same 300 a minute as the other API calls.

Sign-in from your product

The same token asks for single-use links that send people from your app into a course, already signed in: see Sign-in from your product.

CSV exports

Every table walks out the door as plain CSV, staff-only, from the admin. Each file holds every row, however many; a large one starts downloading at once and streams to the end. 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.
purchases.csvAdmin → Purchases, or Admin → Settings → Exports (once Stripe is connected)Every certificate purchase: email, name, course_slug, certificate, status (paid; refunded — in full, from your Stripe Dashboard or automatically for a duplicate payment; disputed — a dispute you lost), amount (what the learner paid in your currency, in major units: 49.00) and currency, purchased_at, refunded_at, certificate_issued_at (empty until the buyer has earned it), stripe_payment_intent and stripe_checkout_session (to match your Stripe payouts), stripe_account, and the learner's account_id and account. A deleted learner's purchases stay, with email, name and the account empty. A name starting with =, +, - or @ gets a leading '. See Selling certificates.
metric-events.csvAdmin → Settings → ExportsEvery ingested metric event: 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), counts_as (the metric Impact counts the event under: the one its name was merged into, else its own name), and event_id (your id for the event, empty when none was sent).
reach.csvAdmin → Settings → Exports, or Admin → ReachEvery daily count: 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 → Settings → Exports, or Admin → ReachThe search box's queries per day: searches, how many found nothing, how many opened a result.
lab-events.csvAdmin → Settings → Exports, or Admin → ReachEach signed-in learner's Run presses and hint reveals: 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 → Settings → Exports, or Admin → QuizzesEvery quiz answer, reveal and flag: 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, 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: 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.

See Content export for your courses themselves.