# Axion Dojo Website API

Version 1. The readable version of this page is at
https://www.axionsoftware.dev/dojo-api.html

This API lets a martial arts school's own website read the school's public
information (studio details, programs, classes, a dated schedule, events, trial
offers, instructors) and send visitors in as leads or as full prospects with
their students. Everything comes back as JSON.

It is for whoever builds or runs the school's website. The school owner creates
the key in Axion Dojo and gives it to you together with the address below.

## Base address

```
https://YOUR-DOJO-ADDRESS/pub/dm/v1
```

Each school has its own address. The owner copies it from
**Settings → Website API → Address** (there is a Copy button beside it).

## Authentication

Send the API key on every request:

```
Authorization: Bearer YOUR_KEY
```

If your tool cannot set an `Authorization` header, send `X-API-Key: YOUR_KEY`
instead. Both work.

There are two kinds of key:

- **Publishable** (`dm_pk_...`): safe to put in a web page. It works only from
  the websites the school lists against it, and it can never read student,
  attendance or payment records.
- **Secret** (`dm_sk_...`): for code running on a server. Never put it in a
  web page.

A browser request from a website the key does not list is refused. Ask the
school to add your domain, including `https://` (and your staging domain, if you
have one). Server-to-server calls send no `Origin` header, so the key alone
authorizes them.

### Check your key

```
curl -H "Authorization: Bearer YOUR_KEY" https://YOUR-DOJO-ADDRESS/pub/dm/v1/ping
```

A working key returns the school's name and the key's permissions. A bad key
returns `401`.

### When the key changes

The school can **rotate** a key in Settings. That issues a new key with the same
websites and permissions, and the old key keeps working for a period the school
chooses (from immediately up to seven days) so you can swap it in without any
downtime. After that the old key returns `401 key_expired`. Keep the key in one
place in your code so a swap is a one-line change.

## Response shape

Every response is JSON in the same envelope:

```json
{ "ok": true,  "data": { },  "meta": { } }
{ "ok": false, "error": { "code": "invalid_key", "message": "..." }, "meta": { } }
```

`meta` describes the call itself, on success and on failure:

```json
"meta": {
  "request_id":  "req_4f9c2a1be07d3a55",
  "method":      "GET",
  "endpoint":    "/pub/dm/v1/schedule",
  "query":       { "start": "2026-10-01", "end": "2026-10-14" },
  "received_at": "2026-09-24T18:02:11Z",
  "api_version": "v1"
}
```

`request_id` is also sent in the `X-Request-Id` header. Quote it when something
looks wrong, and the school can find that exact call under **Recent activity**
in their settings.

Read responses are cached for up to five minutes, so a change made in Axion Dojo
shows on the website within a few minutes, not instantly. A cached answer
repeats the `request_id` of the call that filled the cache.

## Endpoints

| Method | Path | What it does |
|---|---|---|
| GET | `/ping` | Checks the key; returns the school name and the key's permissions |
| GET | `/studio` | Name, phone, email, address, map link, logo, social links, time zone |
| GET | `/programs` | The styles taught |
| GET | `/classes` | The weekly class timetable |
| GET | `/schedule` | Classes and events on real calendar dates |
| GET | `/events` | Events the school has chosen to show on its website |
| GET | `/trials` | Trial and intro offers |
| GET | `/instructors` | Instructor names, titles, ranks, bios and programs |
| POST | `/leads` | A contact form: creates a lead |
| POST | `/trial-requests` | "Book a free class": a lead that records what they picked |
| POST | `/prospects` | A full prospect: the account plus each student, as records |

The read endpoints need the key's read permission. The three POST endpoints
need the key to allow submitting leads; the school sets that when it creates the key.

### GET /studio

Everything a header, footer or contact page needs: `name`, `phone`, `email`,
`timezone`, `address` (`line1`, `line2`, `city`, `state`, `postal_code`,
`one_line`, `maps_url`), `logo_url` and `socials` (`platform`, `url`).

### GET /programs

`programs`: each with `id`, `name`, `short_name`, `description`.

### GET /classes

