v1
Reads, and three writes, by key or OAuth

Read Play from your own code.

The catalog a creator publishes to, the syllabus their learners read, and the lessons themselves — the video, the transcript, the notes and the files — reachable by a script, a partner’s backend, or a classroom you build somewhere else. Bring an API key and read as its owner, or let people sign in to your app and read as them — and, with the scopes they agree to, keep their progress and post the comments they write.

Base URLhttps://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test

Two ways in

An x-api-key header for your own scripts. OAuth access tokens as Authorization: Bearer for apps other people sign in to.

Mostly a reader

Courses, lessons and their media, read with a key or a token. Three things under /v1 write, and all three need an OAuth token whose owner agreed to the scope: progress, favourites and comments.

Revoked means revoked

Nothing is cached in front of the credential check, so a revoked key — or an app somebody disconnected — stops on the next call.

Quickstart

Three steps, and the third one tells you whether it worked.

1

Make a key

In the studio, under your account menu — making one takes an account, because a key belongs to somebody. The secret is shown once: we keep a hash of it, so copy it somewhere safe before you close the dialog. Reading this page needs nothing.

Go to API keys
2

Put it in your environment

Shell
export PLAY_API_KEY="play_sk_…"

An environment variable rather than a literal, so the key stays out of your source code, your shell history and your screenshots.

3

Ask who you are

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me" \
  -H "x-api-key: $PLAY_API_KEY"

A 200 comes back with the key’s name, when it was last used, and the organization it reaches — which is the one thing that decides whether the organization endpoints below will answer.

Authentication

Every call under /v1 takes one of two credentials, in one of two headers. Nothing else about the call changes: the same endpoints, the same shapes, and the same answer to who you are — read from whichever header carries it.

# A key: one header, no flow, acts as its owner.
GET /v1/courses HTTP/1.1
Host: 3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test
x-api-key: play_sk_9f2c1a4b7d8e0f1a2b3c4d5e6f7a8b9c

# An access token: what an app holds after somebody authorized it.
GET /v1/courses HTTP/1.1
Host: 3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test
Authorization: Bearer play_at_3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c

What a key can read

The published catalog is open to any key. Everything else is authorized the way the signed-in apps are, with the key standing in for the person who made it:

A personal key

Reaches what its owner may read: their own organizations’ courses, and the courses they are registered for.

A key made for an organization

Reaches everything that organization owns — every course, published or not, and its lessons. That is the key to build a classroom with, and any member can make one; its admins can see it and revoke it.

What an app can read

Behind a person’s consent, and no further than the scopes they agreed to. The app acts as them — it reads what they may read, never more — and this is the whole catalogue of what it can be given:

profile:read

See your profile

GET /v1/me/profile: a name, a photo, a sentence and links. An API key never holds this one.

courses:read

Read the published catalog

GET /v1/courses and GET /v1/courses/{spaceId} — the courses their authors have listed.

lessons:read

Read course outlines and lessons

The outline, one lesson, and a lesson’s attachments — including courses that are not published, when the person authorizing may read them.

lessons:stream

Play lesson videos

GET /v1/lessons/{contentId}/stream and /subtitles: signed media URLs. Its own scope because serving video is what costs money.

organization:courses:read

Read the courses of an organization

GET /v1/organizations/{orgId}/courses, published or not. The only scope that reaches anything unpublished, and the person authorizing must be a member of that organization.

learning:read

See your progress and your saved lessons

GET /v1/me/learning: what this person has finished and what they have saved. Their own record — no other account is reachable through it.

learning:write

Mark lessons complete, and save them

PUT/DELETE on /v1/lessons/{contentId}/completion and /favourite. Changes the person’s own record and nobody else’s, and cannot delete a lesson or a course.

comments:write

Post comments and replies as you

POST /v1/lessons/{contentId}/comments, with parentId to answer somebody. Your name goes on it. Editing and deleting stay in Play, where the person reading the discussion is the one who wrote it. Reading a discussion needs no scope beyond lessons:read.

An API key holds the five reads — every one of these except profile:read — plus organization:courses:read when it was made for an organization. It holds no write scope at all: a key is a script’s credential with a fixed reach chosen once, and speaking as somebody or changing their record is a permission a person grants to an app on a consent screen. The three writes at the end of the list are reachable with an OAuth token and nothing else.

Secrets are stored as hashes

We keep a SHA-256 of a key, a client secret, a code or a token, and never the value itself. Nobody — not an admin, not support — can read one back, so a lost credential is replaced rather than recovered.

A key acts as its owner

Every read is attributed to the person who made it, and to the organization it was made for. Revoking it, by them or by that organization's admins, ends it.

Managing credentials takes a session

The endpoints that mint and revoke keys and apps are called with a signed-in session token, not with a key. A credential cannot mint another credential.

Nothing is cached

A credential is resolved against its own row on every request, so revoking a key — or disconnecting an app — takes effect on the next call rather than within the hour a caching authorizer would take.

Signing people in with OAuth

For software other people sign in to. Instead of one credential that acts as one person forever, an app asks each of them for permission on a screen, reads as them and no further than they allowed, and can be cut off by any of them — or by all of them at once when the app is deleted.

1

Register the app

In the studio, under OAuth apps. You get a client id, a client secret (or none at all, if it is a public client), and you say where the app is allowed to be sent back to. That redirect URI is matched exactly, forever after.

Go to OAuth apps
2

Send people to the consent screen

Your client builds a URL with a client id, a redirect URI, the scopes it wants, some state, and a PKCE code_challenge — and opens it. Play draws the screen: what your app is, what it is asking for, and who is signed in. Nobody types a Play password into your app, because nobody signs in anywhere but here.

The URL your client opens
https://<your-studio>/oauth/authorize?
  client_id=play_app_7c1d9e2f4a6b8c0d&
  redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fplay%2Fcallback&
  response_type=code&
  scope=profile%3Aread+courses%3Aread&
  state=a1b2c3d4&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256
3

Take the code back to your server

The browser comes back to your redirect URI with code and your state — check the state, then exchange the code at POST /oauth/token with the verifier your client kept. The code is single-use and lives sixty seconds.

Exchanging the code
curl -X POST "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/token" \
  -d grant_type=authorization_code \
  -d code=$CODE \
  -d redirect_uri=https://example.com/auth/play/callback \
  -d code_verifier=$VERIFIER \
  -d client_id=$PLAY_CLIENT_ID \
  -d client_secret=$PLAY_CLIENT_SECRET
4

Call the API as them

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me/profile" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

An hour later the access token expires. Spend the refresh token at the same endpoint and you get a new pair — and the refresh token rotates, so store the new one every time rather than the one you had.

5

Be a good citizen

Handle error=access_denied as an answer rather than a failure — somebody pressed Cancel, and that is allowed. Ask for the scopes you actually use. Revoke at POST /oauth/revoke when somebody deletes their account from your app. And expect a 403 that names a scope: it means the person is still connected but granted you less than this call needs.

PKCE is required of everybody

S256, on every client, public or confidential. A code travels through a browser, a redirect and usually a log; the verifier never leaves the client.

Disconnecting is immediate

Deleting a grant deletes the tokens under it, and nothing is cached in front of the check, so the app's next call is refused rather than one within the hour.

No password ever reaches an app

The consent screen is this app. An app receives a code, then tokens — never a credential a person typed.

No client_credentials grant

An app that acts as itself with nobody behind it is what an API key already is, with a screen for making one. Adding it here would be a second answer to a question that has one.

The credential itself

What the credential you are holding is, and who it acts as. Behind either credential, like everything under /v1.

GET/v1/me
API key or bearer token

Check a credential, and see everything it reaches.

