The Miles API
Ask Miles in natural language. Process meeting transcripts. Sync contacts and tasks. Subscribe to webhook events. Bearer-token auth, JSON in, JSON out — same brain that powers the Miles app, exposed as a REST API.
Quick start
Three minutes from zero to "what was my last meeting about?"
1. Mint an API key
Open Settings → API Keys in the Miles app, click New key, name it after the integration ("Zapier production", "GPT custom action", etc.), and copy the value once — Miles never shows it again.
Keys are prefixed miles_live_. Treat them like passwords — they grant full read/write access to that user's data. Revoke instantly from the same Settings page.
2. Verify connectivity
# Public — no key needed curl https://api.heymiles.app/api/v1/health # Authenticated — replace with your key curl https://api.heymiles.app/api/v1/me \ -H "Authorization: Bearer miles_live_..."
3. Ask Miles something
curl -X POST https://api.heymiles.app/api/v1/ask \ -H "Authorization: Bearer miles_live_..." \ -H "Content-Type: application/json" \ -d '{"input":"What was my last meeting about?"}'
That's it. Every endpoint below follows the same shape: bearer auth, JSON body, JSON response. No SDK required.
Authentication
All authenticated endpoints expect a bearer token in the Authorization header:
Authorization: Bearer miles_live_<32-character-secret>
Tokens are bcrypt-hashed at rest. Miles only knows the prefix (miles_live_a…) for display. Lose the value? Mint a new key and revoke the old one.
Rate limits
60 requests per minute, per key. Every response carries headers so you can self-throttle:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Allowed per minute (always 60 today) |
X-RateLimit-Remaining | Calls left in the current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait (only on 429) |
Hitting the limit returns HTTP 429. Need more throughput? Mint a second key for the second workload.
Errors
Errors come back as JSON with a stable shape:
{
"error": {
"code": "invalid_request",
"message": "`input` is required and must be a non-empty string."
}
}
| Status | code | When |
|---|---|---|
400 | invalid_request | Bad input. message tells you which field. |
401 | unauthorized / invalid_api_key | Missing, malformed, or revoked key. |
404 | not_found | Resource doesn't exist or isn't yours. |
409 | conflict / batch_rolled_back | The card changed since your ifUpdatedAt — re-read and retry. Or an atomic batch failed at index; nothing was applied. |
412 | gmail_not_connected | Endpoint needs Gmail/Calendar — connect in the Miles app. |
413 | payload_too_large | Bulk endpoint hit its sync cap (100 contacts, 200 ids, 100 batch operations). |
429 | rate_limited | Slow down — see Retry-After. |
500 | internal_error | Something on our side. Retry with backoff. |
Ask Miles
Single endpoint that wraps the entire Miles brain. Lets you ask anything in natural language.
Routes the input through Miles's intent parser and the matching handler — calendar, inbox, tasks, memory, transcripts, and more. Returns a natural-language response plus any structured data Miles surfaced.
Request body
input (string, required) | The question or instruction. |
history (array, optional) | Prior turns, each { role, content }, for multi-turn context. |
documentId (string, optional) | Reference an uploaded document Miles has parsed. |
curl -X POST https://api.heymiles.app/api/v1/ask \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"input":"Find inbox info for my 4pm meeting"}'
{
"intent": "meeting_prep",
"response": "From your inbox, three threads relate to your 4pm with Sarah...",
"data": {
"meetings": [{ "summary": "Pricing review", "time": "4:00 PM - 4:30 PM" }],
"emails": [{ "subject": "Re: Pricing draft", "from": "sarah@…", "snippet": "…" }]
},
"navigation": "/meetings/<id>/prep",
"suggestedFollowups": ["Draft a follow-up", "What's after this meeting?"]
}
Transcripts
Drop a meeting transcript in, get a Fireflies-style structured digest back.
Runs the transcript through Miles's processing pipeline: rich markdown summary (lead + Overview + sectioned notes), action items with priorities, decisions with reasoning, topics, attendees, and a drafted follow-up email. Side-effect: creates Tasks and Action records in the user's app.
meetingTitle (string, required) | Display title. |
rawTranscript (string, required) | The full transcript. Min 20 chars. |
meetingDate (ISO string, optional) | Defaults to now. |
attendees (string[], optional) | Names or emails. |
externalId (string, optional) | Your own id for dedup. |
source (string, optional) | Defaults to "api". |
curl -X POST https://api.heymiles.app/api/v1/transcripts \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{ "meetingTitle": "Vectris Q2 sync", "meetingDate": "2026-05-09T14:00:00Z", "attendees": ["Bill Barry", "Vicki Chen"], "rawTranscript": "Speaker 1: Lets review pricing..." }'
Response
{
"transcriptId": "clx…",
"summary": "Bill and Vicki aligned on Q2…\n\n**Overview**\n- …",
"actionItems": [{ "title": "Send pricing deck by EOD Friday", "priority": "immediate" }],
"decisions": [{ "topic": "Pricing model", "decision": "Per-seat for Q2" }],
"topics": ["pricing", "Q2 launch"],
"tasksCreated": 3,
"memoryRecords": 5,
"followUpDrafted": true
}
List the Recall.ai bot recordings available for grabbing. Each record carries a botId (use it directly with /transcripts/grab for the cheapest match), meeting title/date, current status, and whether it's already been processed.
Pull a transcript from a Recall.ai bot recording (Miles' own bot) and run it through the processing pipeline. Equivalent to the in-app "Grab Transcript" button, exposed for automation. Either pass botId directly (best) or any of meetingTitle, meetingDate, meetingId and Miles fuzzy-matches a recording.
{ "botId": "abc123" }
// — or —
{ "meetingTitle": "Q2 Pricing Call", "meetingDate": "2026-05-09T14:00:00Z" }
Response status values
processed | Transcript ready — payload includes summary, actionItems, decisions, topics, transcriptId, tasksCreated. |
already_processed | Returns the existing transcriptId — fetch full detail via /v1/meetings/:id. |
bot_active | Bot is still in the meeting. Retry after it ends. |
async_transcribing | Recall.ai is generating the transcript (2-5 min). Retry the same call later. |
no_transcript_yet | Recording exists but transcript not built yet. Retry. |
no_recording | Bot finished but no recording exists — bot likely failed to join. |
no_bot (404) | No bot matches the given criteria. |
Meetings
Read past meetings — list, single, "the last one", or delete.
The single most recent processed meeting with full detail. Powers "what was my last meeting about?" use cases without needing to list-then-fetch.
curl https://api.heymiles.app/api/v1/meetings/last \
-H "Authorization: Bearer $KEY"
List processed meetings, most-recent-first. range is past (default) or today. Returns brief previews + counts.
Full detail for one meeting: rich markdown summary, raw transcript, actionItems, decisions, topics, followUpDraft.
Remove a meeting and its derived records (action items, decisions, follow-up draft, memory entries). Returns { deleted: true }.
Delete up to 200 meetings in one call. Body: { "ids": [...] }. Returns { deleted, errors, requested }.
Contacts
Sync contacts in and out of Miles's CRM (Memory → People). Upsert by email, partial updates, and free-form tags.
Create or update a contact. Upserts by (userId, email). Email is optional when name or company is present. tags are trimmed + lowercased (max 20) and merged with existing tags by default — send "tagsMode": "replace" to make your payload authoritative. Every response includes tags: string[].
{
"name": "Sarah Chen",
"email": "sarah@weka.vc",
"company": "Weka Ventures",
"title": "Partner",
"notes": "Met at Demo Day. Lead on early-stage.",
"tags": ["investor", "warm"],
"tagsMode": "merge"
}
Sync up to 100 contacts in one call — each may carry its own tags; a request-level tagsMode applies to the batch. Returns { created, updated, skipped, errors, total }. Perfect for "new row in Sheets → push to Miles" Zapier flows.
{ "contacts": [
{ "name": "Sarah Chen", "email": "sarah@weka.vc", "tags": ["investor"] },
{ "name": "Bill Barry", "email": "bill@modius.com", "title": "CEO", "tags": ["ais6", "speaker"] }
]}
Paginated search. q matches across name / email / company / notes. tag=investor,warm returns contacts carrying all listed tags.
Every tag in use, with contact counts: { tags: [{ tag, count }], count }.
Fetch one contact by id.
Add tags to a contact (idempotent). Body { "tags": ["speaker"] }. Use PUT on the same path to replace the whole set ([] clears), and DELETE /api/v1/contacts/:id/tags/:tag to remove one. All return the updated contact.
Tasks
Push items onto Miles's radar. Mirrors to the in-app Actions page automatically.
Create a task. Required: title. Validated horizon: right_now | short_term | horizon. Validated status: pending | in_progress | completed | cancelled.
{
"title": "Send pricing proposal to Sarah",
"horizon": "right_now",
"dueDate": "2026-05-12",
"description": "Per the Vectris call — needs the per-seat tier laid out."
}
List tasks. Filter by horizon and/or status.
Memory
Stash arbitrary key/value facts that Miles remembers across sessions.
Upsert a memory by (userId, key). Required: category, key, value.
{
"category": "preference",
"key": "preferred_meeting_time",
"value": "Mornings before 11am"
}
Search memory. q matches across key and value.
Delete a memory entry by id.
Decisions
Every decision Miles has captured. Each has topic, decision, context, and the source meeting.
Sprints
Read team sprint boards, and create, move, edit, or delete cards — one at a time or a whole board in one call.
Lists every sprint (active / planned / completed) with task and done counts.
Full detail with stages and tasks. Each task carries stageId, stageName, status, priority, blocked reason, due date, primary assignee, full assignees roster, category, externalId, and updatedAt. Read stage ids here before writing — stages are addressed by id (names aren't unique).
Create a card. Defaults to the first stage. Assign by assigneeIds, assigneeEmails (team members only), and/or assigneeAgentIds. Give it an externalId (your CRM row / sheet id — unique per sprint) and a retried create updates that card instead of duplicating it (200 instead of 201). status is derived from the stage. category is an optional free-text board label (max 40 chars) — the in-app board can be filtered down to one category.
{
"title": "Draft AIS6 sponsor deck",
"stageId": "3f1c…",
"priority": "high",
"dueDate": "2026-09-01",
"assigneeEmails": ["troy@ignitegtm.com"],
"category": "Finance",
"externalId": "crm-42"
}
Edit, move, or reassign a card. Any of title, description, stageId, priority, dueDate, blockedReason, category, externalId, assignee keys (sending any assignee key replaces the whole roster; [] unassigns). Moving to a Done-type stage sets status: "done" + completedAt. Add ifUpdatedAt (the card's last updatedAt) to make the write conditional — you get 409 conflict with the card's current state if someone moved it first. DELETE on the same path removes the card.
Update a whole board in one request: up to 100 ordered create / update / delete operations, each with exactly the granular body above. Give a create a ref and later ops can address that card as "$ref". By default each op commits on its own and failures are isolated per item; "atomic": true makes it all-or-nothing (a failure returns 409 batch_rolled_back with the failing index and cause); "dryRun": true validates everything — refs, stages, assignees, ids — and writes nothing. A malformed op fails the whole batch with 400 + index before anything runs. Only real stage moves emit sprint.stage_changed / task.completed webhooks, after commit. For CSV / CRM sync: put an externalId on every create and re-run the same batch freely — it converges instead of duplicating.
{
"operations": [
{ "op": "create", "ref": "row-1", "data": { "title": "Book venue", "externalId": "sheet-1" } },
{ "op": "update", "taskId": "$row-1", "data": { "stageId": "<in-progress-stage-id>" } },
{ "op": "update", "taskId": "8f0c…", "data": { "priority": "critical" } },
{ "op": "delete", "taskId": "2b1e…" }
],
"atomic": false,
"dryRun": false
}
{
"results": [
{ "index": 0, "op": "create", "ref": "row-1", "ok": true, "created": true, "task": { … } },
{ "index": 1, "op": "update", "taskId": "…", "ok": true, "task": { … } },
{ "index": 3, "op": "delete", "taskId": "…", "ok": false, "error": { "code": "not_found", "message": "Task not found" } }
],
"succeeded": 3, "failed": 1, "total": 4, "atomic": false
}
Copy an existing Miles Action or Task onto the board as a card. Body { "source": "action" | "task", "sourceId", "stageId"? }. Preserves the assignee (human or agent).
Inbox search
Runs a Gmail query against the user's full inbox (no time window). Returns matched threads. Auto-prefixes in:inbox, falls back to recent emails when nothing matches.
Returns 412 if the user hasn't connected Gmail. For an AI-summarized answer instead of raw threads, use /v1/ask.
{ "query": "from:sarah pricing", "limit": 15 }
Briefing
Generates a fresh daily briefing via Claude. Returns the structured briefing (greeting, narrative, attentionItems, suggestedActions) plus the events / emails / pending actions it was composed from.
This is an LLM call — costs a few cents per request and takes 2–4s. Cache downstream if you'll call it more than once per day.
Activity
Activity log feed. Common action values: briefing_generated, transcript_processed, task_created, email_sent, meeting_prepped, action_executed.
Webhooks
Get a POST when something happens in Miles, instead of polling.
Webhooks are configured per-user in Settings → Webhooks. Each one has a URL, a per-webhook signing secret (whsec_…), and a list of subscribed event types.
Every delivery is HMAC-SHA256 signed over {timestamp}.{body}. Verify on your receiver before trusting a request — see the example below.
Delivery shape
Every webhook POST has the same envelope:
POST /your-receiver
Content-Type: application/json
User-Agent: Miles-Webhook/1.0
X-Miles-Timestamp: 1715299200
X-Miles-Signature: sha256=<hex>
{
"id": "01HXY…",
"type": "transcript.processed",
"createdAt": "2026-05-09T20:00:00Z",
"data": { /* event-specific payload */ }
}
Event catalog
| Type | Fires when |
|---|---|
transcript.processed |
A transcript finishes processing — payload includes summary, action items, decisions, topics. |
task.created |
A new task hits the user's radar (API, command bar, transcript, etc.). |
task.completed |
A task moves to completed — including via a sprint stage move. |
sprint.stage_changed |
A task moves between kanban stages on the team Sprints view. |
Signature verification (Node.js)
import crypto from 'crypto'; function verifyMilesWebhook(req, secret) { const sigHeader = req.headers['x-miles-signature']; const ts = req.headers['x-miles-timestamp']; if (!sigHeader || !ts) return false; // Reject anything older than 5 minutes (replay protection) if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const presented = sigHeader.split('=')[1]; const rawBody = req.rawBody; // the unparsed string body — capture before JSON.parse const expected = crypto .createHmac('sha256', secret) .update(`${ts}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(presented, 'hex'), Buffer.from(expected, 'hex'), ); }
Retries & auto-pause
Failed deliveries (network error or non-2xx) are retried with exponential backoff: 1m, 5m, 30m, 2h, 12h. After 5 attempts a delivery is marked permanently failed. After 5 consecutive failures across deliveries, the webhook is auto-paused and you'll see the failure count in Settings → Webhooks.
Test deliveries
Settings → Webhooks → "Send test" fires a synthetic transcript.processed event so you can validate signature handling without waiting for a real meeting.
Ready to build?
Mint your first key in Settings, then make your first call — the /v1/ask endpoint alone is a one-line integration.