The recurring weekly timetable. `classes`: each with `id`, `name`, `program`,
`days` (such as `["MON","WED"]`), `start_time` and `end_time` (`"18:30"`),
`start_label` and `end_label` (`"6:30 PM"`), `duration_minutes`, `age_min`,
`age_max`, `age_label`, `starts_on`, `ends_on`. Also returns `timezone`.

### GET /schedule

The classes placed on real dates, with events merged in, so you can draw an
actual calendar.

| Query | Meaning |
|---|---|
| `start` | First date, `YYYY-MM-DD`. Defaults to today. |
| `end` | Last date, `YYYY-MM-DD`. Defaults to 30 days after `start`. At most 180 days after it. |

Returns `timezone`, `start`, `end` and `days`: each day has `date`, `weekday`
and `entries`, sorted by start time. Each entry has `kind` (`"class"` or
`"event"`) and the fields from `/classes` or `/events`.

### GET /events

Takes the same `start` and `end` as `/schedule`. `events`: each with `id`,
`name`, `description`, `location`, `category`, `date`, `start`, `end` (in the
school's own time zone), `start_time`, `start_label`, `cost`, `capacity`.
Only events the school has marked for its website appear.

### GET /trials

`trials`: each with `id`, `name`, `description`, `price`, `duration_value`,
`duration_type` (`"classes"` or `"days"`), `duration_label`.

### GET /instructors

`instructors`: each with `id`, `name`, `title`, `rank`, `bio`, `programs`. No
photos or personal contact details.

### Times and dates

Class times are plain weekly times such as `"18:30"`, with a ready-made label
such as `"6:30 PM"`. Event times are already converted to the school's own time
zone, which every payload with a time also returns as `timezone`. You do not
need to convert anything.

## Sending people in

All three POST endpoints accept JSON or an ordinary form post. They land on the
school's dashboard as a new lead straight away, and the school can choose to get
an email or text when one arrives.

### POST /leads

The simplest option: a contact form.

```json
{
  "first_name": "Sam",
  "last_name":  "Example",
  "email":      "sam@example.com",
  "phone":      "903-555-0142",
  "message":    "Looking for kids classes on weekends.",
  "interest":   "Kids Karate",
  "source":     "Contact page"
}
```

`first_name` and `last_name` are required, plus at least one of `email` or
`phone`. If the email already belongs to someone the school knows, no duplicate
is made: the enquiry is added to that person's history and the response says
`"existing": true`.

Returns `lead_id`, `existing` and `received`.

Add `?with_signup_link=1` to also get a `signup_url`, valid for seven days, that
sends the visitor into the school's own full signup (students, waivers and card
details). Do not collect card details on your own site.

### POST /trial-requests

The same fields as `/leads`, plus any of `trial_id`, `class_id`,
`preferred_date` and `student_name`. It makes the same kind of lead and records
exactly what the visitor picked, so the school can call back already knowing.

### POST /prospects

Brings a family in as real records: the account with its address and emergency
contact, and each student marked as a prospect. These are the same records the
school's own sign-up link creates.

```json
{
  "first_name": "Sam",
  "last_name":  "Example",
  "email":      "sam@example.com",
  "phone":      "903-555-0142",
  "address":    "12 Main St",
  "city":       "Springfield",
  "state":      "TX",
  "zip_code":   "75001",
  "emergency_name":  "Pat Example",
  "emergency_phone": "903-555-0199",
  "message":    "Two kids, evenings work best.",
  "interest":   "Kids Karate",
  "source":     "Enroll page",
  "students": [
    { "first_name": "Riley", "dob": "2017-05-14", "gender": "F" },
    { "first_name": "Jordan", "last_name": "Example", "dob": "2014-11-02",
      "medical_notes": "Mild asthma, has an inhaler" }
  ]
}
```

| Field | Required | Notes |
|---|---|---|
| `first_name`, `last_name` | Yes | The account holder (usually the parent) |
| `email` | Yes | Must be a valid address |
| `phone` | Yes | A 10-digit U.S. number, or an international number starting with `+` |
| `address`, `city`, `state`, `zip_code` | No | |
| `emergency_name`, `emergency_phone` | No | Saved as the account's emergency contact |
| `message` | No | Saved on the account as what they are looking for |
| `interest`, `source` | No | Noted on the account's history |
| `students` | No | Up to 10. Each needs `first_name`; `last_name` defaults to the account's; `dob` is `YYYY-MM-DD`; `gender` and `medical_notes` are optional |

