PlayNotes API

Audio recording, transcription, and speaker identification

Authentication

All endpoints (except OTP login and shared transcripts) require a Bearer token:

Authorization: Bearer po_...

Get an API key by signing in with your email via the OTP flow below.

Auth

POST/auth/otp/request
Send a one-time verification code to an email address.
Request & Response
Request body
{
  "email": string,         // required
  "device": string        // optional, default "api" — "ios" | "mac" | "api"
}
Response 202
{
  "message": "Code sent"
}
Errors
429 — Rate limited (1 request per 2 min per email/device)
POST/auth/otp/verify
Verify OTP code. Returns an API key. Creates account on first use.
Request & Response
Request body
{
  "email": string,         // required
  "code": string,          // required — 6-digit code
  "device": string        // optional, default "api" — must match the request call
}
Response 200
{
  "api_key": string,       // "po_..." — save this
  "created": boolean      // true if a new account was created
}
Errors
401 — Invalid code or too many attempts (max 5)
404 — No pending code — request a new one
POST/auth/key/rotate
Replace the current API key with a new one. Old key is revoked immediately.
Request & Response
Response 200
{
  "api_key": string       // new key
}
POST/auth/keys/revoke-all
Revoke all API keys except the one used to make this request.
Request & Response
Response 200
{
  "revoked": integer      // number of keys revoked
}

Account

GET/me
Get current user profile, settings, and active API keys.
Response
Response 200
{
  "id": string,
  "email": string,
  "settings": {
    "email_transcripts": boolean | null
  },
  "created_at": string,    // ISO 8601
  "active_keys": [
    {
      "id": string,
      "name": string,       // e.g. "ios-26-04-02-15-30-00"
      "created_at": string
    }
  ]
}
PATCH/me/settings
Update user settings.
Request & Response
Request body
{
  "email_transcripts": boolean  // optional
}
Response 200
{
  "email_transcripts": boolean | null
}

Recordings

Single-file upload

POST/recordings/upload
Upload a complete audio file for transcription. Max 500MB.
Request & Response
Request body multipart/form-data
audio: file               // required — M4A, WAV, MP3, FLAC, OGG
source: string             // required — "mac_system" | "mac_mic" | "ios_mic"
recorded_at: string        // required — ISO 8601 datetime
timezone: string           // required — IANA timezone
idempotency_key: string   // required — UUID, unique per user
duration_seconds: float   // optional
Response 202
{
  "recording_id": string,
  "status": "queued",
  "source": string,
  "duration_seconds": float | null,
  "timezone": string,
  "recorded_at": string,
  "created_at": string,
  "title": null,
  "description": null
}
Errors
200 — Idempotent hit (same idempotency_key returns cached result)
413 — File too large (max 500MB)
422 — Invalid source or audio format

Chunked upload

POST/recordings/start
Start a chunked recording session.
Request & Response
Request body application/x-www-form-urlencoded
source: string             // required — "mac_system" | "mac_mic" | "ios_mic"
recorded_at: string        // required — ISO 8601
timezone: string           // required — IANA timezone
idempotency_key: string   // required — UUID
Response 201
{
  "recording_id": string,
  "status": "uploading",
  ...                        // same shape as upload response
}
POST/recordings/{id}/chunks
Upload one chunk. Max 50MB per chunk.
Request & Response
Request body multipart/form-data
index: integer            // required — chunk sequence number
file: file                // required — audio/mp4
Response 200
{}
Errors
409 — Recording not in "uploading" status
413 — Chunk too large (max 50MB)
POST/recordings/{id}/finalize
Finalize a chunked upload and start processing.
Request & Response
Request body application/x-www-form-urlencoded
confirmed_chunks: integer // required — number of chunks uploaded
duration_seconds: float   // required — total duration
Response 200
{
  "recording_id": string,
  "status": "queued",
  ...
}

Query

GET/recordings
List recordings. Cursor-paginated, newest first.
Request & Response
Query parameters
cursor: string            // optional — pagination cursor from previous response
limit: integer            // optional, default 20, max 100
status: string            // optional — filter by status
speaker_id: string        // optional — filter recordings containing speaker
Response 200
{
  "recordings": [
    {
      "id": string,
      "status": string,
      "source": string,
      "duration_seconds": float | null,
      "timezone": string,
      "recorded_at": string,
      "created_at": string,
      "title": string | null,
      "description": string | null
    }
  ],
  "next_cursor": string | null
}
GET/recordings/{id}
Get a recording with its full transcript.
Response
Response 200
{
  "id": string,
  "status": string,
  "source": string,
  "duration_seconds": float | null,
  "timezone": string,
  "recorded_at": string,
  "created_at": string,
  "title": string | null,
  "description": string | null,
  "transcript": [
    {
      "speaker_id": string | null,
      "speaker_name": string | null,
      "start": float,          // seconds
      "end": float,            // seconds
      "text": string
    }
  ]
}
GET/recordings/{id}/transcript
Get transcript only.
Request & Response
Query parameters
format: string            // optional, default "json" — "json" | "text"
Response 200 (json)
{
  "transcript": [ ... ]    // same shape as above
}
Response 200 (text)
[0] Speaker 1: First segment text
[1] Speaker 2: Second segment text