The first call to make with a new credential, and the one that answers both questions its holder has: does this work, and what does it unlock. It answers for either kind — an API key or an OAuth access token — and `kind` says which one you are holding. It needs no scope, which is the point: a credential that has run out of permission still has to be able to find out what it is.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "kind": "oauth",
  "oauth": {
    "app": {
      "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y",
      "clientId": "play_app_7c1d9e2f4a6b8c0d",
      "name": "Team dashboard",
      "description": "Shows your team's courses and progress in one place."
    },
    "scopes": ["profile:read", "courses:read", "lessons:read"],
    "scope": "profile:read courses:read lessons:read"
  },
  "owner": {
    "userId": "8f14e45f-ea6c-4f2b-9d3a-1c2b3a4d5e6f"
  },
  "scopes": ["profile:read", "courses:read", "lessons:read"]
}
kind"key" | "oauth"
Which credential authenticated the call.
keyobject?
The key, when `kind` is `key`. Absent otherwise. It has the fields of any other key: `keyId`, `name`, `prefix`, `createdAt`, `lastUsedAt`, and the organization it was made for.
oauth.appobject?
The app the token was issued to, when `kind` is `oauth`: its `appId`, `clientId`, `name` and `description`.
oauth.scopesarray?
What the token was issued with — the permissions a person agreed to on a consent screen.
oauth.scopestring?
The same list, space-delimited, spelled the way OAuth spells it.
owner.userIdstring
Cognito `sub` of the person the credential acts as. Every read is attributed to them.
scopesarray
Every scope the credential holds. Empty for an API key unless its owner named an organization.
GET/v1/me/learning
learning:read
OAuth access token only

What this person has saved, and what they have finished.

The read that makes the two write scopes worth asking for: an app that can mark a lesson complete and cannot see which lessons are complete draws a checkbox that lies, and the same goes for a heart. Both lists arrive in one response, because both are drawn beside every lesson in a course outline and asking a lesson at a time would be a request per row.

  • Both lists are returned whole, not paged: they are one person’s own activity, bounded by what they have done rather than by what the service holds.
  • A favourite whose lesson has since been deleted is dropped rather than reported as broken — the same answer Play’s own list gives for the same row.
  • Hearts on *comments* and on *loops* are not here. Those are things somebody saves while reading a discussion, and an app drawing a course has no use for them.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me/learning"

Response

200 OK · application/json
{
  "favourites": [
    {
      "contentId": "01JQ8Y5E2F4G6H8J0K2M4P6R8S",
      "title": "Rolling shutter, explained",
      "spaceId": "01JQ8Y4C1D2E3F4G5H6J7K8M9P",
      "spaceTitle": "Documentary camera craft",
      "position": 3,
      "hasVideo": true,
      "favouritedAt": 1772582400000
    }
  ],
  "completed": [
    {
      "contentId": "01JQ8Y6F3G5H7J9K1M3P5R7T9V",
      "spaceId": "01JQ8Y4C1D2E3F4G5H6J7K8M9P",
      "completedAt": 1772668800000
    }
  ]
}
favouritesarray
The lessons this person has saved, most recently saved first, each with the course it belongs to.
favourites[].spaceTitlestring
What the course is called. Read in one batch for the whole list — a list of saved lessons that never says which course any of them is in is a list of links.
favourites[].hasVideoboolean
Whether there is something to play, so the list can offer it.
completedarray
The lessons this person has finished, with the course each belongs to.
GET/v1/me/profile
profile:read
API key or bearer token

Who the person behind the credential is.

A name, a photo, a sentence and a set of links — the same public half of a profile a marketplace course page credits an instructor with. It is what makes an integration feel like part of the product rather than a script holding a token: an app that knows a name can greet somebody by it. Behind `profile:read`, because a name is a person and a catalog is not, and somebody reading a consent screen can tell those two apart.

  • No email, and no timestamps. What this endpoint answers with is what this service shows a stranger on a course page, because a consent screen cannot ask somebody to agree to something they cannot see.
  • An API key never holds profile:read, so this endpoint answers a key with 403. A key belongs to a script, and no person agreed to anything on their own behalf when it was made.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me/profile

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/me/profile" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "profile": {
    "userId": "8f14e45f-ea6c-4f2b-9d3a-1c2b3a4d5e6f",
    "name": "Dana Ruiz",
    "bio": "Teaches film editing, badly but enthusiastically.",
    "socials": {
      "website": "https://dana.example"
    },
    "photoUrl": "https://videos.example.net/people/8f14e45f/photo-1772582400000.jpg?Policy=…"
  }
}
profile.userIdstring
Cognito `sub`. The same id `GET /v1/me` reports as `owner.userId`.
profile.namestring
What they call themselves. Never empty.
profile.biostring
The sentence they wrote about themselves. Empty when they have not written one.
profile.socialsobject
Their links, keyed by kind. Empty when they have added none.
profile.photoUrlstring?
A signed URL, minted per response and good for a few minutes. Absent when they have no photo.

The published catalog

Courses their authors have published, and the syllabus of each one. The same courses the marketplace shows a visitor who has not signed in.

GET/v1/courses
courses:read
API key or bearer token

List the published courses, newest first.

One page of the catalog, or a search across it. With `query` the API searches instead of paging: it reads a bounded stretch of the catalog and matches a case-insensitive substring against each course’s title, its description, and the name of the community it is from.

  • An empty courses array with no nextToken means the catalog is empty, not that the search failed.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/courses

Parameters

querystring
Search the catalog instead of browsing it. A search returns no `nextToken` — what comes back is the matches among a bounded read, which is the honest shape for this to have until the catalog is big enough to want a search index of its own.
limitinteger
How many to return in one page. Defaults to 20, and never more than 100.
nextTokenstring
The page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent.

Request body

None. The endpoint is addressed entirely by its path and query string.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/courses" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "courses": [
    {
      "spaceId": "01JQ8Y4C2D5F7H9K1M3P5R7T9V",
      "title": "Introduction to Film",
      "description": "Eight weeks of how a film is put together, from the first shot to the last cut.",
      "type": "SELF_PACED",
      "color": "#6366f1",
      "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K",
      "organizationName": "Northwind Learning",
      "thumbnailUrl": "https://d111111abcdef8.cloudfront.net/spaces/01JQ8Y4C/cover.jpg?Policy=…&Signature=…&Key-Pair-Id=…",
      "sectionCount": 6,
      "lessonCount": 24,
      "studentCount": 128,
      "createdAt": 1771977600000
    }
  ],
  "nextToken": "eyJzcGFjZUlkIjoiMDFKUThZ…"
}
coursesarray
The page of courses, newest first.
courses[].spaceIdstring
ULID. What a course is addressed by everywhere else in the API.
courses[].titlestring
What the course is called.
courses[].descriptionstring
What it says about itself.
courses[].type"SELF_PACED" | "SCHEDULED"
Self-paced starts when a learner registers; scheduled starts on `startAt`.
courses[].colorstring?
Custom accent, `#rrggbb`. Absent means the interface derives one.
courses[].startAtnumber?
Epoch milliseconds. Only on a scheduled course.
courses[].dripIntervalDaysnumber?
Days between section unlocks. Only on a scheduled course.
courses[].thumbnailUrlstring?
Signed cover image URL, good for a few minutes. Present only if the course has a cover.
courses[].organizationIdstring
The organization the course belongs to.
courses[].organizationNamestring
What that organization is called, for drawing a card without a second call.
courses[].sectionCountinteger
How many sections it holds, counted when you ask rather than stored.
courses[].lessonCountinteger
How many lessons it holds.
courses[].studentCountinteger
How many students are registered for it.
courses[].createdAtnumber
Epoch milliseconds.
nextTokenstring?
Pass this back as `nextToken` to read the next page. Absent on the last page, and always absent from a search.
GET/v1/courses/{spaceId}
courses:read
API key or bearer token

One published course, with its syllabus.

What a course is, and what is in it: its sections and their lessons, in the order they are taught. The lessons themselves are not here — a lesson’s video, notes, files and discussion are what registering for the course is *for*, and they stay behind the course’s own membership.

  • A course that exists but has not been published answers 404, not 403 — the catalog does not report which course ids exist in private.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/courses/01JQ8Y4C2D5F7H9K1M3P5R7T9V

Parameters

spaceIdstring
Required
The course to read, as returned by the list endpoint.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/courses/01JQ8Y4C2D5F7H9K1M3P5R7T9V" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "course": {
    "spaceId": "01JQ8Y4C2D5F7H9K1M3P5R7T9V",
    "title": "Introduction to Film",
    "type": "SELF_PACED",
    "organizationName": "Northwind Learning",
    "sectionCount": 2,
    "lessonCount": 3,
    "studentCount": 128,
    "createdAt": 1771977600000
  },
  "sections": [
    {
      "sectionId": "01JQ8Y6E4F7H9K1M3P5R7T9V1X",
      "title": "Before the camera",
      "lessons": [
        { "contentId": "01JQ8Y8G6H9K1M3P5R7T9V1X3Z", "title": "What a shot is", "hasVideo": true }
      ]
    }
  ]
}
courseobject
The course, in the same shape the list returns.
sectionsarray
Its sections, in teaching order.
sections[].sectionIdstring
ULID of the section.
sections[].titlestring
The section’s heading.
sections[].lessonsarray
The lessons filed under it, in order.
sections[].lessons[].contentIdstring
ULID of the lesson.
sections[].lessons[].titlestring
What the lesson is called.
sections[].lessons[].hasVideoboolean
Whether the lesson plays a video. The video’s id is not published: it is behind the course’s membership.

