Engineering

Building the Meshora API: Design Decisions We Made (and One We Regret)

API architecture diagram with data flow arrows and endpoint hierarchy

We've been living with the Meshora API for about eighteen months now. Some of the early calls aged well. One of them — the one I'll get to at the end — we'd design differently if we were starting from a blank slate today. This isn't a retrospective in the sense of "here's what went wrong"; it's more a record of the tradeoffs we made explicitly and the one we made without fully thinking it through.

The Job Model: Why We Went Async From Day One

Video indexing is inherently async. A 90-minute file takes 8–14 minutes to process through transcription, visual frame analysis, OCR, speaker diarization, and embedding generation. There is no world where a synchronous HTTP request makes sense for that workload. So the core abstraction of our API is a job: you submit a video URL or upload, get back a job_id, and the job transitions through states: queued → processing → completed | failed.

What we spent more time on was the state model between processing and completed. Early in the design we had two intermediate states: transcribing and analyzing. We removed them before launch for a simple reason: partial states create partial polling contracts, and integrators tend to build business logic around intermediate states in ways that break when the internal pipeline changes. Keeping the external state model to four values gave us room to refactor internally without breaking callers.

This was the right call. We've changed the internal pipeline architecture twice since launch — adding a separate diarization pass, changing how we handle multi-language audio — without any API version bumps.

Polling vs. Webhooks: Supporting Both, Defaulting to Webhooks

We support both polling and webhooks, but we made webhooks the default and we actively nudge integrators toward them. The reason is load: with jobs that take 8–14 minutes, polling at any sensible interval (say, every 30 seconds) generates a lot of GET /jobs/:id calls that return processing until they don't. At scale, that's 10–20 polling requests per job — pure overhead with no informational value.

Webhooks flip the model: we POST to your endpoint exactly once when the job state changes. The tradeoff is reliability — your webhook endpoint needs to be reachable, return a 2xx, and process idempotently. We handle retries on our side: three attempts with exponential backoff, 30s / 2m / 10m, then we mark the delivery failed and log it to the job event history. Integrators can always fall back to polling to recover missed events.

One thing we added later that we should have included at launch: a /jobs/:id/events endpoint that returns the complete delivery history for a job. When an integrator's webhook is misfiring and they don't know why, this is the first place we point them. We shipped it in response to the third support request in the same week — should have anticipated it earlier.

Result Pagination: Cursor-Based, Not Offset

Search results from a Meshora query can be large. A semantic search across a 20,000-hour archive might surface 800 matching segments. We needed pagination that was stable under concurrent writes — new videos being indexed while someone is paginating through results — and cursor-based pagination handles that cleanly where offset-based doesn't.

The implementation: each page response includes a next_cursor token (an opaque string encoding the last result position). The client passes this token as a query parameter on the next request. We don't expose page numbers because page numbers imply a stable ordering that breaks when the index updates mid-pagination.

One caveat we document explicitly: cursors expire after 24 hours. This is a deliberate tradeoff — holding cursor state indefinitely would require us to snapshot index state at query time, which doesn't scale. In practice, nobody is paginating through 800 search results manually over multiple days, so the 24-hour window is more than sufficient.

The Transcript Format: The Decision We'd Change

Here's the one we regret. When we designed the transcript output format in GET /jobs/:id/transcript, we returned segments with word-level timestamps nested directly in the segment object:

{
  "segments": [
    {
      "start_ms": 4200,
      "end_ms": 7800,
      "speaker": "S1",
      "text": "The quarterly results were better than expected.",
      "words": [
        { "word": "The", "start_ms": 4200, "end_ms": 4380 },
        { "word": "quarterly", "start_ms": 4420, "end_ms": 4890 },
        ...
      ]
    }
  ]
}

This seemed fine at the time. The problem emerged when we added confidence scores for ASR output — we needed a place to put per-word confidence, and we added it to the word object. Then we added speaker confidence at the segment level. Then we added language detection confidence. The word object now has four optional fields, the segment object has six, and the response size for a 90-minute video is around 2.8 MB of JSON for word-level output.

What we should have done is separate structure from enrichments. A base transcript endpoint returning only segments and timecodes, then separate enrichment endpoints — or enrichment flags on the request — for word-level detail, speaker diarization, confidence scores, and language metadata. The compound object works but it's not cleanly extensible. We'll fix this in v2, but that means a migration path for integrators who have built parsers around the current shape, which is a real cost.

The lesson isn't "plan for everything" — that's how you end up with an over-engineered API on day one. The lesson is: when you have a clear extensibility dimension (in this case, multiple types of enrichment on the same output), model it explicitly from the start even if you're only shipping two of them.

Authentication: API Keys With Org Scoping

We use API keys, not OAuth, for programmatic access. The decision is pragmatic: the integrators building against our API are primarily backend services — MAM connectors, custom ETL pipelines, NLE plugins — not user-facing applications. OAuth's advantage is delegated user authorization, which is the wrong model when the "user" is a service account.

Keys are scoped to an organization (not a user), and organizations can issue multiple keys with different permission sets: read-only, write (submit jobs), admin (manage webhooks, read billing). Revoking a compromised key doesn't require recreating the whole account, and access logs are tied to the key ID, not just the org, which matters for audit trails.

We're not saying OAuth is wrong for APIs in general — for any API where end-user delegation is a use case, it's the right model. Our use case isn't that.

Rate Limiting: Separate Limits for Submission and Search

We have two separate rate limit buckets: one for job submission (video uploads and URL submissions) and one for search queries. They don't share quota. The reason: a batch ingest job — submitting 500 video URLs overnight — should not degrade search performance for interactive users. Video submission is bursty and asynchronous; search is interactive and latency-sensitive. Coupling them into a single request budget creates an operational headache for anyone running both workflows from the same API key.

Current limits are documented on the API reference page. For teams running high-volume batch ingestion, we support a separate ingest key with a higher submission quota and no search quota, so the two workloads can be managed independently.

The API is still v1 and we're not planning a v2 in the short term — except for the transcript format fix, which we'll introduce as an opt-in parameter before deprecating the current shape. If you're building against it now, the word-level transcript endpoint is the one place to abstract cleanly before v2 lands.

More from the blog