API versioning and changes
What can change without notice, what cannot, and how a change to the API updates its own documentation.
The version is a path segment
The version today is v1 and it sits in the URL path. It is the same on both hosts: https://in.segmentic.net/v1/batch and https://api.segmentic.net/v1/whoami.
The version is kept nowhere else. No version header is read, no custom Accept is read, no version query parameter is read, and there is no date-based revisioning. If you send a version anywhere other than the path, it is silently ignored.
The reason is simple: a path segment is visible in a log, in an error report and in a proxy configuration. A header is not, and it gets lost in transit. When you are chasing a failed request at midnight, the path is the one thing that is everywhere.
There is no /api segment in the path. The correct path is /v1/events on the host api.segmentic.net, not /api/v1/events. An address carrying that extra segment gets a 404 with the code unknown_endpoint, and the message names the method and path you sent.
Only one version has ever been published. There is no v2 and no date has been announced for one.
The house rule for changing the version is one sentence: the version changes only when the shape of a response changes in a way an existing integration would notice. The test is not the size of the change on our side. The test is whether code that worked yesterday still works today. Rewriting an internal subsystem entirely, while the response keeps its shape, does not change the version. Removing one small key does.
What counts as breaking
These are breaking and are not done without a version change:
- removing a key from a response
- changing the type of a key, for instance from a string to a number or from a scalar to an object
- removing an endpoint or moving its path
- making a field mandatory that was optional yesterday
- narrowing the set of accepted values for an input
- changing the meaning of a key while its name and type stay where they are
- changing the default of a parameter so that the output differs
- changing the status code for a case that already existed
The most dangerous item on that list is the sixth. Removing a key fails loudly and you find out the same day. Changing the meaning of a key fails quietly and may surface in your reporting weeks later. That is why we treat it as the equal of a removal.
When we want to give a fuller answer, the fuller answer goes under a new key beside the old one and the old key's shape is not touched. Suppose count is a number and it later becomes necessary to return the breakdown behind it. What we do not do is turn count into an object. What we do is add count_breakdown beside it. count stays the same number, with the same meaning.
Most of the pressure that looks like "we need a new version" is really "we need to return more", and more fits beside the old thing. The long life of v1 is the result of that one rule.
We do not hide what the rule costs. Responses get larger and busier over time and a few keys in them are relics. We have accepted that against breaking a customer's connection. There is an example in the product today: GET /v1/schema/traits still returns traits as a bare array of names, because something out there iterates it as strings, and the fuller answer sits beside it in a second key called schema.
What does not count as breaking
These are done at any time, without notice and without a version change:
- adding a new key to a response
- adding a new optional field to an input
- adding a new endpoint
- adding a new value to the set a field may hold
- adding a new header to a response
- changing the order of keys in a JSON object
- fixing a bug so that the response matches the documentation
- changing response times and internal implementation detail
Two items on that list usually catch integrations out: a new value in an enumerated field, and a new key in a response. Both are non-breaking, so your code has to tolerate both.
A live example from this product: the status of an export today is one of queued, running, ready, failed or expired. If a sixth status is ever added, that is an additive change and it gets no advance notice. Code that switches on those five values with no default branch falls over that day.
Writing a client that survives our ordinary changes
If you implement only one thing from this page, make it this: ignore an unknown key, and send an unknown value to the default branch. The rest of this page says what we do; this section is the only part that is yours.
Concretely:
- in Go, do not turn on
DisallowUnknownFieldsfor our responses - in Java with Jackson, leave
FAIL_ON_UNKNOWN_PROPERTIESoff - in any other language, keep strict response-schema validation off the main path
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
// Only the three fields this program uses. A key we add next month is
// decoded into nothing and the program carries on. A strict decoder would
// return an error instead, and the caller would read that as "the API is
// down" on the day we shipped a harmless addition.
type Whoami struct {
TenantID uint32 `json:"tenant_id"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
func whoami(ctx context.Context, key string) (Whoami, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.segmentic.net/v1/whoami", nil)
if err != nil {
return Whoami{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return Whoami{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Whoami{}, fmt.Errorf("whoami: http %d", res.StatusCode)
}
var out Whoami
// No DisallowUnknownFields here, deliberately.
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return Whoami{}, err
}
return out, nil
}
func main() {
me, err := whoami(context.Background(), os.Getenv("SEGMENTIC_API_KEY"))
if err != nil {
fmt.Println("could not read whoami:", err)
os.Exit(1)
}
fmt.Println(me.Role, me.Permissions)
}
Three other habits make a connection brittle, and none of them is something we can compensate for from our side:
- relying on the order of keys in JSON
- relying on the absence of a key rather than checking its value
- reading a date by slicing the string rather than parsing it
One caveat that causes confusion if it goes unsaid: POST /v1/messages rejects unknown fields on the way in. That does not contradict the rule above, which is about reading our responses. That route is strict about its input because a caller who mistyped idempotency_key would otherwise get a brand new key on every retry and send a message per attempt. No other route on either host rejects an unknown input field.
Capabilities, the runtime answer
The question "can this install do X" has a runtime answer, not a documented one:
curl -s https://api.segmentic.net/v1/capabilities \
-H "Authorization: Bearer sk_seg_..."
{
"version": "v1",
"features": {
"segments": true,
"campaigns": true,
"analytics": true,
"transactional": true,
"export": false,
"import": true,
"journeys": true,
"ingest": true,
"async_exports": true,
"campaign_approval": true
},
"limits": {
"max_page_size": 100,
"max_preview_rows": 100,
"max_batch_size": 500,
"estimate_sample": 100,
"query_timeout_sec": 30
}
}
This is our only forward-compatibility mechanism. Rather than hardcoding the list of capabilities in your own code, ask for it here. A capability added later appears in this response without a version change, and your integration sees it without a code change.
Two Segmentic installs genuinely differ. Most capabilities register their routes only when their configuration exists, so a client that assumed the whole surface would be writing against a fiction. A features.transactional of false means POST /v1/messages answers 404 on that install, not 403.
Limits are published rather than merely documented, so that no client and no agent hardcodes a number we later change. If max_batch_size ever rises above 500, a client that read it from here sends larger batches on its own.
Two notes about it:
- Read this response at start-up and hold it for a while. Reading it before every call is pointless load, and it also spends a unit of your budget.
- This endpoint announces capability, not data shape. It is not version negotiation and it does not change the shape of any other endpoint's response.
And one necessary honesty: two keys in features turn no route on the management host on or off. export and journeys only report that the subsystem is configured; the two export routes are controlled by async_exports rather than by export, and no journey route is registered on this host for journeys to control. ingest and import are the same boolean under two names, the one that registers POST /v1/events. The values in the sample above are one deployment's, not a promise: each flag means "this subsystem is configured here", so read it against your own install. The full table of which key controls which route is in the management API reference.
How you hear about a change
Every breaking change is announced at least six months before it takes effect.
The announcement goes out two ways: a changes page in this documentation, and an email to the technical contact of every account that called that endpoint during the period. So if you do not use a part of the API, you do not get an email about it.
Fixing a security vulnerability can shorten that notice. When it does, the reason and the scope of the change are written into the same announcement.
Neither of those two channels is automated today, and we write that plainly, because not saying it is worse than the gap itself. There is no changes page in this documentation yet. And no field on an account names a technical contact, so nothing in the product picks the recipients of that email. Until both are built, the reliable way to see that something changed is GET /v1/capabilities and this documentation, which ships in the same change as the code.
A change to the policy document itself is announced at least 60 days before it takes effect, and previous versions are archived so it is possible to see what we had committed to on the day an integration was written. The full commitment is in the API versioning policy. If anything on this page conflicts with that document, that document is the commitment and this page is the description.
No sunset header is sent today
No machine-readable deprecation header is sent today. Not Sunset, not Deprecation, not Warning on the response of an endpoint on its way out.
In their place are the two channels above: the changes page and the email. There is no other signal.
We write this down explicitly because the opposite belief is expensive. If somebody writes code assuming the system sends a machine-readable warning before anything is switched off, that warning never arrives and their monitoring never fires. On the day it matters, the first sign is a 404 on a path that worked for years.
If these headers are ever added, that is itself an additive change, it happens without a version change, and this page is updated with it.
How long an old version lives
After a new version ships, the previous one stays alive and answering for at least one year.
During that window the old version gets bug fixes and security fixes only. New capability does not appear on it. New capability only lands on the current version, and that on its own is the reason to migrate.
After the window ends, a call to the retired version's path is refused with 410. It is deliberately not redirected to the new version: silently redirecting an old call to a differently shaped answer is worse than an error. You see an error at once; you may never see malformed data.
Because only v1 has ever shipped, no version has been retired and no route on either host answers 410 today. If you get a 410, it is not from Segmentic; it is from a proxy between you and us.
Documentation ships with the change
Part of this product is the product and part of it describes the product: this API reference, the in-panel help, the landing copy, the privacy policy, the system emails. Descriptions are always downstream of features and are never visible inside the feature's own card.
So this happens: a field gets added to the API, the tests are green, the work looks finished, and at that moment the API reference does not mention the field. Nothing turns red and nobody did anything wrong.
The fourth rule of the Segmentic repository exists for exactly that: a description of the product ships in the same change as the product. It is not a style preference, it is an acceptance condition, alongside the three other rules, which are about security and about being bilingual. The rule's own words are that a description which disagrees with the product is worse than no description, because somebody trusts it and is misled by it.
For the API, the relevant row in that rule's table reads: a new endpoint or field in the API means the API reference and its code sample change in the same commit.
What is checked automatically
Three things are automated. One of them runs before a build, the other two in CI:
- the documentation guard, which runs before every site build and fails it with exit code 1. It is the only one of the three attached to a build.
npm run check:localesin the panel package, which puts every display string against its English twin. That row belongs to rule 2. It is a step inCIand a command you can run yourself; nothing in the panel's build calls it.scripts/check-api-is-documented.mjs, its own step inCI, which is rule 7. It runs in two directions, described below.
The first direction: a route registered on the collector or the management API and missing from that surface's reference turns the build red. Eight routes are exempt, and the script names each one with its reason: the open pixel, the two unsubscribe routes, the two preference-centre routes, the short-link redirect, the bounce intake and the CORS preflight. None of those is a route a customer calls.
The second direction is narrower than it sounds. On any page, a code sample whose request line names in.segmentic.net or api.segmentic.net with a path that host does not serve turns the build red. A request line written without a host is not checked, and that is deliberate: it is the shape the honest pages use to show a call the customer cannot make. Privacy prints POST /v1/privacy/erasures under a sentence saying you cannot issue it with curl, and transactional messages lists the template routes under a warning that the management host serves none of them. A check that failed those would teach everybody to delete the explanation instead of fixing anything.
The documentation guard catches these:
- a page written in only one language. A page that exists in Persian and not in English is unfinished work, not finished work awaiting translation.
- two languages that do not share their anchors. Switching language mid-page has to keep your place, and it only keeps working if something checks.
- an em dash and its two relatives, which is rule 1.
- the marks of machine-written Persian: harakat, a rightward arrow in right-to-left prose, a middle dot, Arabic-Indic digits, Arabic ye and kaf.
- a link to a page or an anchor that does not exist.
- Latin digits in Persian prose, as a warning.
What is not checked automatically
There is no script that catches all of it and we do not pretend there is.
Nothing compares a documented field name against the code. If a field is removed from a response tomorrow and this page still names it, the guard stays green and the build passes. What catches that is a person reading the rule's table from top to bottom before opening a pull request and asking which row this change touches.
So when this documentation disagrees with the API's actual behaviour, the API is right. Tell support about it; a wrong page is exactly the thing this rule was written to not have.
What is true today
The honest summary of the current state, for somebody writing a production integration:
| Thing | Today |
|---|---|
| Published versions | v1 only |
v2 | does not exist, no date announced |
| Where the version lives | a path segment, on both hosts |
| Changes page | not built yet |
| Change-announcement email | no field on an account names a technical contact |
Sunset and Deprecation headers | not sent |
A route that answers 410 | none, because no version has been retired |
| Version negotiation by header | not read |
Three things you can rely on today:
GET /v1/capabilitiesat runtime, to know what this install has and what its ceilings are.- This documentation, which ships with the change under rule 4. The machine-readable description of the same surface is in the OpenAPI document.
- The API versioning policy page, which is our written commitment and whose previous versions are archived.
And one thing not to rely on today: no machine-readable signal arrives before a breaking change. If you are building monitoring, build it on error codes and on GET /v1/status, not on a header that does not come.