An organization’s courses

What a key made for an organization reaches that the public catalog does not: its whole catalogue, published or not.

GET/v1/organizations/{orgId}/courses
organization:courses:read
API key or bearer token

List an organization’s courses, including unpublished ones.

The read that makes naming an organization on a key worth doing. A partner integrating with one customer gets that customer’s entire catalogue — the courses written for a team, the drafts, the ones nobody has listed — rather than the subset advertised to the world. Courses come back in the same shape the catalog list returns.

  • A key made for one organization is not a key for every organization. Asking about any other answers 403 rather than an empty list, so an integration wired up to the wrong organization says so instead of reporting that its customer has no courses.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/organizations/01JQ8Y2A1B3C4D5E6F7G8H9J0K/courses

Parameters

orgIdstring
Required
The organization. The key must have been made for it.
limitinteger
How many to return in one page. Defaults to 20, and never more than 100.
nextTokenstring
The page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent.

Request body

None. The endpoint is addressed entirely by its path and query string.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/organizations/01JQ8Y2A1B3C4D5E6F7G8H9J0K/courses" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "courses": [
    {
      "spaceId": "01JQ8Y4C2D5F7H9K1M3P5R7T9V",
      "title": "Introduction to Film",
      "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K",
      "organizationName": "Northwind Learning",
      "sectionCount": 6,
      "lessonCount": 24,
      "studentCount": 128,
      "createdAt": 1771977600000
    }
  ],
  "nextToken": "eyJzcGFjZUlkIjoiMDFKUThZ…"
}
coursesarray
The organization’s courses, newest first. Unpublished ones are included.
courses[].spaceIdstring
ULID. What a course is addressed by everywhere else in the API.
courses[].titlestring
What the course is called.
courses[].descriptionstring
What it says about itself.
courses[].type"SELF_PACED" | "SCHEDULED"
Self-paced starts when a learner registers; scheduled starts on `startAt`.
courses[].colorstring?
Custom accent, `#rrggbb`. Absent means the interface derives one.
courses[].startAtnumber?
Epoch milliseconds. Only on a scheduled course.
courses[].dripIntervalDaysnumber?
Days between section unlocks. Only on a scheduled course.
courses[].thumbnailUrlstring?
Signed cover image URL, good for a few minutes. Present only if the course has a cover.
courses[].organizationIdstring
The organization the course belongs to.
courses[].organizationNamestring
What that organization is called, for drawing a card without a second call.
courses[].sectionCountinteger
How many sections it holds, counted when you ask rather than stored.
courses[].lessonCountinteger
How many lessons it holds.
courses[].studentCountinteger
How many students are registered for it.
courses[].createdAtnumber
Epoch milliseconds.
nextTokenstring?
The next page, when there is one.

A lesson

The pieces a page needs to teach with, in the order it needs them: the outline the lesson sits in, the lesson itself, its video, its subtitles and its attachments. These are authorized by **access** rather than by publication — a key reaches what its owner may read, and a key made for an organization reaches everything that organization owns.

GET/v1/courses/{spaceId}/sections
lessons:read
API key or bearer token

The outline of a course you can read, published or not.

The same shape the syllabus uses — sections, and each lesson’s title and whether it has a video — with one difference that is the whole reason this endpoint exists: it answers for any course the key may read. `GET /v1/courses/{spaceId}` is the catalogue and answers only for a published course; this is the left rail of a classroom, which has to work for the ones nobody has advertised.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/courses/01JQ8Y4C2D5F7H9K1M3P5R7T9V/sections

Parameters

spaceIdstring
Required
The course to outline.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/courses/01JQ8Y4C2D5F7H9K1M3P5R7T9V/sections" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "sections": [
    {
      "sectionId": "01JQ8Y6E4F7H9K1M3P5R7T9V1X",
      "title": "Before the camera",
      "lessons": [
        { "contentId": "01JQ8Y8G6H9K1M3P5R7T9V1X3Z", "title": "What a shot is", "hasVideo": true },
        { "contentId": "01JQ8Y8G6H9K1M3P5R7T9V1X40", "title": "Reading a scene", "hasVideo": false }
      ]
    }
  ]
}
sectionsarray
The course’s sections, in teaching order.
sections[].sectionIdstring
ULID of the section.
sections[].titlestring
The section’s heading.
sections[].lessonsarray
Its lessons, in order.
sections[].lessons[].contentIdstring
What `/v1/lessons/{contentId}` takes.
sections[].lessons[].titlestring
What the lesson is called.
sections[].lessons[].hasVideoboolean
Whether there is a video to ask `/stream` for.
GET/v1/lessons/{contentId}
lessons:read
API key or bearer token

One lesson: its title, its notes, and its poster.

What a lesson is, apart from its media. The notes come back as the document the author wrote — a ProseMirror tree, the same one the classroom renders — rather than as HTML, because this API does not sanitize markup for a caller and a document is not a string anybody has to trust. The poster is here so a page has something to draw before the manifest arrives.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z

Parameters

contentIdstring
Required
The lesson, from the outline above.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "lesson": {
    "contentId": "01JQ8Y8G6H9K1M3P5R7T9V1X3Z",
    "spaceId": "01JQ8Y4C2D5F7H9K1M3P5R7T9V",
    "sectionId": "01JQ8Y6E4F7H9K1M3P5R7T9V1X",
    "title": "What a shot is",
    "notes": { "type": "doc", "content": [] },
    "videoId": "01JQ8Y9H7K1M3P5R7T9V1X3Z5B",
    "thumbnailUrl": "https://d111111abcdef8.cloudfront.net/thumbnails/…?Policy=…&Signature=…",
    "fileCount": 2,
    "position": 1,
    "createdAt": 1771977600000,
    "updatedAt": 1772064000000
  }
}
lesson.contentIdstring
ULID of the lesson.
lesson.spaceIdstring
The course it belongs to.
lesson.sectionIdstring
The section it is filed under.
lesson.titlestring
What it is called.
lesson.notesobject?
The author’s notes as a ProseMirror document. Render it with an editor; do not treat it as markup.
lesson.videoIdstring?
The video it plays, when it has one. Absent on a lesson that is reading only.
lesson.thumbnailUrlstring?
Signed poster URL, when the video has one.
lesson.fileCountinteger
How many attachments it has. `/attachments` returns them.
lesson.positioninteger
1-based order inside its section.
lesson.createdAtnumber
Epoch milliseconds.
lesson.updatedAtnumber
Epoch milliseconds.
GET/v1/lessons/{contentId}/stream
lessons:stream
API key or bearer token

A signed HLS manifest URL for the lesson’s video.

How to play it. The manifest is signed per request, so a lesson’s video is reachable only by somebody who may read the lesson — and `baseUrl` and `signedQuery` come back beside the URL because the signature covers the video’s whole stream prefix rather than one file. A player has to attach that same query to every segment it asks for, which is the one thing it cannot work out from the manifest alone.

  • A lesson with no video answers 404. One whose video is still encoding, or whose encoding failed, answers 409 with the status — which is a state a page can say something about, where an empty manifest URL is not.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z/stream

Parameters

contentIdstring
Required
The lesson whose video to play.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z/stream" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "videoId": "01JQ8Y9H7K1M3P5R7T9V1X3Z5B",
  "manifestUrl": "https://d111111abcdef8.cloudfront.net/processed/01JQ8Y9H7K1M3P5R7T9V1X3Z5B/hls/master.m3u8?Policy=…&Signature=…&Key-Pair-Id=…",
  "baseUrl": "https://d111111abcdef8.cloudfront.net/processed/01JQ8Y9H7K1M3P5R7T9V1X3Z5B/hls/master.m3u8",
  "signedQuery": "Policy=…&Signature=…&Key-Pair-Id=…",
  "expiresAt": 1772669700
}
videoIdstring
The video behind the manifest.
manifestUrlstring
The signed HLS master playlist. Hand this to the player.
baseUrlstring
The same URL unsigned — for building sibling requests.
signedQuerystring
Attach this to every segment and rendition request; the signature covers the whole prefix.
expiresAtnumber
Expiry in epoch **seconds**. Refetch the stream rather than holding a page open past it.
GET/v1/lessons/{contentId}/subtitles
lessons:stream
API key or bearer token

