Skip to content
kenari.

Model changes

kenari publishes every change to the model list and its prices: a new model, a model that was removed, a price that moved, a discount that started or ended, a price change on a known date, and a model that is going to retire. You can read these changes on a web page, in a Telegram channel, or from a public endpoint that a script or an agent can poll. All three show the same changes.

kindWhat happened
addedA new model is in the list.
removedA model left the list.
price_changedThe price of a model changed, or it became free or stopped being free.
discount_startedThe market price of a model is now above the kenari price on the input or output side, where it was not before.
discount_endedThe market price is no longer above the kenari price on a side.
price_scheduledA price change was scheduled, with the date it applies.
sunset_scheduledA model was scheduled to retire, with the date.

The market price is the reference price that GET /api/public/pricing shows next to the kenari price. See Models and pricing.

Changes that are not about prices, such as a model becoming subscriber-only, are not published.

A change is published only after kenari is sure it is real. The list is checked every 5 minutes.

kindAppears
price_changed, discount_started, discount_endedAfter the change has held for about 10 minutes.
addedAfter the model has been listed for about an hour.
removedAfter the model has been gone for about an hour.
price_scheduled, sunset_scheduledAt once, because the date is the news.

A change that reverts quickly is never published. A price that is typed wrong and fixed within 10 minutes does not appear, and a model that disappears for a few minutes and comes back is not reported as removed. If a price goes from A to B to C within 10 minutes, the change is published as A to C.

A model that was already announced with sunset_scheduled still gets a removed entry in the feed when it leaves. The Telegram channel does not post it a second time.

  • The page kenari.id/models/changes lists the changes. You do not need to sign in.
  • The Telegram channel t.me/kenari_updates gets the same changes. Posts are in English, with at most one post per check. Each post groups the changes by kind, with prices in Rupiah per 1M tokens and dates in WIB. A price change shows the percent it moved, a discount line shows the market price it is compared with, and a retiring or removed model names its successor when it has one. A post that gets too long is split into more than one message.
  • GET /v1/models/changes returns the changes as JSON.

GET /v1/models/changes

The endpoint is public. It needs no API key, and browsers can call it from any origin (CORS is open). The response can be cached for 30 seconds, so asking more often than that returns the same answer.

ParameterTypeRequiredDescription
sinceintegerNoUnix time in seconds. Return only changes that were confirmed at or after this moment. Defaults to 0, which means no lower bound.
limitintegerNoHow many changes to return, from 1 to 200. Defaults to 50. A value outside the range is moved to the nearest end of it.

The response is a list: {"object": "list", "channel": ..., "data": [...]}.

FieldTypeDescription
objectstringAlways list.
channelstring or nullThe public Telegram handle that gets the same changes, such as @kenari_updates. null when there is none.
dataarrayThe changes, newest first by confirmed_at. Empty when there is nothing at or after since.

Each item in data has these fields:

FieldTypeDescription
modelstringThe model id, as in GET /v1/models.
kindstringOne of the kinds in What counts as a change.
oldobject, integer or nullThe state before the change. The shape depends on kind. See Old and new by kind.
newobject, integer or nullThe state after the change, in the same shape as old.
effective_atinteger or nullUnix seconds when a scheduled change takes effect. Set for price_scheduled and sunset_scheduled, null for the other kinds.
detected_atintegerUnix seconds when kenari first saw the change.
confirmed_atintegerUnix seconds when the change entered this list. For scheduled changes it equals detected_at. About 10 minutes later for price changes and discounts, and about an hour later for added and removed.
successorstring or nullThe model named as this model’s successor, for sunset_scheduled and removed. null otherwise. Naming a successor does not by itself mean requests are forwarded to it.
{
"object": "list",
"channel": "@kenari_updates",
"data": [
{
"model": "step-3-7-flash",
"kind": "price_changed",
"old": { "free": false, "price_in": 1000000000, "price_out": 4000000000, "image": null },
"new": { "free": false, "price_in": 1200000000, "price_out": 4000000000, "image": null },
"effective_at": null,
"detected_at": 1791529200,
"confirmed_at": 1791532800,
"successor": null
}
]
}

Prices are whole numbers in micro-Rupiah, where Rp 1 is 1,000,000 micro-Rupiah. A token price is per 1M tokens, so 1200000000 means Rp 1,200 per 1M tokens. See How billing works.

Price object. Used by added, removed, price_changed and price_scheduled. It always has four fields:

FieldDescription
freetrue when the model is free.
price_inInput price per 1M tokens.
price_outOutput price per 1M tokens.
imagePrice per image, for models that are priced per image.

A price field is null when the model has no price of that kind, for example image on a chat model.

Discount object. Used by discount_started and discount_ended. It has an in key for the input side, an out key for the output side, or both. Only the sides that started or ended a discount are present. Each side is {"price": ..., "market": ...}:

FieldDescription
priceThe kenari price per 1M tokens on that side.
marketThe market price per 1M tokens on that side, or null when there is none.

Shape per kind:

kindoldneweffective_at
addednullPrice object.null
removedPrice object from before the model left.nullnull
price_changedPrice object before.Price object after.null
discount_startedDiscount object before.Discount object after, with market above price.null
discount_endedDiscount object before, with market above price.Discount object after.null
price_scheduledPrice object with today’s prices.Price object with the prices that apply from effective_at.Unix seconds when the new prices apply.
sunset_scheduledThe previous retirement time in unix seconds, or null when none was set.The retirement time in unix seconds.The same retirement time.

The first call returns the newest changes. Later calls pass since to get only what is new.

Terminal window
curl "https://kenari.id/v1/models/changes?limit=20"
curl "https://kenari.id/v1/models/changes?since=1791532800"

This loop polls every 10 minutes. It keeps the newest confirmed_at it has seen and passes it as since on the next call.

let since = 0;
const seen = new Set();
async function poll() {
const response = await fetch(`https://kenari.id/v1/models/changes?since=${since}&limit=200`);
if (!response.ok) return;
const { data } = await response.json();
// Newest first, so walk it backwards to handle the oldest change first.
for (const change of [...data].reverse()) {
const key = `${change.confirmed_at}:${change.model}:${change.kind}`;
if (seen.has(key)) continue;
seen.add(key);
console.log(change.kind, change.model, change.old, "->", change.new);
}
if (data.length > 0) since = data[0].confirmed_at;
}
await poll();
setInterval(poll, 10 * 60 * 1000);

since includes its own second, so the newest change comes back once more on the next call. The seen set drops it.

since and the order of the list use confirmed_at, the moment a change enters the list, and not detected_at. A change is first seen, then confirmed 10 minutes or an hour later. If since used the time it was first seen, a change seen just before your last poll and confirmed just after it would look older than your since, and you would never get it. A change gets its confirmed_at at the moment it enters the list, so it is never older than a poll that did not have it yet. A poller that passes the newest confirmed_at it has seen as since therefore does not miss a change.

detected_at stays on each item, for when the change first happened.

If more than limit changes are confirmed between two polls, only the newest limit come back. Use limit=200 and poll at least once an hour.

A since or limit that is not a whole number gets a 400 error. The other errors are in Errors.