Partner API · Integration guide
Build Sporty's courses into your flight-school platform
The Partner API connects your flight-school software to Sporty's online ground school. Use it to give your students access to Sporty's courses, keep your flight schools in sync, follow each student's progress, and get notified when they reach a milestone.
Give students a course
When a student buys a course through you, give them a Sporty's license in one call.
Keep schools in sync
Register the flight schools on your platform, then enable or disable them.
Show student progress
With the student's permission, read their profile, finished lessons and practice test results.
Get notified
Receive an HTTPS call when a student finishes a lesson or a course, or is ready for the FAA written exam.
How it fits together
Your server talks to Sporty's in two ways. For anything about your account (your courses, your schools, the licenses you sell) it uses a partner token. To read one student's data it uses a student token, which you get after that student signs in with Sporty's and approves your app. Sporty's calls you back with webhooks.
Before you start
Get your credentials
Sporty's sets up your partner account, chooses the courses you can offer, and sends you a client ID and client secret. Your client ID identifies you on every call: you only ever see your own schools, courses, licenses and webhooks.
If you plan to connect student accounts, also send us the redirect URL(s) where students land after they approve your app, for example https://app.example.com/sportys/callback. Sporty's matches them exactly, so include the scheme, host and path you'll really use. Students see your company name and logo when they approve your app.
Never put it in a mobile app, a browser, or a public repository. If it leaks, contact Sporty's and we'll issue a new one. The old secret stops working as soon as we do.
Base URL
Every path in this guide goes after the base URL. For example, the token endpoint is https://courses.sportys.com/altitude/oauth2/token.
Conventions
- HTTPS and JSON. Send request bodies as JSON with
Content-Type: application/json. The token endpoint is the one exception: it takes a form-encoded body. - Times are UTC in ISO 8601, for example
2026-08-03T15:57:02.668Z. - Courses are identified by a course ID called
webAppId, such asPRIVATEorINSTRUMENT. It's case-sensitive. - Your own IDs are strings you choose:
purchaseIdfor each sale andpartnerFlightSchoolIdfor each school. Keep them unique and stable. - Treat Sporty's IDs as opaque strings. Some look like UUIDs, but not all of them are.
- No pagination or rate limits today. Lists return every item. Be gentle anyway: cache the course catalog and reuse access tokens.
Quickstart
These four calls take you from credentials to a student with a course.
-
Get a partner access token
Exchange your client ID and secret for an access token.
Requestcurl -X POST "https://courses.sportys.com/altitude/oauth2/token" \ -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \ -d "grant_type=client_credentials"Response 200{ "access_token": "eyJraWQiOiJ...", "token_type": "Bearer", "expires_in": 3599 }Send the token on every other call as
Authorization: Bearer <access_token>. -
List the courses you can offer
Requestcurl "https://courses.sportys.com/altitude/oauth2/api/partner/courses" \ -H "Authorization: Bearer $TOKEN"Each item's
webAppIdis the course ID you'll use in the next steps. -
Register a flight school
Requestcurl -X POST "https://courses.sportys.com/altitude/oauth2/api/partner/school" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "partnerFlightSchoolId": "fs-10482", "companyName": "Blue Sky Aviation", "isTestAccount": false, "enabledByUserEmail": "ops@blueskyaviation.example", "event": "STATUS_ENABLED" }' -
Give a student a course
Requestcurl -X POST "https://courses.sportys.com/altitude/oauth2/api/partner/purchase" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "sportysId": 1234567, "purchases": [ { "webAppId": "PRIVATE", "purchaseId": "order-58213-1", "partnerFlightSchoolId": "fs-10482" } ] }'A
201 Createdwith an empty body means it worked: the student has the course. Next, read Give a student access for the details, or set up webhooks.
Key concepts
| Term | What it means | Identified by |
|---|---|---|
| Partner | Your company. Everything you create belongs to your partner account. | Your OAuth client ID |
| Catalog | The courses Sporty's has enabled for your account. You can only sell and look up these. | A list of course IDs |
| School | A flight school that uses your platform. | partnerFlightSchoolId, an ID you choose |
| Student | A person with a Sporty's account. | sportysId, the Sporty's customer number |
| Course | A Sporty's course, such as Private Pilot. A course has volumes (sections), and each volume has elements: videos, interactive lessons, quizzes and documents. | webAppId, for example PRIVATE |
| License | A student's access to a course for a set term. You create one by reporting a purchase. | purchaseId, an ID you choose for each sale |
| Connected student | A student who signed in with Sporty's and approved your app. You can read their profile and progress, and you get webhooks about them. | A student access token |
Authentication
The API uses OAuth 2.0. Every call needs an access token in the Authorization header. Which token you need depends on whose data you're touching:
| Partner token | Student token | |
|---|---|---|
| Use it for | Your courses, schools, licenses and webhooks | One student's profile and course progress |
| Endpoints | /oauth2/api/partner/… | /oauth2/api/me/oauth2/api/courses/{webAppId}/progress |
| OAuth flow | Client credentials | Authorization code, with PKCE |
| Who approves | Nobody: your server asks directly | The student, once, by signing in to Sporty's |
Both flows use the same token endpoint, https://courses.sportys.com/altitude/oauth2/token. OpenID Connect discovery is at https://courses.sportys.com/altitude/.well-known/openid-configuration if your OAuth library can use it.
Get a partner token
Authenticate with HTTP Basic auth (client ID as the username, client secret as the password) and send grant_type=client_credentials as a form field, as in the quickstart. If Sporty's set your account up for client_secret_post, send client_id and client_secret as form fields instead.
- Reuse the token until it expires.
expires_inis the lifetime in seconds, which Sporty's sets for your account. Cache the token and get a new one shortly before it runs out. - On a
401, get a new token and retry once. A token can stop working beforeexpires_inis up, for example while Sporty's deploys an update. - Partner tokens don't come with a refresh token. Just request a new one.
Connect a student
To read a student's profile or progress, or to get webhooks about them, the student has to approve your app. This is the standard OAuth 2.0 authorization code flow, so most OAuth client libraries handle it for you.
-
Send the student to Sporty's
Create a random
stateand a PKCEcode_verifier, then redirect the student's browser to:Authorization URLhttps://courses.sportys.com/altitude/oauth2/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https%3A%2F%2Fapp.example.com%2Fsportys%2Fcallback &scope=openid%20profile &state=9f2c4e81a07b4d3c8e5f6a1b2c3d4e5f &code_challenge=BASE64URL_SHA256_OF_VERIFIER &code_challenge_method=S256Ask for
openid profile.profilelets you read/oauth2/api/me, and the approval itself is what turns on webhooks for this student.Use letters and digits instateUse a value like 32 random hex characters. Values that contain
-or_, such as UUIDs or base64url strings, make the sign-in fail before the student is sent back to you. -
The student signs in and approves
The student logs in with their Sporty's account and approves your app; they only see this screen the first time. Sporty's then redirects to your
redirect_uriwith?code=…&state=…. Check thatstatematches what you sent.If the student cancels, Sporty's doesn't redirect back to you at all, so don't wait on a callback forever.
-
Exchange the code for tokens
Do this right away: codes expire after a few minutes.
Requestcurl -X POST "https://courses.sportys.com/altitude/oauth2/token" \ -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \ -d "grant_type=authorization_code" \ -d "code=CODE_FROM_CALLBACK" \ -d "redirect_uri=https://app.example.com/sportys/callback" \ -d "code_verifier=YOUR_CODE_VERIFIER"The response has an
access_token, arefresh_tokenand anid_token. Store the refresh token securely against the student's record in your system. -
Call the student endpoints, and refresh when needed
Use the access token on
/oauth2/api/meand/oauth2/api/courses/{webAppId}/progress. When it expires, get a new one without involving the student:Requestcurl -X POST "https://courses.sportys.com/altitude/oauth2/token" \ -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \ -d "grant_type=refresh_token" \ -d "refresh_token=STORED_REFRESH_TOKEN"The refresh token stays the same each time you use it, until it expires. If a refresh fails with
invalid_grant, the refresh token expired or the student disconnected your app: ask them to connect again.
Optional: add the student to one of your schools
Instead of a random state, you can send a JSON object encoded in standard Base64 (not base64url) with your partnerFlightSchoolId as schoolId. Put a random value in it too, so state still protects the sign-in. When the student approves, Sporty's adds them to that school, as long as you've registered the school and Sporty's has linked it to a school on Sporty's.
{ "schoolId": "fs-10482", "nonce": "9f2c4e81a07b4d3c8e5f6a1b2c3d4e5f" }
URL-encode the Base64 string when you put it in the authorization URL, because it can contain +, / and =.
Show your course catalog
Use these two calls to list the courses you can offer and to show what's inside each one, for example to build a course picker or to link a student straight to a lesson.
List your courses
[
{
"webAppId": "PRIVATE",
"shortName": "Private Pilot",
"totalElements": 170,
"totalRequiredElements": 133,
"courseLastUpdateDateTime": "2026-07-16T19:30:10.959Z",
"elementsLastUpdateDateTime": "2026-06-16T17:46:05.123Z",
"metadata": {
"url": "https://courses.sportys.com/training/portal/course/PRIVATE"
}
},
{
"webAppId": "INSTRUMENT",
"shortName": "Instrument Rating",
"totalElements": 105,
"totalRequiredElements": 87,
"courseLastUpdateDateTime": "2026-07-16T19:30:10.888Z",
"elementsLastUpdateDateTime": "2026-04-22T01:46:08.649Z",
"metadata": {
"url": "https://courses.sportys.com/training/portal/course/INSTRUMENT"
}
}
]
| Field | Type | Description |
|---|---|---|
webAppId | string | The course ID. Use it everywhere a course is needed. |
shortName | string | Display name, for example "Private Pilot". |
totalElements | integer | Number of lessons (elements) in the course. |
totalRequiredElements | integer | How many of those count toward completion. Optional lessons don't. |
courseLastUpdateDateTime | date-time | When the course details last changed. |
elementsLastUpdateDateTime | date-time | When the course's lessons last changed. Re-fetch the course details if this is newer than your copy. |
metadata.url | string | Link to the course on Sporty's. |
The list isn't in any particular order. Sort it however suits your UI.
Get one course with its lessons
Returns the same summary plus volumes, each with its elements. It covers the course's video training volumes.
{
"webAppId": "PRIVATE",
"shortName": "Private Pilot",
"totalElements": 170,
"totalRequiredElements": 133,
"courseLastUpdateDateTime": "2026-07-16T19:30:10.959Z",
"elementsLastUpdateDateTime": "2026-06-16T17:46:05.123Z",
"metadata": { "url": "https://courses.sportys.com/training/portal/course/PRIVATE" },
"volumes": [
{
"uuid": "1a3ff1fa-1d1f-4528-9a71-5e1ebee8d88f",
"description": "Your First Few Hours",
"sectionId": "1",
"order": 3,
"totalElements": 26,
"totalRequiredElements": 26,
"elements": [
{
"uuid": "8af588a3-6bd4-4525-9fb9-ddb248d6eed8",
"type": "Video",
"name": "Getting Started with Sporty's Learn to Fly Course",
"required": true,
"order": 0,
"metadata": {
"runtime": "00:05:27",
"runtimeSeconds": 327,
"url": "https://courses.sportys.com/training/portal/course/PRIVATE/volumes/VIDEO_TRAINING/1a3ff1fa-1d1f-4528-9a71-5e1ebee8d88f/8af588a3-6bd4-4525-9fb9-ddb248d6eed8"
}
}
]
}
]
}
- Volumes are in course order (by
sectionId). Don't use the volume'sorderfield; it doesn't reflect the course. - Elements are in order within their volume, and their
orderfield matches. typeisVideo,InteractiveLesson,QuizorDocument.metadata.urlopens the lesson on Sporty's.runtimeandruntimeSecondsare only set for videos; they'renullfor other types.uuidis the element's ID. It's a UUID for videos and interactive lessons, but not for quizzes and documents, so store it as a plain string.
If the course doesn't exist you get 404. If it exists but isn't in your catalog you get 401 with a message; see Errors.
Keep schools in sync
Register each flight school on your platform with Sporty's, using your own ID for it. You'll use that ID when you give its students a course.
Create or update a school
Posting a partnerFlightSchoolId you've already used updates that school, so use this call for both creating and updating. It returns 200 with an empty body.
| Field | Type | Description | |
|---|---|---|---|
partnerFlightSchoolId | string | Required | Your ID for the school. |
companyName | string | Required | The school's name. |
enabledByUserEmail | string | Required* | Email of the person at the school who turned the integration on. *Send this or userEmail. |
userEmail | string | Optional | Used as enabledByUserEmail when that's empty. |
isTestAccount | boolean | Optional | Marks a school you use for testing. Send it on every update: leaving it out sets it to false. |
event | string | Optional | STATUS_ENABLED or STATUS_DISABLED. Leave it out to keep the current status. New schools start enabled. |
Disabling a school marks it inactive. It doesn't change any student's access.
List your schools
[
{
"id": 42974776,
"partnerFlightSchoolId": "fs-10482",
"schoolId": 42908793,
"companyName": "Blue Sky Aviation",
"isTestAccount": false,
"enabledByUserEmail": "ops@blueskyaviation.example",
"createDate": "2026-01-21T18:54:24.058Z",
"schoolCode": "BLUE_SKY",
"partnerSchoolStatus": "ENABLED"
},
{
"id": 43857884,
"partnerFlightSchoolId": "fs-20931",
"schoolId": null,
"companyName": "UNKNOWN",
"isTestAccount": false,
"enabledByUserEmail": "UNKNOWN",
"createDate": "2026-08-03T15:53:13.019Z",
"schoolCode": null,
"partnerSchoolStatus": "ENABLED"
}
]
schoolIdandschoolCodeare filled in once Sporty's links your school to a school on Sporty's. Until then they'renull.- Schools named
UNKNOWNare placeholders. Sporty's creates one when you give a student a course with apartnerFlightSchoolIdyou haven't registered. Post the school to fill in its details, and register schools before you sell through them.
Give a student access to a course
When a student buys a Sporty's course through you, call this to give them a license. One call can grant several courses.
Identify the student
The student needs a Sporty's account. Identify them in one of two ways:
| Send | When |
|---|---|
sportysId | Preferred. You get it from /oauth2/api/me once the student has connected their account. If you send it, email is ignored. |
email, firstName, lastName | When the student hasn't connected yet. The email must match the one on their Sporty's account exactly. Include the name too: Sporty's needs it the first time a student uses its courses. |
Request
| Field | Type | Description | |
|---|---|---|---|
sportysId | integer | One of | The student's Sporty's customer number. |
email | string | One of | The email on the student's Sporty's account. |
firstName, lastName | string | With email | The student's name. |
purchases | array | Required | One entry per course. At least one. |
purchases[].webAppId | string | Required | The course to grant. It must be in your catalog. |
purchases[].purchaseId | string | Required | Your unique ID for this sale. Reuse it if you retry. |
purchases[].partnerFlightSchoolId | string | Recommended | The school the student bought through. Register the school first. |
purchases[].date | date-time | Optional | When the license starts. Defaults to now. A future date means the student can't use the course until then. |
{
"sportysId": 1234567,
"purchases": [
{
"webAppId": "PRIVATE",
"purchaseId": "order-58213-1",
"partnerFlightSchoolId": "fs-10482"
},
{
"webAppId": "INSTRUMENT",
"purchaseId": "order-58213-2",
"partnerFlightSchoolId": "fs-10482"
}
]
}
{
"email": "jordan.rivera@example.com",
"firstName": "Jordan",
"lastName": "Rivera",
"purchases": [
{
"webAppId": "PRIVATE",
"purchaseId": "order-58213-1",
"partnerFlightSchoolId": "fs-10482",
"date": "2026-11-12T15:00:00Z"
}
]
}
What happens
201 Created, empty body: the student has every course in the request. Each license runs for the course's standard term from its start date.- All or nothing. If any course in the request fails, none are granted. Fix the problem and send the whole request again.
- Already licensed: if the student already has, or had, a license for a course, that course is skipped and the call still succeeds. Renewing an expired license through the API isn't supported yet; contact Sporty's.
- Connected students also trigger a
COURSE_PURCHASEDwebhook.
Retries and duplicates
- Retrying is safe if you reuse the same
purchaseIds: the student keeps the license they already have. - Never reuse a
purchaseIdfor a different student. Sporty's rejects it with409 Conflict. - Send one school per request. If a student buys courses through two schools, make two calls.
| Status | Meaning |
|---|---|
201 | Done. Empty body. |
400 | The body is invalid, for example purchases is empty or email isn't an email address. |
401 with a message | A course isn't in your catalog. With an empty body, your token is missing or expired. |
404 | No student with that sportysId, or no course with that webAppId. |
409 | A purchaseId is already used for a different student. |
Report a purchase for a student who isn't connected
Use this when a student buys a Sporty's course through you but hasn't connected a Sporty's account. It records the sale with Sporty's. It doesn't create an account or give the student access; to do that, use /partner/purchase.
| Field | Type | Description | |
|---|---|---|---|
courseId | string | Required | The course that was bought (a webAppId in your catalog). |
purchaseId | string | Required | Your unique ID for this sale. |
companyId | string | Optional | Your ID for the school the student bought through. |
user | object | Optional | The student as they appear in your system: id, studentId, email, firstName, lastName. |
{
"courseId": "PRIVATE",
"purchaseId": "order-58214-1",
"companyId": "fs-10482",
"user": {
"id": "u-88412",
"studentId": "S-2026-0193",
"email": "sam.patel@example.com",
"firstName": "Sam",
"lastName": "Patel"
}
}
It returns 200 with an empty body. Send each sale once: Sporty's doesn't combine repeated reports. Errors are the same as for purchases: 404 for an unknown course, 401 for a course outside your catalog.
Show a student's progress
Once a student has connected their account, use their student token to read who they are and how far along they are. Neither call accepts a partner token.
Get the student's profile
{
"id": 50211873,
"sportysId": 1234567,
"email": "jordan.rivera@example.com",
"firstName": "Jordan",
"lastName": "Rivera",
"createdDate": "2026-03-02T14:11:09Z",
"address": {
"address": null,
"city": null,
"state": null,
"zip": null
},
"activeLicenses": ["PRIVATE", "SPORT"],
"inactiveLicenses": []
}
| Field | Description |
|---|---|
sportysId | The student's Sporty's customer number. Save it on your student record: it's how you give this student a course, and it's the userId in webhooks. |
activeLicenses | Course IDs the student can use now, however they got them. A course can appear more than once. |
inactiveLicenses | Course IDs whose license has ended. |
address | Postal address, if the student gave one. Any part can be null. |
Get progress in one course
{
"completedElements": [
"8af588a3-6bd4-4525-9fb9-ddb248d6eed8",
"9b31980f-e74c-42e4-b47c-590b95d458a3"
],
"tests": [
{
"id": 61540032,
"uuid": "5b0e7d0c-2c1f-4a51-9d0e-3f1c2b7a9e41",
"completedDate": "2026-08-03T15:57:02.668Z",
"questions": 60,
"correct": 55,
"grade": 92,
"pass": true
}
],
"milestones": [],
"courseRequirement": {
"passingGrade": 80,
"passingTests": 2
},
"certificates": [],
"completion": {
"percentComplete": 24,
"requiredPercentComplete": 31,
"videoPercentComplete": 24,
"requiredVideoPercentComplete": 31,
"displayedPercentComplete": 31,
"videoWatchedLength": { "hours": 4, "minutes": 12, "seconds": 37 },
"elements": {
"elements": 170,
"requiredElements": 133,
"completedElements": 41,
"completedRequiredElements": 41
}
}
}
How to use it:
- Progress bar: show
completion.displayedPercentComplete. It's the figure Sporty's shows the student, so the two stay consistent. Clamp it to 0–100 in your UI. - Video checklist:
completedElementslists theuuidof every video the student has finished. Match them against the course details to tick videos off. - Practice tests: each entry in
testsis one attempt.courseRequirementsays what the course asks for, for example two passed tests at 80% or better. - Milestones:
milestoneslistsCOURSE_COMPLETED,WRITTEN_EXAM_ELIGIBLEandWRITTEN_EXAM_REQUESTEDwith the date the student first reached each one. - Certificates: earned certificates (such as an exam endorsement or a course completion certificate) have
pdfUrlandjpgUrllinks. The links expire after 24 hours, so fetch progress again when the student wants one; don't store them. - Watch time:
videoWatchedLengthcounts each video once;totalVideoWatchedLengthincludes re-watches.
A student without a license for the course still gets 200, with the license-based figures at zero. An unknown webAppId returns 404.
Use webhooks to hear about finished lessons and courses, then fetch progress for that one student.
Webhooks
Webhooks tell your server when something happens to a student: a course was bought, a lesson or course was finished, or the student became ready for the FAA written exam. Sporty's sends an HTTPS POST with a small JSON body to the URL you register.
You get events for students who have connected their Sporty's account to your app, about courses in your catalog. Events from before a student connected aren't sent afterwards, and accounts registered as flight instructors (CFIs) don't generate events.
Set up
-
Choose how Sporty's proves it's Sporty's
Pick a long random secret and tell Sporty's how to send it. One setting covers all your webhooks. Sporty's doesn't send any events until it's set.
PUT/oauth2/api/partner/webhook-configPartner tokenRequest body{ "authenticationType": "BEARER_TOKEN", "tokenValue": "whsec_4f9a1c7e2b8d6a3f5e0c9b7d1a2e4f6c" }authenticationTypeHeader on each webhook BEARER_TOKENAuthorization: Bearer <tokenValue>API_KEYx-api-key: <tokenValue>CUSTOM_HEADER<customHeaderName>: <tokenValue>. Also sendcustomHeaderName.Check that header on every request and reject anything that doesn't match.
GET /oauth2/api/partner/webhook-configshows the current setting. -
Register a URL for each event you want
POST/oauth2/api/partner/webhooksPartner tokenRequest body{ "eventType": "COURSE_COMPLETED", "url": "https://app.example.com/sportys/webhooks", "enabled": true }Response 200{ "id": 812, "eventType": "COURSE_COMPLETED", "url": "https://app.example.com/sportys/webhooks", "enabled": true }- Set
enabledtotrue. If you leave it out, the webhook is created switched off. - One webhook per event type. The same URL can take every event; use the
eventfield to tell them apart. If you register the same event twice, you get every event twice. - Use an HTTPS URL.
- Set
Change or remove a webhook
| Call | What it does |
|---|---|
GET /oauth2/api/partner/webhooks | Lists your webhooks. |
PUT /oauth2/api/partner/webhooks/{webhookId} | Replaces the webhook. Send all three fields: a missing enabled switches it off. Returns the updated webhook. |
DELETE /oauth2/api/partner/webhooks/{webhookId} | Removes it and cancels its pending retries. Returns 204. |
Events
| Event | Sent when |
|---|---|
| COURSE_PURCHASED | The student gets a license for the course, whether through you, Sporty's website or Sporty's app. |
| LESSON_COMPLETED | The student finishes a required lesson: watches a required video, completes a required interactive lesson or document, or passes a quiz. eventDetails says which. |
| COURSE_COMPLETED | The student finishes the course's last required lesson. |
| WRITTEN_EXAM_ELIGIBLE | The student has finished the course and passed the required practice tests, so they can ask for their FAA written exam endorsement. |
| WRITTEN_EXAM_REQUESTED | The student asks for that endorsement. |
| TEST | Sporty's sends a test on request. See Test your endpoint. |
COURSE_PURCHASED, COURSE_COMPLETED, WRITTEN_EXAM_ELIGIBLE and WRITTEN_EXAM_REQUESTED each happen once per student and course.
Payload
POST /sportys/webhooks HTTP/1.1
Host: app.example.com
Content-Type: application/json; charset=utf-8
Authorization: Bearer whsec_4f9a1c7e2b8d6a3f5e0c9b7d1a2e4f6c
{
"sportysId": "1234567",
"userId": "1234567",
"webAppId": "PRIVATE",
"courseId": "PRIVATE",
"event": "LESSON_COMPLETED",
"timestamp": "2026-11-12T15:04:05.482Z",
"eventDetails": {
"elementUuid": "9b31980f-e74c-42e4-b47c-590b95d458a3",
"elementType": "Video",
"elementTitle": "When Should You Fly?"
}
}
| Field | Description |
|---|---|
event | The event type. |
userId | The student's Sporty's customer number (sportysId), as a string. |
courseId | The course ID. |
timestamp | When it happened, in UTC. A retried delivery keeps the original time. |
eventDetails | For LESSON_COMPLETED: the lesson's elementUuid, elementType and elementTitle. null for every other event. |
sportysId, webAppId | Older copies of userId and courseId, kept for existing integrations. Use userId and courseId. |
Delivery and retries
- Answer with any
2xxwithin 10 seconds. Anything else, including a timeout, counts as a failure. Save the event and do the work afterwards. - Failures are retried later with the same body, to the webhook's current URL and authentication, until a delivery succeeds. Disabling a webhook pauses its retries; deleting it cancels them.
- Expect duplicates. Treat
event,userId,courseIdandeventDetails.elementUuidtogether as the event's identity and ignore repeats. - Don't rely on order. For example,
WRITTEN_EXAM_ELIGIBLEcan arrive beforeCOURSE_COMPLETED. - An event can arrive a moment before the API shows the change. If progress looks unchanged right after an event, fetch it again a few seconds later.
Test your endpoint
Register a webhook with "eventType": "TEST" and ask your Sporty's contact to send a test event. It arrives with your authentication header and this body:
{ "event": "TEST", "timestamp": "2026-11-12T15:04:05.482Z" }
A failed test is retried like any other event, so disable or delete the TEST webhook when you're done.
Errors
Errors use standard HTTP status codes. Most come with a JSON body (application/problem+json) that has a status and a title or message. Authentication failures come with an empty body.
| Status | What it means | What to do |
|---|---|---|
400 | The request body is invalid: a required field is missing, the JSON is malformed, or a value isn't allowed. Validation errors list the fields in fieldErrors. | Fix the request. Don't retry it unchanged. |
401, empty body | The access token is missing, expired or no longer accepted. | Get a new token and retry once. |
401 with a message | The course isn't in your catalog. | Check the webAppId, or ask Sporty's to add the course. |
403 | Wrong kind of token, such as a student token on a /partner/ endpoint, a partner token on /me, or a POST sent without a token. | Use the token type the endpoint needs. |
404 | The student, course or webhook doesn't exist. | Check the ID. |
409 | The purchaseId is already used for a different student. | Use a new purchaseId for this sale. |
5xx | Sporty's couldn't complete the request. Some invalid requests also return 500 today. | Check the request against this guide, then retry with backoff. Retrying a purchase with the same purchaseIds is safe. |
{
"type": "https://www.jhipster.tech/problem/entity-not-found",
"title": "Course not found for web app id: PRIVTE",
"status": 404
}
{
"type": "https://www.jhipster.tech/problem/problem-with-message",
"status": 401,
"path": "/altitude/oauth2/api/partner/purchase",
"message": "Licenses cannot be submitted for the course MULTIENGINE"
}
{
"type": "https://www.jhipster.tech/problem/constraint-violation",
"title": "Method argument not valid",
"status": 400,
"path": "/altitude/oauth2/api/partner/purchase",
"message": "error.validation",
"fieldErrors": [
{ "objectName": "partnerPurchaseRequest", "field": "purchases", "message": "must not be empty" }
]
}
Match on the status code, not the message text: messages can change.
All endpoints
Paths are relative to https://courses.sportys.com/altitude. The API reference has every field, and the OpenAPI file imports into Postman or Insomnia.
| Method | Path | Token | What it does | Success |
|---|---|---|---|---|
| POST | /oauth2/token | Client ID and secret | Get an access token | 200 |
| GET | /oauth2/api/partner/courses | Partner | List the courses you can offer | 200 |
| GET | /oauth2/api/partner/courses/{webAppId} | Partner | One course with its volumes and lessons | 200 |
| GET | /oauth2/api/partner/school | Partner | List your schools | 200 |
| POST | /oauth2/api/partner/school | Partner | Create or update a school | 200, empty body |
| POST | /oauth2/api/partner/purchase | Partner | Give a student access to one or more courses | 201, empty body |
| POST | /oauth2/api/partner/report-purchase | Partner | Record a sale for a student who isn't connected | 200, empty body |
| GET | /oauth2/api/partner/webhooks | Partner | List your webhooks | 200 |
| POST | /oauth2/api/partner/webhooks | Partner | Add a webhook | 200 |
| PUT | /oauth2/api/partner/webhooks/{webhookId} | Partner | Replace a webhook | 200 |
| DELETE | /oauth2/api/partner/webhooks/{webhookId} | Partner | Remove a webhook | 204 |
| GET | /oauth2/api/partner/webhook-config | Partner | See how Sporty's authenticates to your webhook URL | 200 |
| PUT | /oauth2/api/partner/webhook-config | Partner | Set how Sporty's authenticates to your webhook URL | 200 |
| GET | /oauth2/api/me | Student | The connected student's profile | 200 |
| GET | /oauth2/api/courses/{webAppId}/progress | Student | The student's progress in one course | 200 |
Go-live checklist
- The client secret lives only on your server, in a secrets store.
- Partner tokens are cached and reused. A
401gets a new token and one retry. - Every school is registered before you sell through it, with an
enabledByUserEmail. - Every sale has its own
purchaseId, and retries reuse it. - Each purchase request covers one school.
- You identify connected students by
sportysId. - The sign-in asks for
openid profile, uses astatewithout-or_, and checksstateon return. - Refresh tokens are stored encrypted, and an
invalid_grantasks the student to connect again. - Webhook authentication is set, and each webhook is registered once with
"enabled": trueand an HTTPS URL. - Your webhook endpoint checks the auth header, answers within 10 seconds, ignores duplicates and doesn't depend on order.
- Percentages are clamped to 0–100, and certificate links are fetched fresh, not stored.
Support
Your Sporty's partner contact can issue or replace credentials, add courses to your catalog, register redirect URLs, link your schools on Sporty's, and send test webhooks.
When you report a problem, include the time (UTC), the endpoint, the HTTP status and response body, and the purchaseId or partnerFlightSchoolId involved. Never send your client secret or a student's tokens.