Signed WebVTT tracks, and the transcript that goes with them.

Every ready track — the language it was transcribed in, and any translation the author generated — as a signed WebVTT URL for whatever player you use. `words` is the other half of the same recording: each word with when it is said, which is what lets a transcript highlight as it is read rather than appearing a line at a time.

  • A lesson whose subtitles are still being generated answers 200 with its status and no tracks, rather than an error — “no captions yet” is a state a page renders, not a failure it has to catch.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z/subtitles

Parameters

contentIdstring
Required
The lesson whose subtitles to read.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z/subtitles" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "videoId": "01JQ8Y9H7K1M3P5R7T9V1X3Z5B",
  "status": "READY",
  "sourceLanguage": "en-US",
  "tracks": [
    {
      "language": "en-US",
      "label": "English",
      "isSource": true,
      "subtitleUrl": "https://d111111abcdef8.cloudfront.net/subtitles/…/source.vtt?Policy=…&Signature=…",
      "baseUrl": "https://d111111abcdef8.cloudfront.net/subtitles/…/source.vtt",
      "signedQuery": "Policy=…&Signature=…&Key-Pair-Id=…",
      "expiresAt": 1772669700
    }
  ],
  "words": [
    { "w": "A", "s": 0, "e": 120 },
    { "w": "shot", "s": 120, "e": 460 }
  ]
}
videoIdstring?
Null when the lesson has no video at all.
statusstring
`NONE`, `GENERATING`, `READY` or `FAILED` — whether captions exist yet, so a page can say “coming” rather than show nothing.
sourceLanguagestring?
The language it was transcribed in, when there are tracks.
tracksarray
One per ready language. Empty when there are none.
tracks[].languagestring
BCP-47 code, e.g. `en-US`, `zh-CN`.
tracks[].labelstring
Human-readable, e.g. `English`.
tracks[].isSourceboolean
True for the track it was transcribed in, false for a translation.
tracks[].subtitleUrlstring
The signed WebVTT file.
tracks[].signedQuerystring
As with the stream: the signature covers the prefix.
tracks[].expiresAtnumber
Expiry in epoch seconds.
wordsarray?
Each word with `w`, and `s`/`e` in milliseconds from the start of the video. Capped; absent when the video has no timings.
PUT/v1/lessons/{contentId}/completion
learning:write
OAuth access token only

Mark a lesson done.

Marks the lesson finished for the person the token acts as, and earns whatever that crossing earns them in the course they are taking. Reading the lesson is the whole authorization — progress is somebody’s own record of what they have finished, not an editorial act and not an assessment — and `learning:write` is what says an app may keep it for them.

  • Idempotent. Marking a finished lesson done again is still done, and the original completedAt is kept — so a client that retries a request it never saw the answer to cannot rewrite history.
  • A lesson with no video can be completed. Progress is about the lesson, not about how much of the video was watched.
  • A reward earned by crossing a milestone is granted here and read in Play. It is deliberately not in this response: an app keeping a progress list has no business enumerating somebody’s rewards.

Parameters

contentIdstring
Required
The lesson being finished.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/completion" \
  -X PUT

Response

200 OK · application/json
{
  "completed": true,
  "completedAt": 1772582400000
}
completedboolean
Always true on this method — the lesson is done after it.
completedAtnumber
Epoch milliseconds. The moment it was **first** finished, which is what a repeat call returns.
DELETE/v1/lessons/{contentId}/completion
learning:write
OAuth access token only

Take a lesson back off the done list.

The other half of the pair, for the client toggling a checkbox. Removing a completion that was never there is not an error: pressing it twice, or on a lesson that was never marked, has asked for the same state either way.

  • A reward already earned is not taken back: what somebody has been given is theirs.

Parameters

contentIdstring
Required
The lesson being unmarked.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/completion" \
  -X DELETE

Response

200 OK · application/json
{
  "completed": false
}
PUT/v1/lessons/{contentId}/favourite
learning:write
OAuth access token only

Save a lesson to somebody’s favourites.

Saves the lesson for the person the token acts as, and answers with how many people have saved it — which is the number a lesson page draws beside the heart. Favouriting twice is not two favourites and not two increments: the write is conditional, so the count only moves when a row was actually created.

  • Only lessons can be saved through this API. Play’s own list also holds hearts on comments and on loops — gestures somebody makes while reading a discussion — and those are not things an app does on their behalf.

Parameters

contentIdstring
Required
The lesson being saved.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/favourite" \
  -X PUT

Response

200 OK · application/json
{
  "favourited": true,
  "favouriteCount": 12
}
favouritedboolean
The state after the call.
favouriteCountnumber
How many people have this lesson saved, after this call. Floored at zero.
DELETE/v1/lessons/{contentId}/favourite
learning:write
OAuth access token only

Unsave a lesson.

The other half of the toggle. Removing a favourite that was not there is not an error — the count only moves if a row was actually deleted.

Parameters

contentIdstring
Required
The lesson being unsaved.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/favourite" \
  -X DELETE

Response

200 OK · application/json
{
  "favourited": false,
  "favouriteCount": 11
}
GET/v1/lessons/{contentId}/comments
lessons:read
API key or bearer token

The discussion on a lesson.

Every top-level comment with its replies, oldest first — what everybody said, not only what you said. Behind `lessons:read` and nothing else: anybody who may read a lesson may read what was said about it, the same rule Play’s own apps follow. `truncated` says when a discussion is longer than one read (500 comments), so a client can say "the most recent 500" rather than showing half a conversation as if it were all of it.

  • Threads, not rows. A reply always carries the thread’s *root* as parentId, however deep the conversation looks, so this response is already nested and a client never has to build the tree. That rule is the API’s, and it is why the answers arrive assembled.
  • No per-caller hearts. Unlike Play’s own discussion endpoint, these comments do not say whether *you* have favourited them. A heart is part of somebody’s learning record — what learning:read is for — and a route that hands out a conversation should not hand out a person’s saving habits with it. A field that always said false would be worse than absent.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/comments

Parameters

contentIdstring
Required
The lesson whose discussion is being read.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/comments" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "threads": [
    {
      "comment": {
        "commentId": "01JQ9C1D2E3F4G5H6J7K8M9P0Q",
        "contentId": "01JQ8Y5E2F4G6H8J0K2M4P6R8S",
        "authorId": "8f14e45f-ea6c-4f2b-9d3a-1c2b3a4d5e6f",
        "authorName": "Dana Ruiz",
        "body": "Worth watching the second half twice.",
        "replyCount": 1,
        "favouriteCount": 3,
        "createdAt": 1772582400000,
        "updatedAt": 1772582400000
      },
      "replies": [
        {
          "commentId": "01JQ9C2E3F4G5H6J7K8M9P0Q1R",
          "contentId": "01JQ8Y5E2F4G6H8J0K2M4P6R8S",
          "authorId": "2b3c4d5e-6f70-4a1b-8c9d-0e1f2a3b4c5d",
          "authorName": "Sam Okafor",
          "body": "Agreed — the rolling-shutter demo is at 6:10.",
          "parentId": "01JQ9C1D2E3F4G5H6J7K8M9P0Q",
          "replyToId": "01JQ9C1D2E3F4G5H6J7K8M9P0Q",
          "replyCount": 0,
          "favouriteCount": 0,
          "createdAt": 1772668800000,
          "updatedAt": 1772668800000
        }
      ]
    }
  ],
  "truncated": false
}
threadsarray
Top-level comments, oldest first, each with its replies.
threads[].comment.parentIdstring?
Absent on a top-level comment.
threads[].replies[]array
The answers to it, in the order they were written. A reply never nests further — see below.
threads[].replies[].replyToIdstring?
The comment this reply answers, when that is not the thread’s root. Threads are two levels deep and this is what lets a screen say "replying to Sam" inside one.
threads[].comment.replyCountinteger
How many replies the thread has. Zero on a reply itself.
truncatedboolean
True when the lesson has more comments than one read returns.
POST/v1/lessons/{contentId}/comments
comments:write
OAuth access token only

