TheirStory API Guide
Overview
TheirStory provides a web-based interviewing and storytelling platform that helps families and organizations collect and make accessible audiovisual oral histories. This guide details how to interact with the TheirStory HTTP API to authenticate, read user and organization data, and access stories, transcripts, and published media.
This document covers the read API. Write operations (creating projects, uploading recordings, editing stories) are not yet publicly documented; contact us if you need them.
Base URL
The API base URL is https://node.theirstory.io. All examples below use this base URL. The API is served over HTTPS only.
Authentication
The API uses JSON Web Tokens (JWTs). You obtain a token by calling POST /signin with email and password credentials, and you include the token on every subsequent request.
Authorization header format (important)
The
Authorizationheader on this API does not use theBearerscheme. Send the raw JWT as the header value, with noBearerprefix. This differs from the convention described in RFC 6750 and from the default behavior of most API testing tools (Postman, Insomnia,curlexamples copied from other APIs) and many client libraries.
Correct:
Authorization: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Incorrect (will return 403):
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
If you receive 403 Forbidden on every call despite having a valid token, this is the first thing to check. We plan to add support for the standard Bearer scheme; until then, configure your tooling accordingly.
Postman: Set the Authorization type to "API Key", with the key set to Authorization, the value set to the raw token (not "Bearer Token"), and "Add to" set to "Header".
curl:
curl -H "Authorization: $TS_TOKEN" \
"https://node.theirstory.io/current_user?visitorId=..."
Token storage in browser clients
If you are calling this API from server-side code, store the token in your server's secret store (environment variables, a secrets manager, etc.) and never send it to the browser.
If you are calling this API directly from a browser, you have three options, all with tradeoffs:
| Option | XSS-readable? | Survives page refresh? | Notes |
|---|---|---|---|
| In-memory | No | No | Safest, but the user must sign in again on every reload. |
sessionStorage |
Yes | No (cleared on tab close) | Convenient; vulnerable to XSS. |
localStorage |
Yes | Yes | Most convenient; vulnerable to XSS. This is what the sample code below uses. |
The sample code below uses localStorage for simplicity. This is a convenience tradeoff, not a security recommendation. Any script running on your page — including third-party tags, dependencies, or extensions — can read localStorage and exfiltrate the token. For production browser integrations, the safest pattern is a backend-for-frontend (BFF): your server holds the TheirStory token, and your browser code authenticates against your own server with a session cookie. The browser never sees the TheirStory token.
We are tracking work to support HttpOnly cookie-based session auth, which would give browser clients a genuinely XSS-resistant option directly against this API.
Token lifetime and refresh
Tokens are JWTs signed by the API. Expiry is set on the token itself; decode the JWT payload to read the exp claim if you need to know when to re-authenticate. There is currently no dedicated refresh endpoint — to obtain a new token, call POST /signin again. Plan for this in any long-lived integration.
Conventions
Request and response format
- All request and response bodies are JSON. Send
Content-Type: application/jsonon requests with a body. - The
Accept: application/jsonheader is recommended on all requests. - All endpoints return JSON unless explicitly noted (e.g. the HTML transcript endpoint).
- Timestamps are ISO 8601 strings in UTC (e.g.
2024-01-15T14:26:14.086Z).
Field naming
Field names are inconsistent across the API: some use snake_case (first_name, organizational_code), others use camelCase (maxProjectsAllowed, backgroundUrl). Treat field names as case-sensitive and refer to the per-endpoint documentation for the exact spelling. Normalizing field naming is on our backlog.
Internal fields
Several responses include fields prefixed with an underscore (_id) or named __v. These are storage-layer internals that we currently expose for backwards compatibility. Treat them as read-only and avoid relying on __v in your application logic; we may remove or stop populating it in the future.
Pagination
Only GET /stories currently supports pagination, via page and pageSize query parameters. Other list endpoints (/projects, /organization) return all records the caller has access to. If you expect a large number of records, please contact us before integrating so we can advise.
API endpoints
Sign in
Authenticates a user and returns user information along with an access token.
Request body
{
"email": "user@example.com",
"password": "yourpassword"
}
Response
A JSON object containing a JWT token and detailed information about the authenticated user.
Response fields
| Field | Type | Description |
|---|---|---|
token | string | JWT authentication token. Send as the raw Authorization header value on subsequent requests — see Authorization header format. |
full_name | string | User's full name. |
user_id | string | Unique identifier for the user. |
role | string | User's role (e.g. admin, member). |
email | string | User's email address. |
user_details | object | Detailed user profile. See below. |
organization_info | object | User's primary organization. See below. |
api_version | string | Version of the API that served the response. |
create_gallery | boolean | Whether this user may create galleries. |
user_details contains:
| Field | Type | Description |
|---|---|---|
id | number | Numeric user ID. |
email | string | User's email. |
first_name | string | User's first name. |
last_name | string | User's last name. |
photo_file_name | string | null | Filename of the user's photo, if any. |
photo_content_type | string | null | MIME type of the user's photo, if any. |
organizations | array | Organizations the user belongs to. |
Each entry in organizations contains id, name, subdomain, custom_domain, description (HTML), and addresses[].
organization_info contains organization-level settings and quotas. See the Organization endpoint for the field list — the same shape is used here.
Example response
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.AbCdEfGh",
"full_name": "John Doe",
"user_id": "1234567890abcdef",
"role": "admin",
"email": "user@example.com",
"user_details": {
"id": 12345,
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"photo_file_name": null,
"photo_content_type": null,
"organizations": [
{
"id": 67,
"name": "Example Organization",
"subdomain": "example",
"custom_domain": "",
"description": "Example Organization is a platform that helps collect and preserve stories through interviews.",
"addresses": [
{
"address_line_1": "123 Main Street",
"address_line_2": "",
"city": "Anytown",
"state": "NY",
"zipcode": "12345",
"country": "US"
}
]
}
]
},
"organization_info": {
"_id": "abcdef1234567890",
"organization_name": "Example Admin",
"organizational_code": "example_admin_123456",
"permanent_org": {
"export_allowed": true,
"archive_nbr": "01ab-2345"
},
"settings": {
"transcription_service": "cloudconvert",
"filler_words": false,
"vocabulary": [],
"auto_chaptering": true,
"summary_prompt": "Create a concise summary of this interview focusing on key topics discussed.",
"chaptering_prompt": "Create an index for this interview with timecodes, titles, summaries, and keywords."
},
"branding": false,
"activity": true,
"org_manager_view_all": true,
"export_allowed": true,
"require_session_passwords": false,
"local_recordings_allowed": true,
"allow_dial_in": true,
"local_recordings_polyfill": false,
"groups": ["group123", "group456", "group789"],
"credits": 1000000,
"maxProjectsAllowed": 200,
"projects": 150,
"recording_hours": 320,
"backgroundUrl": "uploads/projectbackgrounds/example-background-image.jpg",
"created_at": "2023-01-15T12:30:45.678Z",
"__v": 5
},
"api_version": "1.2.3",
"create_gallery": true
}
Example code
const response = await fetch('https://node.theirstory.io/signin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
// See "Token storage in browser clients" for tradeoffs.
// localStorage is convenient but XSS-readable.
localStorage.setItem('authToken', data.token);
Note on
export_allowedvsaviary_export_allowed: Some organizations useaviary_export_allowedin place ofexport_allowedto indicate permission to export to Aviary specifically. If both are present, treataviary_export_allowedas the more specific flag. Both fields are returned as-is and we are working to consolidate them.
Current user information
Retrieves information about the currently authenticated user.
Query parameters
| Parameter | Required | Description |
|---|---|---|
visitorId |
yes | The visitor ID associated with this session. The value is embedded in your JWT's visitor-id claim — decode the JWT payload to retrieve it, or persist the value your client generated at sign-in. |
Example request
GET /current_user?visitorId=7fe32a19-84c5-4921-b517-29d7c8e54f3a
Authorization: <jwt>
Response
Same shape as the Sign in response, including a refreshed token. The user may belong to multiple organizations; user_details.organizations is an array.
Example response (abbreviated)
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"full_name": "Jane Smith",
"user_id": "847629394863",
"role": "admin",
"email": "jane.smith@example.com",
"user_details": {
"id": 12345,
"email": "jane.smith@example.com",
"first_name": "Jane",
"last_name": "Smith",
"organizations": [
{ "id": 78, "name": "Community Archives Project", "subdomain": "communityarchives" },
{ "id": 92, "name": "Metro Historical Society", "subdomain": "metrohistory" }
]
},
"organization_info": { "...": "see Sign in response" },
"api_version": "1.2.3",
"create_gallery": true
}
Projects
Retrieves projects the authenticated user has access to.
Response
An array of project objects.
Response fields
| Field | Type | Description |
|---|---|---|
_id | string | Unique identifier for the project. |
name | string | Project name. |
description | string | Project description. |
creator | string | User ID of the project's creator. |
backgroundUrl | string | Storage path to the background image. |
projectBackgroundUrl | string | Fully resolved URL to the background image. |
displayTitle | boolean | Whether to display the project title in the UI. |
defaultProjectForOrganization | boolean | Whether this is the organization's default project. |
organization | string | Organization ID that owns the project. |
organizationName | string | Name of the owning organization. |
view_only_code | string | Invitation code for view-only access. |
view_only_code_created_at | string | When the view-only code was created (ISO 8601). |
member_code | string | Invitation code for member access. |
member_code_created_at | string | When the member code was created. |
project_manager_code | string | Invitation code for project manager access. |
project_manager_code_created_at | string | When the project manager code was created. |
folders | array | Folders in the project. See below. |
storiesInProject | number | Total stories in the project. |
isManager | boolean | Whether the authenticated user manages this project. |
created_at | string | When the project was created. |
__v | number | Internal version (see Internal fields). |
Each folder contains: _id, name, creator, project, defaultFolderForProject, created_at, __v, and an fu array of folder-user relationships. Each fu entry contains: _id, user, accepted, creator, folder, created_at, __v.
Invitation codes are sensitive.
view_only_code,member_code, andproject_manager_codegrant access to the project. Treat them like passwords: don't log them, don't surface them in URLs you share publicly, and don't include them in browser-visible code unless the user is supposed to be sharing them.
Example response
[
{
"_id": "proj123456789",
"name": "Community Archives",
"description": "Archive of local community stories and interviews",
"creator": "user987654321",
"backgroundUrl": "uploads/projectbackgrounds/community-archives-bg.jpg",
"displayTitle": true,
"defaultProjectForOrganization": false,
"organization": "org123456789",
"organizationName": "Community Archives Organization",
"view_only_code": "aBcD1234EfGh5i6",
"view_only_code_created_at": "2024-01-15T14:26:58.682Z",
"member_code": "jKlM7890nOpQ1r2",
"member_code_created_at": "2024-01-15T14:26:58.987Z",
"project_manager_code": "sTuV3456wXyZ7a8",
"project_manager_code_created_at": "2024-10-15T14:30:21.837Z",
"projectBackgroundUrl": "https://storage.example.com/uploads/projectbackgrounds/community-archives-bg.jpg?token=abc123",
"folders": [
{
"_id": "fold123456789",
"name": "Senior Interviews",
"creator": "user987654321",
"project": "proj123456789",
"defaultFolderForProject": false,
"created_at": "2024-01-20T12:57:05.461Z",
"__v": 0,
"fu": [
{
"_id": "foldu123456789",
"user": "user987654321",
"accepted": true,
"creator": true,
"folder": "fold123456789",
"created_at": "2024-01-20T12:57:05.484Z",
"__v": 0
}
]
}
],
"storiesInProject": 32,
"isManager": true,
"created_at": "2024-01-15T14:26:14.086Z",
"__v": 0
}
]
Organization
Retrieves the organizations the authenticated user has access to.
Response
An object containing a single organizations array.
Response fields
Each organization in the array contains:
| Field | Type | Description |
|---|---|---|
_id | string | Unique identifier for the organization. |
organization_name | string | Display name. |
organizational_code | string | Unique slug-style code. |
activity | boolean | Whether the organization is active. |
org_manager_view_all | boolean | Whether org managers can view all projects. |
export_allowed | boolean | General export permission. |
aviary_export_allowed | boolean | Aviary-specific export permission (when present). |
require_session_passwords | boolean | Whether recording sessions require passwords. |
local_recordings_allowed | boolean | Whether local recordings are permitted. |
local_recordings_polyfill | boolean | Whether the local-recording polyfill is enabled. |
allow_dial_in | boolean | Whether dial-in recording is enabled. |
branding | boolean | Whether custom branding is enabled. |
groups | array | IDs of groups in the organization. |
permanent_org | object | Permanence details (export_allowed, archive_nbr). |
settings | object | Transcription/AI settings; see below. |
users | array | Users in the organization; see below. |
credits | number | Available credits. |
maxProjectsAllowed | number | Project quota. |
projects | number | Current project count. |
recording_hours | number | Recording-hour quota. |
backgroundUrl | string | Storage path to the organization's background image. |
created_at | string | Creation timestamp. |
__v | number | Internal version. |
settings contains:
| Field | Type | Description |
|---|---|---|
transcription_service | string | Transcription provider (e.g. cloudconvert). |
filler_words | boolean | Whether filler words are kept in transcripts. |
vocabulary | array | Custom vocabulary terms for transcription. |
auto_chaptering | boolean | Whether automatic chaptering is enabled. |
summary_prompt | string | LLM prompt used for summary generation. |
chaptering_prompt | string | LLM prompt used for chapter generation. |
Each user in users contains: _id, is_verified, role, stories, full_name, email, organization, signed_up, __v, create_gallery, and featureFlags (an object with enableTwilioVideo, localRecordingMimeType, disableAutomaticResolutionSwitching, autoChaptering, _id).
Example response (abbreviated)
{
"organizations": [
{
"_id": "org123456789",
"organization_name": "Community Archives Project",
"organizational_code": "cap_admin_2023",
"activity": true,
"org_manager_view_all": true,
"aviary_export_allowed": true,
"require_session_passwords": false,
"groups": ["group1234", "group5678", "group9012"],
"permanent_org": { "export_allowed": true, "archive_nbr": "07kp-2341" },
"settings": {
"transcription_service": "cloudconvert",
"filler_words": false,
"vocabulary": ["oral history", "archives", "cultural heritage"],
"auto_chaptering": true,
"summary_prompt": "Create a concise summary...",
"chaptering_prompt": "Divide this interview into logical chapters..."
},
"users": [
{
"_id": "user123456",
"is_verified": false,
"role": "admin",
"full_name": "Emma Johnson",
"email": "emma.johnson@example.com",
"organization": "org123456789",
"signed_up": "2023-05-12T15:32:47.821Z",
"create_gallery": true,
"featureFlags": {
"enableTwilioVideo": true,
"localRecordingMimeType": "video/webm;codecs=vp8,opus",
"disableAutomaticResolutionSwitching": false,
"autoChaptering": true,
"_id": "flag123456"
},
"__v": 0
}
],
"credits": 1750000,
"maxProjectsAllowed": 300,
"projects": 187,
"recording_hours": 428,
"backgroundUrl": "uploads/projectbackgrounds/community-archives-bg.jpg",
"branding": true,
"created_at": "2023-04-15T14:27:42.519Z",
"__v": 5
}
]
}
Stories
Retrieves stories (recordings) accessible to the authenticated user, with pagination.
Query parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
page | no | 1 | Page number (1-indexed). |
pageSize | no | 15 | Items per page. |
Response fields
| Field | Type | Description |
|---|---|---|
page | number | Current page number. |
total | number | Total stories matching the request. |
items | array | Story objects on this page; see below. |
Each story object contains at minimum: _id, title, record_date (ISO 8601), duration (seconds), author, indexes (chapter metadata), and asyncOperations (processing history). The full per-story shape matches the story field in the transcript JSON response — use that as the canonical reference.
Example response
{
"page": 1,
"total": 234,
"items": [
{
"_id": "def456ghi789jkl012",
"title": "Example Recording",
"record_date": "2025-01-27T16:44:51.289Z",
"duration": 107,
"author": { "id": 12345, "first_name": "John", "last_name": "Doe" },
"indexes": [],
"asyncOperations": []
}
]
}
Get story transcript JSON
Retrieves a story's transcript in JSON format, including word-level timing and story metadata.
Path parameters
| Parameter | Description |
|---|---|
storyId | The unique identifier of the story. |
Response fields
| Field | Type | Description |
|---|---|---|
transcript | object | Transcript content and metadata; see below. |
story | object | Story metadata. |
videoURL | string | Time-limited URL to the video recording. |
participants | array | Participants in the recording. |
tags | array | Tags applied to the recording. |
transcript contains:
| Field | Type | Description |
|---|---|---|
_id | string | Transcript identifier. |
status | string | completed, processing, failed. |
storyId | string | Story this transcript belongs to. |
words | array | Word-level entries: { start, end, text } in seconds. |
paragraphs | array | Paragraph-level entries: { start, end, speaker }. |
created_at | string | When the transcript was created. |
updated_at | string | When the transcript was last updated. |
videoURLis short-lived and signed. Don't cache or share it. Re-request the transcript to get a fresh URL.
Example response (abbreviated)
{
"transcript": {
"_id": "abc123def456ghi789",
"status": "completed",
"storyId": "def456ghi789jkl012",
"words": [
{ "start": 1.12, "end": 1.44, "text": "Super" },
{ "start": 1.44, "end": 1.80, "text": "quickly." }
],
"paragraphs": [
{ "start": 0, "end": 105.6, "speaker": "SPEAKER_S1" }
],
"created_at": "2025-01-27T16:46:48.850Z",
"updated_at": "2025-01-27T16:46:48.850Z"
},
"story": {
"_id": "def456ghi789jkl012",
"title": "Example Recording",
"record_date": "2025-01-27T16:44:51.289Z",
"duration": 107
},
"videoURL": "https://example.s3.amazonaws.com/uploads/example-id/transcoded.mp4?token=example",
"participants": [],
"tags": []
}
Get story transcript HTML
Retrieves a story's transcript as HTML with timing attributes, suitable for use with Hyperaudio Lite and similar interactive-transcript libraries.
Note: this endpoint is under
/stories/...while the JSON variant above is under/transcripts/.... This is a historical inconsistency we plan to resolve; both will continue to work.
Response
An HTML document. Each word is wrapped in a <span> with timing data attributes:
| Attribute | Description |
|---|---|
data-m | Start time of the word in milliseconds. |
data-d | Duration of the word in milliseconds. |
class="speaker" | Marks a speaker label. |
Example response
<article>
<section>
<p>
<span data-m="0" data-d="0" class="speaker">SPEAKER_S1: </span>
<span data-m="1120" data-d="320">Super </span>
<span data-m="1440" data-d="360">quickly. </span>
<span data-m="1800" data-d="360">Then </span>
...
</p>
</section>
</article>
Groups
This endpoint is documented as available but its response shape is not yet finalized. Contact us if you need to integrate against it; we will update this section once the response is stable.
Publishing
The publishing endpoints create non-expiring media assets on our Mux-backed CDN, useful when you need stable URLs to embed in external systems.
Publish
Request body
{ "format": "video" }
format accepts "video" or "audio".
Response
{ "url": "https://stream.mux.com/{uid}/highest.mp4" }
For audio:
{ "url": "https://stream.mux.com/{uid}/audio.m4a" }
Assets may take up to a minute to be created. The URL is returned immediately but may 404 or stream a placeholder until processing completes. There is currently no status endpoint or webhook; if you need to verify availability, poll the returned URL with a
HEADrequest until it succeeds. We recommend a poll interval of 5–10 seconds and a total timeout of 2 minutes.
Unpublish
Request body
{ "format": "video" }
Response
{
"message": "video deleted",
"url": "https://stream.mux.com/{uid}/highest.mp4"
}
Example code
const response = await fetch(
`https://node.theirstory.io/stories/${storyId}/publish_media`,
{
method: 'POST',
headers: {
'Authorization': token, // raw JWT, no "Bearer" prefix
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ format: 'video' })
}
);
if (!response.ok) {
throw new Error(`Publish failed: ${response.status}`);
}
const { url } = await response.json();
Error handling
The API uses standard HTTP status codes. Error responses are JSON objects of the shape:
{
"error": "short_error_code",
"message": "Human-readable description of what went wrong."
}
| Status | Meaning | Common causes |
|---|---|---|
| 400 | Bad Request | Malformed JSON, missing required parameters. |
| 401 | Unauthorized | Missing, expired, or invalid token. |
| 403 | Forbidden | Valid token but insufficient permissions. Also returned if the Authorization header includes Bearer — see Authorization header format. |
| 404 | Not Found | Resource does not exist or the caller cannot see it. |
| 429 | Too Many Requests | Rate limit exceeded. See Rate limits. |
| 500 | Internal Server Error | Unexpected error. Retry with backoff and contact support if it persists. |
Rate limits
The API enforces per-token rate limits. Exceeding the limit returns 429 Too Many Requests. We do not currently publish specific limits; design your integration to back off on 429 and retry after a delay of at least 1 second, doubling on repeated failures. Contact us if your integration needs sustained high throughput.
Security considerations
- Authorization header format is non-standard on this API. See Authorization header format. This is the most common cause of
403errors for new integrators. - Don't put tokens where untrusted JavaScript can read them. Any script on a page where you've stored a token in
localStorageorsessionStoragecan read and exfiltrate it. For browser integrations, prefer the BFF pattern (your server holds the TheirStory token; your browser talks to your server) and treatlocalStoragestorage as an explicit convenience tradeoff. See Token storage in browser clients. - Use HTTPS for all requests. The API only accepts HTTPS connections.
- Invitation codes are credentials.
view_only_code,member_code, andproject_manager_codegrant project access; don't leak them in logs, analytics, or public URLs. - Signed asset URLs expire.
videoURLand similar signed URLs in responses are short-lived. Don't cache them and don't share them; re-fetch the parent resource to get a fresh URL. - Apply least privilege. Use the role with the minimum permissions needed for your integration.
- Handle errors carefully. Don't surface raw API error messages to end users; log them server-side and show generic messages to users.
Versioning
The API URL is not versioned. Responses include an api_version field that reflects the server's deployed version (a semantic version string). Breaking changes are communicated through release notes; subscribe to repository updates or contact support to be notified.
Backwards-compatible changes (new fields, new endpoints) may be added without notice. Treat your response parsing accordingly — ignore fields you don't recognize rather than failing.
Support
- Issues with this documentation: open an issue at https://github.com/theirstory/theirstory-api/issues.
- API access requests and integration questions: contact TheirStory support.
- Embedded iframe examples: iframe-examples.html.