API versioning and errors
Read a Steer Phones problem response, branch on its error code, retry safely, and stay compatible as version 1 grows.
A client that survives platform changes does two things: it tolerates additions it did not expect, and it reacts to failures by machine-readable code rather than by reading English. This page covers both — how version 1 is allowed to change, and the exact failure envelope every operation returns.
What version 1 may change
The major version is part of the base URL, https://api.steerphones.com/v1.
Inside version 1, these changes can happen without warning and are not breaking:
- A new operation appears.
- A new field appears on an existing response.
- A new optional field is accepted on an existing request.
- A new error code appears for a condition that previously used a broader one.
So parse leniently: ignore response fields you do not recognize, do not validate responses against a closed schema, and do not treat an unknown error code as a parsing failure — fall back to the HTTP status. A change that would break a conforming client requires a new major version in the URL, published with migration guidance; version 1 is not retired underneath you.
Each published operation also carries a lifecycle stage in its approval record — stable, beta, or deprecated. Every version 1 operation is currently beta. While an operation is beta, Steer Phones may still correct its shape — a renamed field, a tightened type — without a new major version, and does so with notice rather than silently: an email to every integration holding a key, and a release note in this documentation. An operation is promoted to stable once the first integrations have run end-to-end against it; from that point, any change that would break a conforming client requires a new major version in the URL, exactly as above. Depend on stable operations for anything you cannot easily change; treat beta as subject to revision with notice, and deprecated as scheduled for removal.
Beta revisions
Because every version 1 operation is beta, the section above promises that a corrected shape is published rather than changed underneath you. This section is that record. Entries stay here after the change ships, so an integration built earlier can find out what moved.
Transcription status now reports current availability
transcriptionStatus on the call-log detail and voicemail detail operations described what the
last transcription attempt did. It now describes whether transcript text is retrievable right now.
- Before. A call or voicemail whose transcript had been removed — by your retention settings or
by an explicit deletion — kept reading
completed, with its transcript text empty or absent. The status recorded that transcription had once succeeded. - After. The same record reads
unavailableand returns no text.completednow means, and only means, that transcript text is stored and returned by this read. The other three values are unchanged:processingwhile transcription is queued or running,failedwhen an attempt did not succeed, andunavailablewhen there is no text to return.
No field was added, renamed, or removed, and no record whose transcript is still present changes
status. If your integration read completed as proof that a transcript had existed at some point,
read it as a statement about the current response instead — and use unavailable on a record you
previously stored as the signal that the transcript has since been removed. The bulk reporting rows
introduced with this release use the same four values with the same meaning, so one mapping covers
all three operations.
Not every list operation pages the same way
Pagination previously stated, without exception, that every list operation shares the page-number envelope. The call-log reporting collection introduced with this release pages by cursor instead. That statement is now scoped to the standard list operations, and the exception is named where the promise is made. Nothing about the existing list operations changed. See Pagination.
Deleting a tag that retained calls carry
Tag management is a dashboard action rather than an API operation, but it governs data these operations return, so the change is recorded here. A tag that any call in your retained history still carries can no longer be deleted, because that call’s tags are part of its record. The dashboard points you at Inactive instead, which retires the tag without rewriting the calls that carry it and keeps its name reserved in its group.
While this release was rolling out there was a short window in which deleting such a tag failed with a generic error rather than that guidance. If a tag deletion failed unexpectedly during that window, retry it: you will now get either the deletion or the Inactive guidance. See Organize calls with tags and tag groups.
Pagination
The standard list operations page the same way, so one pager works for all of them. One operation family is a deliberate exception, described at the end of this section.
Two request parameters control the page:
page— 1-indexed, defaulting to 1.pageSize— defaulting to 25, with a maximum of 100. A larger value is a validation error, not a silent clamp.
List requests are strict: a query parameter the operation does not document is a validation error. (Responses are the opposite — additive — per the section above.)
Every list response uses one envelope:
{
"data": [],
"total": 1204,
"page": 3,
"pageSize": 25,
"totalPages": 49
}
total and totalPages count the rows matching your filters — for a filtered list they describe
the filtered set, not the whole resource. data holds one page of those rows.
Ordering is deterministic: rows come back newest first, by createdAt descending, with the row
identifier as the tiebreak. Two rows created in the same instant therefore always order the same
way, and the same request over the same data always yields the same pages.
The data underneath can still move. These are live lists ordered newest-first, so a row created while you are walking the pages pushes every older row down one position, and the next page you request can repeat a row you already received. Two rules make that harmless:
- Deduplicate by identifier whenever you page a window that is still receiving rows.
- Backfill inside a closed date window. For a historical import, do not walk from page 1 of the
unbounded list: set
dateToto the moment you start, walk that window to the end, then pick up anything newer with a later pass — or let webhooks deliver it. A window whose upper bound is in the past no longer gains rows, so its pages do not shift.
The reporting collection pages by cursor
GET …/call-log-reports is the documented exception to everything above. It is a bulk extraction
over a time window you bound yourself, and rows can move inside that window while you walk it —
which is the case a page number handles worst, because a row arriving underneath you shifts every
later page. So it pages by cursor:
- Ascending by the timestamp you selected, then by identifier. Oldest first, not newest first.
{data, nextCursor, hasMore}— nototal, nopage, nopageSize, and no page count.limitinstead ofpageSize, and a required half-open[dateFrom, dateTo)window.
Send nextCursor back unchanged until hasMore is false. The full rules — window selection,
cursor validity and restart, duplicate handling, optional transcripts, and the limits these
operations enforce — are in
Use the customer API operations.
The failure envelope
Every documented failure returns application/problem+json (RFC 9457) with a flat body:
| Field | Always present | Meaning |
|---|---|---|
code |
Yes | The stable machine identifier. This is what your code branches on. |
status |
Yes | Mirrors the HTTP status code. |
title |
Yes | Short human summary of the problem type. |
detail |
Yes | A user-safe explanation. Display it if you must; never match on it. |
type |
Yes | A URL identifying the problem type. An identifier to match, not a link. |
correlationId |
Yes | The request identifier to quote to support. |
retryable |
Yes | Whether this condition is transient. Derived from code, never from text. |
retry_after |
No | Seconds to wait before retrying, when a meaningful delay exists. |
errors |
No | Field-level validation problems. Present on validation failures. |
params |
No | Extra primitive detail about the condition, such as a limit that was hit. |
{
"type": "https://errors.steerphones.example/validation-error",
"title": "Validation error",
"status": 400,
"code": "VALIDATION_ERROR",
"detail": "The request contains invalid data.",
"correlationId": "9f3ab21c",
"retryable": false,
"errors": [{ "pointer": "/name", "code": "REQUIRED", "detail": "This field is required." }]
}
detail is user-safe by construction: internal messages, stack traces, and identifiers of other
tenants never reach it. That also means it is a poor branching key — it is written for a person.
Field-level validation errors
A validation failure returns 400 with code: "VALIDATION_ERROR" and an errors array. Each entry
names the offending field by JSON Pointer into the request body, along with a per-field code:
| Field code | Meaning |
|---|---|
REQUIRED |
The field was absent. |
TOO_SMALL |
Below the minimum allowed. |
TOO_BIG |
Above the maximum allowed. |
INVALID_FORMAT |
Present but wrongly formatted. |
INVALID |
Present but not an accepted value. |
Pointers let you highlight the exact input a person got wrong instead of showing a whole-request
failure — /eventTypes points at that member of the body you sent.
Codes worth handling
| Code | Typical status | What to do |
|---|---|---|
VALIDATION_ERROR |
400 | Fix the request. Use errors to say which field. |
AUTH_UNAUTHORIZED, AUTH_INVALID_TOKEN, AUTH_EXPIRED |
401 | Check the key is present, current, and not revoked. |
AUTH_FORBIDDEN |
403 | The key lacks the required scope, or the credential type is wrong for the route — a dashboard sign-in session, or a key that is not a customer integration key. |
NOT_FOUND |
404 | The record — or the phone system in the path — does not exist or is not covered by the key’s grant. The two cases are deliberately indistinguishable. |
CONFLICT |
409 | The request contradicts current state. Re-read, then decide. |
RECORDING_NOT_READY |
409 | The audio exists but is still processing. Retryable — wait briefly, then request the URL again. |
RATE_LIMITED |
429 | Back off and retry after retry_after. |
EXPORT_TOO_LARGE |
413 | The response is too large to deliver, and nothing was truncated. Narrow the request where the operation allows it; follow that operation’s own guidance. |
EXTERNAL_SERVICE_TIMEOUT |
502 or 503 | A dependency timed out. Retry with backoff. |
EXTERNAL_SERVICE_ERROR |
502 or 503 | A dependency is unavailable. Do not hammer it; alert instead. |
INTERNAL_ERROR |
500 | Capture correlationId and contact support. |
Some conflicts carry a more specific code than CONFLICT so a client can react precisely rather
than parsing a message — RECORDING_NOT_READY is one, returned by the signed-URL operation while
audio is still processing. Handle the specific codes you know, and fall back to the status for the
rest.
Retry safely
retryable is computed from the error code, so it never drifts from the message text. Only
genuinely transient conditions — rate limiting, dependency timeouts, and a still-processing
artifact (RECORDING_NOT_READY) — are marked retryable. A validation failure, a permission
failure, or any other conflict will fail identically no matter how often you resend it.
For writes, one more rule applies: do not retry a write unless the published reference for that operation states that retrying is safe. A timeout means the request may have succeeded, so blind retries can duplicate work. Where the reference documents no retry guarantee, re-read the state and decide from there.
Use exponential backoff with jitter, honour retry_after when present, and cap total attempts so a
sustained outage does not become an unbounded queue in your own system.
Rate limits
Requests are counted per API key over a 60-second window. A key is issued on one of two tiers:
| Tier | Allowance |
|---|---|
standard |
120 requests per 60 seconds |
premium |
600 requests per 60 seconds |
Every response carries the current window:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed in the current window. |
X-RateLimit-Remaining |
Requests left in the window. |
X-RateLimit-Reset |
Unix time, in seconds, when the window resets. |
Retry-After |
Seconds to wait. Sent with a 429 response. |
Over the limit, you get 429 with code: "RATE_LIMITED" and a retry_after value. The limit
applies to the key’s traffic as a whole, not to any single operation — which is why the operation
reference does not repeat a 429 entry on each operation. Every operation can return one.
The budget belongs to the key, not to a phone system. A key whose grant covers several phone
systems — a group or organization key — spends that one budget across all of them, so an
integration iterating N systems is dividing a single allowance N ways. Ask for the premium tier
as the default on any multi-system key; standard suits a single-system integration. The tier is
chosen when the key is issued, and Steer Phones can raise it for an integration with a legitimate
need.
Design for the headers rather than a hard-coded number — read X-RateLimit-Remaining and slow
down before you are refused. Spread bulk work over time instead of firing it in parallel bursts,
and back off on 429 rather than retrying immediately.
Two operations carry an extra budget of their own on top of the key’s tier: the call-log reporting collection and its dimensions snapshot are each limited to 120 requests per minute per key. These requests also consume the key’s overall allowance: a standard key has 120 requests per minute across all operations combined. They are also the only operations that refuse rather than continue when the rate limiter itself cannot be consulted — the same 429, for a different reason. Their throughput and recovery are described under Limits and the failures they produce.
When you contact support
Every response carries an X-Request-Id header, and every problem body repeats it as
correlationId. Quote that value, the approximate time, and the operation you called. It identifies
your exact request in Steer Phones’ records without exposing your key or the request body, so it is the
fastest route to an answer — and it is safe to paste into a ticket.