Platform capabilities
Platforms do not agree on anything. One takes four images, another takes one. One accepts a 9:16 video and rejects 1:1. One has no analytics per post but plenty per location. Hard-coding those differences in your client means editing your client every time we ship one.
GET /v1/platforms is the machine-readable answer. It returns one entry per
platform your key can reach, and every limit in it is the same value the
validator and the publisher use — not a copy maintained alongside them.
export async function platformCatalog(zhyper: ZhyperClient): Promise<Catalog> {
// Katalog ANAHTARIN modunu izler: test anahtari sandbox registry'sini,
// canli anahtar uretim registry'sini gorur. Ikisi ayni platform kumesini
// tasimak ZORUNDA DEGILDIR.
return zhyper.request<Catalog>('listPlatforms', {});
}
The catalogue follows your key's mode. A test-mode key sees the sandbox registry, a live key sees the production one, and the two need not carry the same platforms — a platform can be complete in sandbox and still gated off in production.
Reading a capability
Each entry carries platform, capabilities, mediaDelivery, quotaModel,
apiVersion and apiSunsetAt.
Inside capabilities, most fields are either an object of limits or the
literal false. That false is doing real work, and it means different things
at different depths:
export async function imageLimitsFor(
zhyper: ZhyperClient,
platform: string,
): Promise<{ supported: boolean; maxCount?: number; maxBytes?: number }> {
const catalog = await platformCatalog(zhyper);
const entry = catalog.find((row) => row.platform === platform);
if (!entry) return { supported: false };
// `publish` VE `image` ikisi de `false` OLABILIR ve bu iki AYRI sey soyler:
// `publish: false` platformun bu API'den hic yayin kabul etmedigini,
// `image: false` yayin kabul ettigini ama GORUNTU kabul etmedigini. Ikisini
// ayni kovaya koyan bir istemci ikinci durumda yanlis hata gosterir.
const publish = entry.capabilities.publish;
if (publish === false) return { supported: false };
const image = publish.image;
if (image === false) return { supported: false };
return { supported: true, maxCount: image.maxCount, maxBytes: image.maxBytes };
}
publish: false— the platform accepts no publishing through this API at all.publish.image: false— it publishes, but not images.
A client that collapses those two shows the wrong error in the second case. The
same distinction applies to video, document, carousel, inbox and ads.
What false promises
A capability set to false is a promise that the request will be rejected
early — at validation, before anything is scheduled — rather than accepted and
then failing at delivery.
This is worth stating because it was once not true. Slack declared image, video
and document support while no media pipeline existed behind it: posts were
accepted, queued, and died at delivery. That declaration is now false, so the
rejection arrives at POST /v1/tools/validate/post and at post creation, with
image_not_supported, video_not_supported or document_not_supported and
param: "media".
If you want to know whether a platform will take your attachment, ask this endpoint or the validator — do not infer it from the fact that a post was accepted a year ago.
Analytics, and the companion field
analytics splits into post and account: the metric names available per
published post, and per connected account. Either can be empty.
An empty post list does not mean the platform has no analytics:
export async function analyticsShapeFor(
zhyper: ZhyperClient,
platform: string,
): Promise<{ post: number; account: number; companion: string | null }> {
const catalog = await platformCatalog(zhyper);
const entry = catalog.find((row) => row.platform === platform);
const analytics = entry?.capabilities.analytics;
if (analytics === undefined || analytics === false) {
return { post: 0, account: 0, companion: null };
}
// `companion` bir platformun metriklerinin GONDERI basina degil baska bir
// granulariteden geldigini soyler. Bugun tek deger `location`: metrikler
// isletme lokasyonu duzeyinde toplanir, tekil gonderi duzeyinde degil.
// `post: []` + dolu bir `account` gorunce "analitigi yok" diye okuma.
return {
post: analytics.post.length,
account: analytics.account.length,
companion: analytics.companion?.granularity ?? null,
};
}
When companion is present it names the granularity the metrics actually come
from. Today the only value is location — Google Business aggregates at the
business-location level, so there is no per-post number to give you, while
account carries the full set of location metrics. Treating post: [] as "no
analytics" would hide a surface that exists.
What this endpoint deliberately does not tell you
- Per-account differences. Some platforms narrow a capability for a specific
connected account — a Discord bot without an attachment permission, an X app
without media scope. The catalogue describes the platform;
accountCapabilityon the entry, and the account health surface, describe the account. - Your quota position.
quotaModelsays how the platform's quota is shaped (shared pool or per tenant), not how much of it you have left. That lives in/v1/usage. - Internal method names. The catalogue is a customer contract, so adapter internals are stripped from it on the way out even where the underlying declaration carries them.
Is a platform up right now?
GET /v1/platforms says what a platform can do. /v1/status says whether it
is answering. It takes no key — it is the public status page — and returns one
entry per platform in the live registry plus an overall roll-up.
Each entry carries status (operational, degraded, outage, unknown),
since, and two fields describing how the circuit breaker behind that status is
scoped:
scope: "platform"— one breaker shared by every tenant. That is the right unit for a shared API: when Instagram stops answering, it stops for everyone.scope: "origin"— one breaker per instance host, because the server address comes from your connected account, so "the platform" is the wrong unit. Mastodon and Bluesky are the platforms scoped this way today — but read the field, do not memorise the list: each platform declares its own scoping, and this page is not where that declaration lives. One broken server used to hold every tenant's posts on that platform; it no longer does.openOriginCircuits— how many of those per-instance breakers are open or half-open atcheckedAt. It is0wheneverscopeis"platform".
openOriginCircuits never changes status. One Mastodon server going dark
is not Mastodon going dark, and on a federated network the wider claim is never
true — so the number is reported and the platform status stays where it is.
Read it as a weather report, not as an alarm.
The count is a count and nothing else. Which server, whose account, and how many
tenants sit behind it are not published here. Your own instance being
circuit-broken shows up where you can act on it instead: the affected targets
come back held on your posts.
unknown on an origin-scoped platform also covers "we could not count". If the
breaker store cannot be read, the platform reports unknown rather than a
reassuring 0.
Contacts
A contact is a person your team talks to. It belongs to exactly one profile, carries the details you keep about them (name, email, company, notes, tags, custom fields), and links t
Ads
Ads are a different shape from the rest of this API. Everywhere else you own the records: a post, a queue, a contact all live here. An ad does not. The objects live in the Meta ad