A plain HTML form cannot post a list, so a form post may send one student as
`student_first_name`, `student_last_name`, `student_dob`, `student_gender` and
`student_medical_notes`.

Returns `201`:

```json
{
  "ok": true,
  "data": {
    "received": true,
    "account_id": 81,
    "account_number": "ACC-00081",
    "students": [
      { "id": 501, "first_name": "Riley",  "last_name": "Example",
        "dob": "2017-05-14", "status": "prospect" },
      { "id": 502, "first_name": "Jordan", "last_name": "Example",
        "dob": "2014-11-02", "status": "prospect" }
    ]
  },
  "meta": { }
}
```

`/prospects` always makes a new account. If the email or phone matches a family
the school already has, the school is warned to check for a duplicate. Nothing
is merged automatically. A refused field comes back with `error.field` naming
it, so your form can point at it.

A website cannot create a parent-portal login or store a card through this API.
To take payment, send the visitor to a `signup_url` from `/leads`.

### Spam protection (please include this)

Add a text input named `website`, hidden with CSS and left empty. People never
see it; spam bots fill in every field they find. A submission with it filled in
gets a normal-looking answer and is thrown away.

```html
<input type="text" name="website" tabindex="-1" autocomplete="off"
       style="position:absolute;left:-9999px;" aria-hidden="true">
```

## A working example

```html
<form id="enquiry">
  <input name="first_name" placeholder="First name" required>
  <input name="last_name"  placeholder="Last name" required>
  <input name="email" type="email" placeholder="Email" required>
  <input name="phone" placeholder="Phone">
  <textarea name="message" placeholder="How can we help?"></textarea>
  <input type="text" name="website" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px;" aria-hidden="true">
  <button>Send</button>
</form>
<script>
document.getElementById('enquiry').addEventListener('submit', async (e) => {
  e.preventDefault();
  const res = await fetch('https://YOUR-DOJO-ADDRESS/pub/dm/v1/leads', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer YOUR_PUBLISHABLE_KEY',
               'Content-Type': 'application/json' },
    body: JSON.stringify(Object.fromEntries(new FormData(e.target)))
  });
  const out = await res.json();
  alert(out.ok ? 'Thanks! We will be in touch.' : out.error.message);
});
</script>
```

## Limits

| What | Limit |
|---|---|
| Reads, per key | 600 requests every 5 minutes |
| Submissions, per key | 120 an hour |
| Submissions, per visitor | 5 an hour |

A limited request returns `429` with a `Retry-After` header in seconds. Successful
responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`.

## Errors

| Status and code | Meaning |
|---|---|
| `401 missing_key` | No key was sent. Check the `Authorization` header. |
| `401 invalid_key` | The key is wrong, or the school revoked it. |
| `401 key_expired` | The school rotated this key and the old one has stopped. Ask for the new key. |
| `403 insufficient_scope` | The key is not allowed to submit leads. |
| `403 origin_not_allowed` | Your website is not on the key's list. Ask the school to add it, with `https://`. |
| `400 bad_request` | A date is malformed, or the range is too long. |
| `400 missing_field` | A required field is empty. The message (and `error.field` on `/prospects`) names it. |
| `400 invalid_field` / `invalid_email` | A value is not valid, such as an email or a date of birth. |
| `400 too_many_students` | More than 10 students in one `/prospects` call. |
| `429 rate_limited` | Too many requests. Wait for `Retry-After`. |
| `500 server_error` / `503 server_error` | Something went wrong on our side. Try again. |

A CORS error in the browser usually means `origin_not_allowed`. The school can
confirm it under **Recent activity**, which lists every call and why any were
refused.

## What this API will not do

- Read student records, attendance or contact details
- Read or take payments, or show balances
- Sign anyone in, or reach the parent portal
- Change anything at the school other than adding leads and prospects

That is what makes a publishable key safe to put in a web page.