Post a comment on a lesson.

The one write under `/v1` that puts somebody’s **name** on something: the comment appears in the lesson’s discussion under the name of the person who authorized the app, exactly as if they had typed it in Play — and with `parentId` it answers somebody else’s. Nobody who can read a lesson needs any further permission to take part in its discussion, which is why `comments:write` is the whole gate.

  • Replies are one id. Send the comment being answered as parentId — top-level or a reply, either way — and the response’s parentId tells you the thread it landed in. A reply to a reply keeps the same thread root and records who it answers in replyToId, which is what keeps a discussion two levels deep instead of a tree nobody can draw.
  • No editing and no deleting. A posted comment can be removed in Play, by the person whose name is on it. Posting is the grant here; rewriting or retracting under somebody’s name is a larger one that nothing asked for.
  • The lesson’s comment count moves with it, and the thread’s reply count when this was a reply, so a discussion reads as one comment longer everywhere it is drawn.

Parameters

contentIdstring
Required
The lesson being commented on.

Request body

bodystring
Required
The comment. 1–2000 characters, trimmed.
parentIdstring
The comment being answered. Omit for a top-level comment. It may be a top-level comment **or a reply** — the API works out where the new comment sits in the thread, so a client never sends the thread’s root itself.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y5E2F4G6H8J0K2M4P6R8S/comments" \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{
    "body": "This is the clearest explanation of a rolling shutter I have watched.",
    "parentId": "01JQ9C1D2E3F4G5H6J7K8M9P0Q"
  }'

Response

201 Created · application/json
{
  "comment": {
    "contentId": "01JQ8Y5E2F4G6H8J0K2M4P6R8S",
    "commentId": "01JQ9C1D2E3F4G5H6J7K8M9P0Q",
    "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K",
    "authorId": "8f14e45f-ea6c-4f2b-9d3a-1c2b3a4d5e6f",
    "authorName": "Dana Ruiz",
    "body": "This is the clearest explanation of a rolling shutter I have watched.",
    "replyCount": 0,
    "favouriteCount": 0,
    "createdAt": 1772582400000,
    "updatedAt": 1772582400000,
    "favourited": false
  }
}
commentobject
The comment as it was stored, with the author’s name resolved from their profile.
comment.authorNamestring
Read from their Play profile at the moment of posting — a name can change between two comments, and what somebody was called when they said something is part of what they said.
comment.favouritedboolean
Always false on a new comment: nobody has hearted it yet.
GET/v1/lessons/{contentId}/attachments
lessons:read
API key or bearer token

The worksheets and files beside the video.

Everything attached to the lesson, each with a signed URL. They are signed under one policy scoped to the lesson’s own prefix, so a single signature serves every file — which is also why the whole list comes back at once rather than paged: a lesson’s material is a handful of files.

Try it

Needs a key — add one above

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z/attachments

Parameters

contentIdstring
Required
The lesson whose attachments to list.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/v1/lessons/01JQ8Y8G6H9K1M3P5R7T9V1X3Z/attachments" \
  -H "x-api-key: $PLAY_API_KEY"

Response

200 OK · application/json
{
  "attachments": [
    {
      "fileId": "01JQ8YBJ9M3P5R7T9V1X3Z5B7D",
      "name": "shot-list.pdf",
      "contentType": "application/pdf",
      "size": 182734,
      "url": "https://d111111abcdef8.cloudfront.net/contents/…/shot-list.pdf?Policy=…&Signature=…",
      "createdAt": 1771977600000
    }
  ],
  "expiresAt": 1772669700
}
attachmentsarray
The lesson’s files. Empty when it has none.
attachments[].fileIdstring
ULID, unique within the lesson.
attachments[].namestring
The file’s name as it was uploaded — what to save it as.
attachments[].contentTypestring
MIME type, so a caller knows what it is holding.
attachments[].sizenumber?
Bytes, when it was recorded at upload.
attachments[].urlstring
Signed download URL.
attachments[].createdAtnumber
Epoch milliseconds.
expiresAtnumber
Expiry in epoch seconds, shared by every URL in the answer.

Managing keys

The same operations the studio performs when you press Create key or Revoke. These take your **signed-in session**, not an API key: a key cannot mint keys.

GET/me/api-keys
Signed-in session

Your own keys that still work.

The keys you hold, newest first. Every row is a key that works: revoking deletes one, so there is no revoked state to return and nothing here that cannot authenticate. The secret itself is never in this response — the API keeps only a hash of it — which is why a key is only ever readable at the moment it is made.

Try it

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/me/api-keys

Parameters

limitinteger
How many to return in one page. Defaults to 20, and never more than 100.
nextTokenstring
The page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent.

Request body

None. The endpoint is addressed entirely by its path and query string.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/me/api-keys" \
  -H "Authorization: Bearer $PLAY_TOKEN"

Response

200 OK · application/json
{
  "keys": [
    {
      "keyId": "01JQ8Z6K4M7N9P2R5T8V1W3X6Y",
      "name": "Nightly reporting",
      "prefix": "play_sk_9f2c1a4b",
      "createdAt": 1772582400000,
      "lastUsedAt": 1772668800000,
      "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K",
      "organizationName": "Northwind Learning"
    }
  ],
  "nextToken": null
}
keysarray
Your keys, newest first.
keys[].keyIdstring
ULID. The key’s public name — what revokes it, and what a support question quotes.
keys[].namestring
What the key is for, as its owner named it.
keys[].prefixstring
The opening characters of the secret, `play_sk_…`. Enough to tell two keys apart, not enough to use one.
keys[].createdAtnumber
Epoch milliseconds.
keys[].lastUsedAtnumber?
When it was last presented, accurate to about five minutes. Absent until it is used.
keys[].organizationIdstring?
The organization it was made for, when its creator named one.
keys[].organizationNamestring?
That organization’s name, so a list needs no second call.
POST/me/api-keys
Signed-in session

Make a key. The secret comes back once.

The response carries the secret and it is the only time the API will ever hand one over: what is stored is a SHA-256 of it, so nothing — not an admin, not support, not this endpoint — can read the key back. A caller that loses one revokes it and makes another.

  • One account may hold 25 live keys at a time. Past that, creating one answers 409 and asks you to revoke one first — an unbounded list of keys is an unbounded list of things that can be lost.

Try it

POST https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/me/api-keys

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Request body

namestring
Required
What the key is for. 2–60 characters. A name you will recognize in six months, when deciding which key to cut off.
organizationIdstring
The organization to make the key for. Any active member of it may name it. A key made for an organization appears in that organization’s own list, where its admins can revoke it without asking you — and reaches that organization’s unpublished courses as well as the public catalog.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/me/api-keys" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Nightly reporting",
    "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K"
  }'

Response

201 Created · application/json
{
  "key": {
    "keyId": "01JQ8Z6K4M7N9P2R5T8V1W3X6Y",
    "name": "Nightly reporting",
    "prefix": "play_sk_9f2c1a4b",
    "createdAt": 1772582400000,
    "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K",
    "organizationName": "Northwind Learning"
  },
  "secret": "play_sk_9f2c1a4b7d8e0f1a2b3c4d5e6f7a8b9c"
}
keyobject
The key as it will be listed from now on.
secretstring
The credential itself, shown once and never again. Send it as the `x-api-key` header.
DELETE/me/api-keys/{keyId}
Signed-in session

Revoke one of your own keys.

Revoking is a hard delete. The key stops authenticating on the next request — nothing is cached in front of the authorizer, and the row the presented secret would have matched is gone — and it is gone from every listing and from the table at the same moment. There is no half state and nothing left to read back, which is why the answer carries no body.

  • The secret is unrecoverable, so a revoked key is not a key that can be brought back: an integration that lost its credential needs a new one, not this one restored.
  • Revoking a key twice answers 404 the second time. There is no row left to tell an already-revoked key from one that never existed, and nothing about which ids exist is a stranger’s business either.

Try it

DELETE https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/me/api-keys/01JQ8Z6K4M7N9P2R5T8V1W3X6Y

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Parameters

keyIdstring
Required
The key to revoke.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/me/api-keys/01JQ8Z6K4M7N9P2R5T8V1W3X6Y" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X DELETE

