Freezing an API contract without freezing the data: shape snapshots instead of value snapshots
You renamed a field in the JSON. Whose integration did you just break? Nobody knows until a client tells you.
The CognitionDesk public API is consumed by the chat widget, booking pages and storefronts running on other people's sites. Until September those routes had no version, and no test looked at the shape of their responses: if someone renamed a field, the PR went green and the widget embedded on a client's site stopped reading it.
We fixed it with three pieces: a /v1 prefix, a version header on every response, and tests that freeze the shape of the responses, not their values.
1. A shape snapshot
A classic snapshot stores the whole response. The trouble is that responses carry dates, ids and text that change on their own, so the snapshot breaks all the time and the team learns to regenerate it with -u without reading the diff. A test that gets updated without looking protects nothing.
What an integrator cares about isn't the values, it's the keys and their types. This function reduces a response to exactly that:
export function forma(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return v.length ? [forma(v[0])] : ['(vacío)'];
if (typeof v === 'object') {
const o = {};
for (const k of Object.keys(v).sort()) o[k] = forma(v[k]);
return o;
}
return typeof v;
}
forma({ a: 1, b: [{ c: 'x' }], d: null });
// → { a: 'number', b: [{ c: 'string' }], d: 'null' }
(forma is Spanish for "shape"; the code is as it lives in our repo.) Two decisions that aren't obvious at first glance:
- Keys are sorted. Reordering an object literal in the code isn't a contract change and shouldn't turn anything red.
- An array is described by its first element. That's why each test's fixtures build homogeneous lists on purpose.
Each test requests the route twice, with /v1 and without a prefix, and requires both responses to be equal before looking at the shape:
/** Requests `/v1<route>` and `<route>`, requires them to match, returns the body. */
async function pedirEnLasDos(ruta) {
const [conV1, sinV] = await Promise.all([fetch(`${base}/v1${ruta}`), fetch(`${base}${ruta}`)]);
expect(conV1.headers.get(VERSION_HEADER)).toBe('1');
expect(sinV.headers.get(VERSION_HEADER)).toBe('1');
expect(conV1.status).toBe(sinV.status);
const [a, b] = await Promise.all([conV1.json(), sinV.json()]);
expect(a).toEqual(b);
return { status: conV1.status, body: a };
}
it('GET /public/booking/:slug', async () => {
const { status, body } = await pedirEnLasDos('/public/booking/consultorio-demo');
expect(status).toBe(200);
expect(forma(body)).toMatchSnapshot();
});
And the committed snapshot is readable: it's the list of what a client can read.
"config": {
"assistantId": "string",
"botName": "string",
"openByDefault": "boolean",
"panelSize": "string",
"primaryColor": "string",
...
}
This snapshot only changes when someone adds a field, removes one or changes its type. In other words, only when it's time to ask who that affects.
2. What the 18 tests cover
- Public widget config, booking page and storefront: the shape of the success JSON and the shape of the 404.
- Chat stream: there's no single JSON to reduce, so the contract is the framing. Every line is
data: {type, data}, the last event isdata: [DONE], and if something fails midway the last event is{type: "error", data: {message}}, never a stray JSON body after the headers. - Version:
/v1/<route>answers the same as/<route>, the header is always present, and a version that doesn't exist returns 404.
Before calling them done, we watched them fail: we renamed phone in the booking page response and changed the stream's [DONE]. Both changes turned the check red. If you've never seen a contract test fail, you don't know whether it tests anything.
3. Freeze the odd parts too
Writing the tests surfaced two things that were already like that in production:
- The booking page availability adds
cached: trueonly when the response comes from cache. Same route, two shapes. The test requests twice (a miss and a hit) and freezes exactly that:cachedis the only allowed difference between the two. - The widget config 404 is
{ error }, while the booking page 404 is{ success, error }.
We didn't fix them in the same change, on purpose. The contract's job is to describe what clients already receive today. "Fixing" an odd shape is a contract change like any other, and it gets its own PR, looking at who it affects.
4. The /v1 goes at the root
$ curl -si https://api.cognitiondesk.com/v1/health | grep -i version
x-cognitiondesk-version: 1
$ curl -s https://api.cognitiondesk.com/v2/health
{"success":false,"error":"unsupported_api_version","message":"API v2 no existe. Versiones: v1."}
/v1/<route> is an alias of /<route>: a middleware strips the prefix from the URL before CORS and the router, so rate limits, auth and CORS see the same route as always. Unversioned routes keep working as v1, and nothing broke on deploy day. Every response, versioned or not, carries X-CognitionDesk-Version, so a client can tell which contract it talked to.
The first idea was to put the version inside each base: /public/booking/v1/:slug. That route took the page away from any business whose slug was v1. Nothing is mounted at the root under v1, and it's one rule for every surface.
An incompatible change will be v2, with v1 kept alive for at least 90 days.
At CognitionDesk
The chat widget already talks to /v1 by default (@cognitiondesk/widget 1.9.0). If you integrate against the public API, use the prefix and check the header. If your integration needs a different guarantee, write to us through the chat on this page.
Comments and corrections
Build your first AI agent today
RAG over your documents, tools, workflows and a web widget — free tier included.
Get started free