Delete

DELETE/recordings/{id}/pending
Abandon a recording still in uploading status. Deletes chunks and record.
Response
Response 204 — No content
Errors
409 — Recording not in "uploading" status
DELETE/recordings/{id}
Delete a recording, its audio from storage, and all associated data.
Response
Response 204 — No content

Speakers

GET/speakers
List all identified speakers for your account.
Response
Response 200
[
  {
    "id": string,
    "name": string | null,
    "name_is_user_set": boolean,
    "sample_count": integer,
    "created_at": string
  }
]
GET/speakers/{id}
Get a speaker with their recording count and IDs.
Response
Response 200
{
  "id": string,
  "name": string | null,
  "name_is_user_set": boolean,
  "sample_count": integer,
  "created_at": string,
  "recording_count": integer,
  "recording_ids": [string]
}
PATCH/speakers/{id}
Rename a speaker.
Request & Response
Request body
{
  "name": string           // required
}
Response 200 — Updated speaker object
DELETE/speakers/{id}
Soft-delete a speaker.
Response
Response 204 — No content

Sharing

POST/recordings/{id}/share
Generate a public share link for a recording. Idempotent.
Response
Response 200
{
  "share_url": string       // public URL
}
GET/recordings/{id}/share
Get the existing share URL for a recording.
Response
Response 200
{
  "share_url": string
}
Errors
404 — No active share link
DELETE/recordings/{id}/share
Revoke a share link.
Response
Response 204 — No content
GET/share/{token}
View a shared transcript. No authentication required.
Response
Response 200
{
  "recorded_at": string,    // local timezone
  "duration_seconds": float | null,
  "timezone": string,
  "transcript": [
    {
      "speaker_id": string | null,
      "speaker_name": string | null,
      "start": float,
      "end": float,
      "text": string
    }
  ]
}

Webhooks

Receive HTTP callbacks when recordings are processed. One webhook per account. Events: recording.complete, recording.empty, recording.failed.

Payloads are signed with X-PlayNotes-Signature: sha256=<hex> (HMAC-SHA256 of the JSON body using your webhook secret).

POST/webhooks
Set your webhook URL. Replaces existing if one is already configured.
Request & Response
Request body
{
  "url": string            // required — https:// URL, no private/loopback
}
Response 201
{
  "id": string,
  "url": string,
  "secret": string         // only returned on creation — save this
}
GET/webhooks
Get your configured webhook.
Response
Response 200
{
  "id": string,
  "url": string,
  "created_at": string
}
// or null if no webhook configured
DELETE/webhooks
Delete your webhook.
Response
Response 204 — No content
GET/webhooks/{id}/deliveries
List delivery attempts for your webhook.
Response
Response 200
[
  {
    "id": string,
    "recording_id": string | null,
    "event": string,
    "status": string,          // "pending" | "delivered" | "failed"
    "attempts": integer,
    "last_attempted_at": string | null
  }
]

Events (SSE)

GET/events
Server-Sent Events stream for real-time notifications. Heartbeats every 30s.
Events
Event format
event: recording.complete
data: {"recording_id": "rec_..."}

event: recording.empty
data: {"recording_id": "rec_..."}

event: recording.failed
data: {"recording_id": "rec_..."}

Credits

GET/credits/balance
Get current credit balance and auto top-up settings.
Response
Response 200
{
  "balance_cents": integer,
  "auto_topup_enabled": boolean,
  "auto_topup_threshold_cents": integer | null,
  "auto_topup_amount_cents": integer | null
}
POST/credits/purchase
Start a Stripe checkout session to purchase credits. Minimum $3.99.
Request & Response
Request body
{
  "amount_cents": integer  // required — min 399
}
Response 200
{
  "checkout_url": string  // Stripe Checkout URL — redirect user here
}
PATCH/credits/auto-topup
Configure automatic credit top-up.
Request & Response
Request body
{
  "enabled": boolean,               // required
  "threshold_cents": integer,       // required if enabled — min 100
  "amount_cents": integer          // required if enabled — min 399
}
Response 200
{
  "auto_topup_enabled": boolean,
  "auto_topup_threshold_cents": integer | null,
  "auto_topup_amount_cents": integer | null
}
GET/credits/transactions
List credit transactions. Cursor-paginated.
Request & Response
Query parameters
cursor: string            // optional
limit: integer            // optional, default 20, max 100
Response 200
{
  "transactions": [
    {
      "id": string,
      "amount_cents": integer,
      "balance_after_cents": integer,
      "type": string,           // "purchase" | "usage" | "admin_grant" | "auto_topup"
      "recording_id": string | null,
      "description": string | null,
      "created_at": string
    }
  ],
  "next_cursor": string | null
}

Recording statuses

Audio sources

Rate limits

Supported audio formats

M4A, WAV, MP3, FLAC, OGG. Max 500MB per file, 50MB per chunk.