Response

204 No Content — no body. The status is the whole answer.

GET/organizations/{orgId}/api-keys
Signed-in session

The organization’s live keys, whoever made them.

An admin’s list. Keys outlive the integrations they were made for and often the people who made them, so the question “who still has access to this?” has to be answerable by somebody other than the person holding the key. Revoking deletes a key, so this list is exactly the access that is still live — which is what makes it comparable against the people who should still have it.

Try it

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/organizations/01JQ8Y2A1B3C4D5E6F7G8H9J0K/api-keys

Parameters

orgIdstring
Required
The organization. The caller must be one of its admins.
limitinteger
How many to return in one page. Defaults to 20, and never more than 100.
nextTokenstring
The page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent.

Request body

None. The endpoint is addressed entirely by its path and query string.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/organizations/01JQ8Y2A1B3C4D5E6F7G8H9J0K/api-keys" \
  -H "Authorization: Bearer $PLAY_TOKEN"

Response

200 OK · application/json
{
  "keys": [
    {
      "keyId": "01JQ8Z6K4M7N9P2R5T8V1W3X6Y",
      "name": "Nightly reporting",
      "prefix": "play_sk_9f2c1a4b",
      "userId": "8f14e45f-ea6c-4f2b-9d3a-1c2b3a4d5e6f",
      "userEmail": "dana@northwind.example",
      "createdAt": 1772582400000,
      "lastUsedAt": 1772668800000,
      "organizationId": "01JQ8Y2A1B3C4D5E6F7G8H9J0K",
      "organizationName": "Northwind Learning"
    }
  ],
  "nextToken": null
}
keysarray
The organization’s keys, newest first.
keys[].keyIdstring
ULID. The key’s public name — what revokes it, and what a support question quotes.
keys[].namestring
What the key is for, as its owner named it.
keys[].prefixstring
The opening characters of the secret, `play_sk_…`. Enough to tell two keys apart, not enough to use one.
keys[].createdAtnumber
Epoch milliseconds.
keys[].lastUsedAtnumber?
When it was last presented, accurate to about five minutes. Absent until it is used.
keys[].organizationIdstring?
The organization it was made for, when its creator named one.
keys[].organizationNamestring?
That organization’s name, so a list needs no second call.
keys[].userIdstring
Cognito `sub` of the person who made the key — which is who it acts as.
keys[].userEmailstring?
Their email at the time, so an admin list can be read by a person.
DELETE/organizations/{orgId}/api-keys/{keyId}
Signed-in session

Revoke one of the organization’s keys.

The same hard delete as the one above, performed by an admin on a key somebody else made. The key has to belong to this organization rather than merely exist: an admin of one organization is nobody’s admin in the next.

  • A key made for a different organization answers 404, the same answer an id that does not exist gets.

Try it

DELETE https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/organizations/01JQ8Y2A1B3C4D5E6F7G8H9J0K/api-keys/01JQ8Z6K4M7N9P2R5T8V1W3X6Y

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Parameters

orgIdstring
Required
The organization the key was made for.
keyIdstring
Required
The key to revoke.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/organizations/01JQ8Y2A1B3C4D5E6F7G8H9J0K/api-keys/01JQ8Z6K4M7N9P2R5T8V1W3X6Y" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X DELETE

Response

204 No Content — no body. The status is the whole answer.

OAuth: acting as somebody

The flow a third-party app uses to act as one of our people — with that person’s permission, and only as far as the scopes they agreed to. An app registers first (see the group below), then sends people here.

GET{studio}/oauth/authorize
A person’s browser

Send a person here to sign in and grant permission.

Not an API call — this is a page in the studio that a *browser* is sent to, which is the one part of the flow an integration does not make itself. The app builds the URL, opens it, and waits for the browser to come back to its redirect URI with a `code`. The page draws the consent screen: what the app is, what it is asking for, and who is signed in.

  • The browser comes back to redirect_uri?code=…&state=…, or with error=access_denied&state=… if the person pressed Cancel. Both are answers — do not treat a refusal as a hang.
  • The code is single-use and lives 60 seconds. Exchange it immediately; do not store it.
  • If the client id or the redirect URI is wrong, the studio draws an error page and nothing is redirected anywhere. That is deliberate: forwarding an error to a URI that has not been verified is how an authorization server becomes an open redirector.

Parameters

client_idstring
Required
The app’s client id, from the studio.
redirect_uristring
Required
Where to send the browser back to. Must match one of the app’s registered URIs **exactly** — no wildcards, no prefix matching. A mismatch is an error page, never a redirect.
response_typestring
Required
Always `code`. The implicit flow is not implemented and not coming.
scopestring
Space-delimited, and optional: leaving it off asks for everything the app is registered for. Asking for a scope the app is not registered for fails the whole request rather than being quietly trimmed.
statestring
Opaque, echoed back on the redirect verbatim. Use it: it is what ties the browser that comes back to the request that sent it, and it is the only defence against a login-CSRF that this flow has.
code_challengestring
Required
`base64url(sha256(code_verifier))`, unpadded. Required of **every** client, public or not.
code_challenge_methodstring
Required
Always `S256`. `plain` is refused: a challenge sent in the clear proves nothing.

Request body

None. The endpoint is addressed entirely by its path and query string.

Request

The URL your client opens
https://<your-studio>/oauth/authorize?client_id=play_app_7c1d9e2f4a6b8c0d\
  &redirect_uri=https://example.com/auth/play/callback\
  &response_type=code\
  &scope=profile:read courses:read\
  &state=a1b2c3d4\
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\
  &code_challenge_method=S256

Built by your client, with its own client id, its own redirect URI, its own state and a code_challenge derived from a verifier it keeps. The values here are the documented examples.

Response

302 Found · back to your redirect_uri — no body. The status is the whole answer.

POST/oauth/token
client_id + client_secret

Exchange a code for tokens — or a refresh token for a new pair.

The call an app’s *server* makes. One endpoint, two grant types: `authorization_code` turns the code the browser brought back into an access token and a refresh token, and `refresh_token` turns a refresh token into a new pair when the access token expires. There is no `client_credentials` grant.

  • Client authentication is client_secret_basic (an Authorization: Basic header, with both halves percent-encoded) or client_secret_post (the two fields in this body). Both work; libraries differ.
  • Errors come back in the OAuth shape — {"error":"invalid_grant","error_description":"…"} — not in this API’s {error:{code,message}} shape, because that is what OAuth client libraries parse.
  • Every response here is Cache-Control: no-store. A token response is a credential.
  • The old refresh token stops working the moment it is spent. If you lose the response to a refresh, you have lost the connection and the person has to authorize the app again.

Request body

grant_typestring
Required
`authorization_code` or `refresh_token`.
codestring
The code from the redirect. Required for `authorization_code`.
code_verifierstring
The verifier the challenge was derived from. Required for `authorization_code`, and never sent anywhere before this call.
redirect_uristring
The same URI the code was issued for. Required for `authorization_code`, and required to match.
refresh_tokenstring
The refresh token to spend. Required for `refresh_token`.
scopestring
On a refresh only, and only to ask for **less** than the grant holds.
client_idstring
Required
The app’s client id. May go here, or in an HTTP Basic header with the secret.
client_secretstring
Required for a confidential client. Absent for a public one, which authenticates with `client_id` and PKCE alone.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/token" \
  -d "client_id=$PLAY_CLIENT_ID" \
  -d "client_secret=$PLAY_CLIENT_SECRET" \
  -X POST \
  -d "grant_type=authorization_code" \
  -d "code=play_ac_9f2c1a4b7d8e0f1a2b3c4d5e" \
  -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
  -d "redirect_uri=https://example.com/auth/play/callback" \
  -d "refresh_token=play_rt_3a1b9c8d7e6f5a4b3c2d1e0f" \
  -d "scope=courses:read" \
  -d "client_id=play_app_7c1d9e2f4a6b8c0d" \
  -d "client_secret=play_cs_5e4d3c2b1a0f9e8d7c6b5a4d"

Response

200 OK · application/json
{
  "access_token": "play_at_8c7b6a5d4e3f2a1b0c9d8e7f6a5b4c3d",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "play_rt_1f2e3d4c5b6a7988a9b0c1d2e3f4a5b6",
  "scope": "profile:read courses:read"
}
access_tokenstring
Present this as `Authorization: Bearer …` on every `/v1` call. Lives **one hour**.
token_typestring
Always `Bearer`.
expires_ininteger
Seconds until the access token expires. 3600.
refresh_tokenstring
Lives **30 days**, and rotates: redeeming it deletes it and issues a new one. Store the new one every time.
scopestring
What the tokens actually hold — always present, even when it equals what you asked for, so a misconfigured app is visible rather than mysterious.
POST/oauth/revoke
client_id + client_secret

Hand a credential back.

What an app calls when it is done with somebody — they removed their account from the app, or the app is being shut down. Revoking a **refresh** token takes the access tokens issued with it as well, so "I gave it back" is true immediately rather than in an hour.

  • The answer is 200 whatever happens — an unknown token, a token belonging to another app, one already revoked. A 404 would make this endpoint a way to ask "is this string a token".
  • This is not how a *person* disconnects an app. That is the Connected apps screen, and it ends the authorization itself rather than only its credentials.

Request body

tokenstring
Required
The access or refresh token to revoke.
token_type_hintstring
`access_token` or `refresh_token`. Advisory: this service looks the token up and knows what it is.
client_idstring
Required
The app’s client id.
client_secretstring
Required for a confidential client.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/revoke" \
  -d "client_id=$PLAY_CLIENT_ID" \
  -d "client_secret=$PLAY_CLIENT_SECRET" \
  -X POST \
  -d "token=play_rt_1f2e3d4c5b6a7988a9b0c1d2e3f4a5b6" \
  -d "token_type_hint=refresh_token" \
  -d "client_id=play_app_7c1d9e2f4a6b8c0d" \
  -d "client_secret=play_cs_5e4d3c2b1a0f9e8d7c6b5a4d"

Response

200 OK · application/json
{}

Managing OAuth apps

Registering the client, and reading back what it has been allowed to do. All of these take a signed-in session rather than key — an app is somebody’s, and the studio is where it is managed. The screens that drive them are OAuth apps and Connected apps; this is what they call.

GET/oauth/apps
Signed-in session

The apps this account has registered.

Newest first, and never paged: the number of apps one person may register is capped at the same number they may hold keys — twenty-five — so a list that stopped short would be a list the owner cannot check their own allowance against.

Try it

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps" \
  -H "Authorization: Bearer $PLAY_TOKEN"

Response

200 OK · application/json
{
  "apps": [
    {
      "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y",
      "clientId": "play_app_7c1d9e2f4a6b8c0d",
      "name": "Team dashboard",
      "description": "Shows your team's courses and progress in one place.",
      "homepageUrl": "https://example.com",
      "redirectUris": ["https://example.com/auth/play/callback"],
      "scopes": ["profile:read", "courses:read", "lessons:read"],
      "isPublic": false,
      "clientSecretPrefix": "play_cs_5e4d3c2b",
      "createdAt": 1772582400000,
      "updatedAt": 1772582400000
    }
  ]
}
appsarray
The caller’s apps, newest first.
apps[].appIdstring
ULID. What addresses the app in the studio, and in the paths above.
apps[].clientIdstring
The public half of the credential, `play_app_…`. Sent to the authorization page and to the token endpoint.
apps[].namestring
What the app is called on the consent screen.
apps[].descriptionstring
The sentence under the name on the consent screen.
apps[].homepageUrlstring?
Where the app lives, linked from the consent screen.
apps[].logoUrlstring?
The app’s mark on the consent screen and the connections screen.
apps[].redirectUrisarray
Where the app may be sent back to, matched exactly. `https` anywhere, `http` only on localhost, or a native app’s own scheme.
apps[].scopesarray
The most the app may ever ask a person for.
apps[].isPublicboolean
True when the app has no secret and authenticates with PKCE alone.
apps[].clientSecretPrefixstring?
The opening characters of the secret, `play_cs_…`. Absent on a public client, which has none.
apps[].createdAtnumber
Epoch milliseconds.
apps[].updatedAtnumber
Epoch milliseconds. Moves when any setting does.
POST/oauth/apps
Signed-in session

Register an app, and get its credentials.

Any signed-in person may register an app. Registering one grants nobody anything: the app acts as the people who authorize it, so what it can reach is decided by a consent screen and a grant, not by who registered it. The response carries the client secret exactly once.

  • The secret is stored as a SHA-256 hash. Nothing — not support, not an admin — can read one back, so "copy it now" is the shape of that dialog rather than a nicety.
  • Registering a public client is the right answer for anything that ships to a machine somebody else controls. A secret inside a browser bundle is not a secret, and a screen that handed one over would teach its author that their app is authenticated when anything holding the string is.

Try it

POST https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Request body

namestring
Required
2–60 characters. Shown on the consent screen.
descriptionstring
Required
Up to 280 characters, and required: a consent screen that names an app and explains nothing is not consent.
homepageUrlstring
Optional, and shown on the consent screen with the app’s name.
logoUrlstring
Optional. The app’s mark on the consent screen, and on the connections screen of everybody who authorized it.
redirectUrisarray
Required
One to ten absolute URIs, matched exactly at authorization time. `https` anywhere, plain `http` only on localhost, or a native app’s own scheme. No fragments.
scopesarray
Required
The most this app may ever ask a person for. At least one, and a subset of the catalogue below.
isPublicboolean
True for a browser, desktop or CLI app: no secret is minted at all, and the client authenticates with PKCE alone. Defaults to false.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Team dashboard",
    "description": "Shows your team's courses and progress in one place.",
    "homepageUrl": "https://example.com",
    "logoUrl": "https://example.com/logo.png",
    "redirectUris": "[\"https://example.com/auth/play/callback\"]",
    "scopes": "[\"profile:read\",\"courses:read\",\"lessons:read\"]",
    "isPublic": "false"
  }'

Response

201 Created · application/json
{
  "app": {
    "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y",
    "clientId": "play_app_7c1d9e2f4a6b8c0d",
    "name": "Team dashboard",
    "description": "Shows your team's courses and progress in one place.",
    "redirectUris": ["https://example.com/auth/play/callback"],
    "scopes": ["profile:read", "courses:read", "lessons:read"],
    "isPublic": false,
    "clientSecretPrefix": "play_cs_5e4d3c2b",
    "createdAt": 1772582400000,
    "updatedAt": 1772582400000
  },
  "secret": "play_cs_5e4d3c2b1a0f9e8d7c6b5a4d3e2f1a0b"
}
appobject
The app as its owner sees it.
app.appIdstring
ULID. What addresses the app in the studio, and in the paths above.
app.clientIdstring
The public half of the credential, `play_app_…`. Sent to the authorization page and to the token endpoint.
app.namestring
What the app is called on the consent screen.
app.descriptionstring
The sentence under the name on the consent screen.
app.homepageUrlstring?
Where the app lives, linked from the consent screen.
app.logoUrlstring?
The app’s mark on the consent screen and the connections screen.
app.redirectUrisarray
Where the app may be sent back to, matched exactly. `https` anywhere, `http` only on localhost, or a native app’s own scheme.
app.scopesarray
The most the app may ever ask a person for.
app.isPublicboolean
True when the app has no secret and authenticates with PKCE alone.
app.clientSecretPrefixstring?
The opening characters of the secret, `play_cs_…`. Absent on a public client, which has none.
app.createdAtnumber
Epoch milliseconds.
app.updatedAtnumber
Epoch milliseconds. Moves when any setting does.
secretstring?
The client secret, and the only time it is ever transmitted. Absent on a public client.
GET/oauth/apps/{appId}
Signed-in session

One app, in full.

The app’s own settings page: every redirect URI, every scope it is registered for, its client id, and the prefix of its secret.

  • Somebody else’s app answers 404, the same answer an id that does not exist gets.

Try it

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y

Parameters

appIdstring
Required
The app’s ULID — not its client id. The two are different strings, and this path wants the shorter one.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y" \
  -H "Authorization: Bearer $PLAY_TOKEN"

Response

200 OK · application/json
{
  "app": {
    "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y",
    "clientId": "play_app_7c1d9e2f4a6b8c0d",
    "name": "Team dashboard",
    "description": "Shows your team's courses and progress in one place.",
    "redirectUris": ["https://example.com/auth/play/callback"],
    "scopes": ["profile:read", "courses:read", "lessons:read"],
    "isPublic": false,
    "clientSecretPrefix": "play_cs_5e4d3c2b",
    "createdAt": 1772582400000,
    "updatedAt": 1772582400000
  }
}
PATCH/oauth/apps/{appId}
Signed-in session

Edit an app.

Only the fields sent are written, and `null` clears an optional one. Changing what the app is *called* or where it is sent back to touches nobody’s connection; changing the **scopes** ends every authorization of the app, because a person agreed to a list printed on a screen and a changed list has to be agreed to again.

  • Whether a client can keep a secret is not editable. It is a fact about where the code runs, decided when the app is registered: flipping it later would either invent a secret into a running browser app or take one away from a deployed server.

Try it

PATCH https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Parameters

appIdstring
Required
The app’s ULID.

Request body

namestring
A new name.
descriptionstring
A new sentence for the consent screen.
homepageUrlstring?
`null` clears it; absent leaves it alone.
logoUrlstring?
`null` clears it; absent leaves it alone.
redirectUrisarray
The whole list, not a change to it. Removing one breaks that flow and disconnects nobody.
scopesarray
The whole list. Changing it disconnects everybody who has connected the app.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X PATCH \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Team dashboard",
    "description": "Courses, progress and certificates in one place.",
    "homepageUrl": "https://example.com",
    "logoUrl": "https://example.com/logo.png",
    "redirectUris": "[\"https://example.com/auth/play/callback\"]",
    "scopes": "[\"profile:read\",\"courses:read\"]"
  }'

Response

200 OK · application/json
{
  "app": {
    "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y",
    "clientId": "play_app_7c1d9e2f4a6b8c0d",
    "name": "Team dashboard",
    "description": "Courses, progress and certificates in one place.",
    "redirectUris": ["https://example.com/auth/play/callback"],
    "scopes": ["profile:read", "courses:read"],
    "isPublic": false,
    "clientSecretPrefix": "play_cs_5e4d3c2b",
    "createdAt": 1772582400000,
    "updatedAt": 1772680000000
  },
  "authorizationsEnded": 3
}
appobject
The app as it now stands.
authorizationsEndedinteger?
How many people were disconnected by a scope change. Present only when the scopes changed.
POST/oauth/apps/{appId}/secret
Signed-in session

Replace an app’s client secret.

Rotation rather than addition: an app has one secret, and an app that thinks its secret leaked wants the old one to stop working. The previous secret is refused the moment this returns, so the app has to be redeployed with the new value to keep authenticating.

  • A public client answers 400: it has no secret to rotate, and it is told so rather than being handed one.
  • People who have connected the app are unaffected. A client secret is what the app authenticates *itself* with; their grants and tokens are theirs.

Try it

POST https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y/secret

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Parameters

appIdstring
Required
The app’s ULID.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y/secret" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X POST

Response

200 OK · application/json
{
  "app": { "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y", "clientSecretPrefix": "play_cs_9a8b7c6d" },
  "secret": "play_cs_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d"
}
appobject
The app, with its new secret prefix.
secretstring
The new secret, shown once.
DELETE/oauth/apps/{appId}
Signed-in session

Delete an app, and end everything it was given.

A hard delete that goes further than the row: every authorization of the app is ended and every token those produced is deleted first, so nothing the client was given still works. An app deleted before its tokens would leave credentials behind that authenticate calls, point at no client, and appear on somebody’s connections screen under a name that no longer exists.

  • Everybody who connected the app is disconnected, and the app is not told: its next call is refused, which is how it finds out.

Try it

DELETE https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Parameters

appIdstring
Required
The app’s ULID.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/apps/01JQ9B7M5N8P1Q4R7T0V3W6X9Y" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X DELETE

Response

204 No Content — no body. The status is the whole answer.

GET/oauth/connections
Signed-in session

What this account has let in.

Every app this person has authorized, what each one was allowed, when they connected it and when it last did anything. The apps are read live rather than denormalized onto the grant: an app can rename itself at any moment, and somebody deciding whether to keep a connection needs to see what the app *is* rather than what it was called on the day they authorized it.

Try it

GET https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/connections

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/connections" \
  -H "Authorization: Bearer $PLAY_TOKEN"

Response

200 OK · application/json
{
  "connections": [
    {
      "appId": "01JQ9B7M5N8P1Q4R7T0V3W6X9Y",
      "clientId": "play_app_7c1d9e2f4a6b8c0d",
      "name": "Team dashboard",
      "description": "Shows your team's courses and progress in one place.",
      "scopes": ["profile:read", "courses:read"],
      "createdAt": 1772582400000,
      "updatedAt": 1772582400000,
      "lastUsedAt": 1772668800000
    }
  ]
}
connectionsarray
The apps this person has authorized, most recently agreed to first.
connections[].scopesarray
What this person agreed to — which may be less than the app is registered for, if its registration narrowed after they consented.
connections[].lastUsedAtnumber?
When the app last used the grant, accurate to about five minutes. Absent until it has.
DELETE/oauth/connections/{appId}
Signed-in session

Disconnect an app from this account.

The person’s own revoke, and the one that has to work while nobody is looking: it deletes the grant and every token the app holds for this account, so the app stops being able to call this API on its next request rather than within the hour its access token would have expired. Nothing is cached in front of the authorizer, which is what makes that true.

  • The app is not told, and there is no webhook: an app finds out by being refused, which is how it finds out that an access token expired too. A callback would mean this service making an outbound request to a URL a client chose.
  • An app this person has not authorized answers 404, the same answer an unknown app id gets.

Try it

DELETE https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/connections/01JQ9B7M5N8P1Q4R7T0V3W6X9Y

This one changes something, and there is no undo. The values above are the reference’s examples, so most untouched sends land on records that do not exist — but read what you are sending anyway.

Parameters

appIdstring
Required
The app to disconnect — its ULID.

Request body

None. The endpoint is addressed entirely by its path.

Request

cURL
curl "https://3fi4pdbpzj.execute-api.us-east-1.amazonaws.com/test/oauth/connections/01JQ9B7M5N8P1Q4R7T0V3W6X9Y" \
  -H "Authorization: Bearer $PLAY_TOKEN" \
  -X DELETE

Response

204 No Content — no body. The status is the whole answer.

Errors

Anything the endpoint itself refuses comes back as JSON with the status in both places a client looks: the response code, and a body it can print.

403 Forbidden · application/json
{
  "error": {
    "code": 403,
    "message": "This API key was not made for that organization"
  }
}
400
A query parameter or a request body the API could not accept — a `query` longer than 80 characters, a non-numeric limit, malformed JSON.
401
No credential, or one that does not authenticate: an unknown or revoked key, an expired or spent access token, a malformed one. A `/v1` route answers this itself — the credential is read from whichever header carries it — so the body is this API’s own shape rather than the gateway’s. A method that takes a *session* — everything that mints or revokes a credential — answers 401 the same way when called without one.
403
A credential that is valid and is not allowed *this*: **missing a scope** the route needs (`This credential is missing the lessons:stream scope`), a key not made for the organization being asked about, or a caller who is not one of that organization’s admins. The scope refusal names the scope on purpose: a scope is not a secret, and an integration that has run out of permission needs to know which permission to ask its user for.
404
No such course, or a course that exists but has not been published. No such key — which includes one belonging to somebody else, and one already revoked, because revocation deletes it.
409
The account already holds the maximum number of keys.
500
Something failed on our side. The request can be retried.

Conventions

Four things that are true of every endpoint here.

Paging
Lists come back with a nextToken. Pass it back as the nextToken query parameter to read the next page, and stop when it is absent. It is opaque — it encodes the last row read, so changing it does not mean what you might hope.
Timestamps
Every time in this API is epoch milliseconds in UTC, as a number. Nothing is a formatted date string, because a client that has to parse one has to guess a locale.
Publication and access
Two different questions. The catalog holds the courses their authors published, where an unpublished one answers 404 so that it never reports which ids exist in private. The lesson endpoints are authorized by access instead: they answer for any course the key may read, published or not, and answer 403 for one it may not.
Versioning
Everything is under /v1. Within a version, changes are additive — a new field, a new endpoint — and nothing that exists today is renamed or removed.
Cross-origin
Every response carries Access-Control-Allow-Origin: *, the ones API Gateway produces before a function runs included — which is what lets this page’s Send buttons read a 401 or a 403 instead of it arriving as an unexplained network failure. The API is readable from any origin by design; the key is what limits it.