İçeriğe geç
Belgeler menüsüİçerik API’si kılavuzu

metrics İçerik API'si — v1

Sitelerin ve mobil uygulamaların, panelde yazılıp onaylanan blog yazılarını kendi tasarımlarıyla göstermesi için sözleşme. Site yalnız yayındaki yazıları okur (taslak, onaydaki sürüm ya da siteden kaldırılmış yazı hiç dönmez); metrics bir yazı değişince sitenin bildirim adresine imzalı bir istek (yayın webhook'u) gönderir, site önbelleğini yeniler ve yazıyı yeniden okur.

Bu sayfa derleme sırasında depodaki docs/api/content-api.md dosyasından üretilir.

  • Taban adres: https://api.<alan>/v1/content/apps/{appId} (geliştirme: http://localhost:7100/v1/content/apps/{appId})
  • appId: panelde İçerik → Ayarlar → Siteye bağlama sayfasında yazan uygulama kimliği
  • Makine tarafından okunur belge: openapi/content.json (koddan üretilir)

1. Kavramlar

KavramAçıklama
YazıBaşlık, adres (slug), dil, özet, blok içerik, kategori, etiketler, yazarlar, kapak, SEO alanları ve yayın yerleri. Panelde taslaktan onaya, onaydan yayına geçer.
Yayındaki sürümSitenin gördüğü sürüm. Yayındaki yazı düzenlenirse yeni sürüm aynı onay yolundan geçince yayındakini günceller; o zamana kadar API eski sürümü vermeye devam eder.
Blok içerikİçerik HTML değil, bloklardan oluşan bir belgedir: paragraf, ara başlık (H2–H4), liste, alıntı, görsel, soru-cevap, ayraç. Metin düz metindir; kalın, italik ve kod işaretleri ve bağlantılar parça olarak gelir.
htmlBlokların metrics tarafından çizilmiş, her metni kaçışlanmış HTML'i: sabit bir etiket kümesi (p, h2–h4, ul, ol, li, blockquote, figure, img, dl, hr, a, strong, em, code…), betik, stil ya da satır içi HTML yok. Kendi çiziminizi yapmak isterseniz body.blocks kullanın.
Yayın yeriweb (sitenin blogu) ya da app (uygulama içi duyuru ve rehber akışı). Liste feed ile süzülür.
İçerik anahtarıOrganizasyonun panelden oluşturduğu, kapsamı content:read olan API anahtarı (mk_…). Yalnız sunucu tarafında kullanılır; tarayıcıya ya da mobil uygulamanın koduna konmaz.

2. Kimlik doğrulama

HTTP
GET /v1/content/apps/{appId}/posts HTTP/1.1
Host: api.<alan>
Authorization: Bearer mk_…
Accept-Language: tr
  • Anahtarsız istek 401, content:read kapsamı olmayan anahtar 403 alır.
  • Anahtarın uygulama kapsamı bu uygulamayı içermiyorsa ya da yazı yayında değilse 404 döner (başka uygulamanın yazılarının varlığı sızmaz).
  • Hata gövdesi her zaman { "error": { "code", "message", "traceId" } }; message isteğin dilindedir (Accept-Language: tr, en).

3. Uçlar

Yöntem ve yolNe döner
GET /postsYayındaki yazıların listesi (özet alanlar), imleçle sayfalı
GET /posts/{slug}Adresle tek yazının belgesi, ya da eski adresse yönlendirme bilgisi
GET /categoriesKategoriler (sıra, açıklama, yayındaki yazı sayısı)
GET /tagsEtiketler ve yayındaki yazı sayıları
GET /authorsYazarlar ve profilleri
GET /sitemapSite haritasının girdileri (noindex yazılar hariç, çeviriler alternatif olarak)
GET /preview/{token}Editörlerin verdiği önizleme belirteciyle yazının çalışma sürümü
GET /assets/{assetId}Yayındaki bir yazının ya da yazarının görselinin baytları

Bütün yollar https://api.<alan>/v1/content/apps/{appId} altındadır.

4. Yazı listesi

GET /posts?feed=web&locale=tr&limit=20

ParametreAçıklama
limitSayfa boyu, 1–100 (varsayılan 20)
cursorÖnceki yanıtın nextCursor değeri; null ise son sayfa
localeDil (tr, en, en-GB)
categoryKategorinin adresi (rehberler)
tagEtiket
authorYazarın adresi
feedweb ya da app; verilmezse ikisi de
searchBaşlık ya da özette geçen kelimeler
updatedSinceYalnız bu andan sonra yayındaki sürümü değişen yazılar (artımlı eşitleme; ISO 8601)
sortpublished (ilk yayın, varsayılan) ya da updated (son değişiklik)
JSON
{
  "items": [
    {
      "id": "01a1b8a9-2ec3-7d45-a807-1949a3f6aa55",
      "slug": "olcumleme-rehberi",
      "locale": "tr",
      "title": "Ölçümleme Rehberi: Sık Yapılan 5 Hata",
      "excerpt": "Olay adlarından kampanya etiketlerine…",
      "url": "https://www.ornek.com/blog/olcumleme-rehberi",
      "category": { "slug": "rehberler", "name": "Rehberler" },
      "tags": ["ölçüm", "kurulum"],
      "authors": [{ "slug": "deniz-ornek", "name": "Deniz Örnek" }],
      "cover": { "assetId": "…", "url": "…", "expiresAt": "…", "file": "…", "alt": "…", "width": 1920, "height": 1080 },
      "feeds": ["web"],
      "readingMinutes": 1,
      "publishedAt": "2026-10-29T00:57:37.730Z",
      "updatedAt": "2026-10-29T00:57:37.730Z",
      "revision": 1
    }
  ],
  "nextCursor": null
}

url, uygulamanın panelde girilen yazı adresi şablonundan (https://www.ornek.com/blog/{slug}) çıkar; şablon yoksa null.

5. Adresle yazı, yönlendirme ve kaldırılmış yazı

GET /posts/{slug} üç biçimde yanıt verir:

DurumYanıt
Yazı yayında200 { "kind": "post", "post": { … } }
Adres yazının eski adresi200 { "kind": "redirect", "redirect": { "slug": "yeni-adres", "url": "https://…/blog/yeni-adres", "permanent": true } }
Yazı siteden kaldırıldı410 CONTENT_POST_GONE
Böyle bir yayın yok404 CONTENT_POST_NOT_FOUND
  • API kendisi 301 vermez: HTTP istemcisi yönlendirmeyi sessizce izleyip eski adresi önbellekte tutmasın diye. Siteniz redirect aldığında ziyaretçiye kalıcı yönlendirme (301) vermelidir; arama motorları yeni adrese geçer.
  • 410 alan sayfa ziyaretçiye «bu yazı kaldırıldı» demeli ve 410 durum koduyla dönmelidir; arama motorları yazıyı dizinden çıkarır.

Belgenin alanları:

AlanAçıklama
bodyBlok belgesi ({ "version": 1, "blocks": [ … ] })
htmlBlokların güvenli HTML'i (bkz. §1)
text, toc, wordCount, readingMinutesDüz metin, içindekiler (başlıkların HTML'deki çapa kimlikleriyle), kelime sayısı, okuma süresi
authorsYazarlar: ad, unvan, biyografi, fotoğraf, web sitesi, profil bağlantıları
seotitle (SEO başlığı ya da başlık), description (SEO açıklaması ya da özet), canonicalUrl, noindex, ogImage (paylaşım görseli ya da kapak)
structuredData, jsonLdschema.org verisi (BlogPosting / Article / NewsArticle; soru-cevap blokları varsa FAQPage). jsonLd aynı veridir, <script type="application/ld+json"> içine doğrudan konacak biçimde kaçışlanmış
translationsAynı yazının başka dillerdeki adresleri (hreflang)
previousSlugsYazının eski adresleri (hepsi bu yazıya yönlenir)
previewÖnizleme belirteciyle okunan çalışma sürümüyse true: böyle bir sayfa dizine eklenmez

6. Önbellek: ETag, Last-Modified, 304

Her yanıt ETag (gövdenin özeti) ve Last-Modified (uygulamanın sunulan içeriğinin son değişikliği) taşır. Sonraki istekte bunları geri gönderin; değişiklik yoksa yanıt 304 Not Modified ve gövdesizdir.

HTTP
GET /v1/content/apps/{appId}/posts/olcumleme-rehberi HTTP/1.1
Authorization: Bearer mk_…
If-None-Match: "q1w2e3r4t5y6u7i8o9p0a1s2d3f4g5h6"
If-Modified-Since: Sun, 08 Nov 2026 01:00:00 GMT

HTTP/1.1 304 Not Modified
  • Yanıtlar Cache-Control: private, max-age=60 ve Vary: Authorization, Accept-Language taşır; önizleme no-store.
  • Önerilen düzen: sayfaları uzun süre önbellekte tutun, yayın webhook'u geldiğinde ilgili sayfaları (yazının adresi, eski adresleri, listeler, site haritası) yenileyin; webhook kaçarsa updatedSince ile artımlı eşitleme yapın.

7. Görseller

Kapak, paylaşım görseli, yazar fotoğrafı ve içerikteki görseller aynı biçimde gelir:

AlanAçıklama
urlSüreli imzalı bağlantı (expiresAt, varsayılan 7 gün; bitiş saatin başına yuvarlanır, böylece aynı dosya bir saat boyunca aynı bağlantıyı verir ve belgenin ETag'i değişmez)
fileGörselin baytlarının kalıcı adresi (GET /assets/{assetId}), içerik anahtarıyla okunur; `?variant=original
variants.medium, variants.thumb1080 ve 320 piksel genişlikte WebP sürümlerinin imzalı bağlantıları
alt, width, height, mimeTypeAlt metin (kapakta ve içerik görsellerinde zorunlu), boyutlar ve tür

Görselleri sunucunuzda indirip kendi adresinizden sunun: imzalı bağlantılar süreli ve başka bir sitenin sayfasına doğrudan gömülmek için değildir. html içindeki <img src> adresleri de imzalı bağlantılardır; kendi kopyanızın adresiyle değiştirin.

8. Site haritası

GET /sitemap?limit=1000&feed=web her yazı için { slug, locale, url, updatedAt, alternates } döner (en çok 5000 girdi bir sayfada, imleçle). noindex yazılar dahil değildir; çeviriler alternates olarak gelir. Sitenizin sitemap.xml'ini bu listeden üretin.

9. Önizleme

Editörler panelden süreli bir önizleme bağlantısı (cp_… belirteci) oluşturabilir. Bağlantı metrics'in kendi betiksiz önizleme sayfasını açar. Sitenizin kendi tasarımıyla önizleme göstermek için kendi önizleme sayfanız belirteci GET /preview/{token} ucuna iletir: yanıt yazının çalışma sürümüdür (preview: true), no-store döner; süresi dolmuş ya da iptal edilmiş belirteç 410, tanınmayan belirteç 404 alır. Önizleme sayfasını noindex yapın.

10. Yayın webhook'u

Panelde İçerik → Ayarlar → Siteye bağlama sayfasında bir bildirim adresi eklediğinizde metrics şu durumlarda adrese POST gönderir:

typeNe oldu
content.post.publishedYazı yayına çıktı (ilk kez ya da kaldırıldıktan sonra geri döndü)
content.post.updatedYayındaki yazının sürümü değişti (metin, adres, SEO…)
content.post.unpublishedYazı siteden kalktı (arşivlenince de)
testPanelden gönderilen deneme; sitede hiçbir şey değişmemeli

Gövde küçüktür; yazının kendisini taşımaz, nerede okunacağını söyler:

JSON
{
  "id": "01a1b8c0-…",
  "type": "content.post.updated",
  "createdAt": "2026-11-08T10:00:00.000Z",
  "app": { "id": "01a1b249-…", "slug": "web-sitesi" },
  "post": {
    "id": "01a1b8a9-…",
    "slug": "olcumleme-rehberi",
    "previousSlugs": ["olcum-kurulumu-hatalari"],
    "locale": "tr",
    "url": "https://www.ornek.com/blog/olcumleme-rehberi",
    "revision": 3,
    "feeds": ["web"],
    "publishedAt": "2026-10-29T00:57:37.730Z",
    "updatedAt": "2026-11-08T10:00:00.000Z",
    "documentPath": "/v1/content/apps/01a1b249-…/posts/olcumleme-rehberi"
  }
}

Başlıklar:

BaşlıkAçıklama
Metrics-Signaturet=<unix saniye>,v1=<hex>; v1 = hex(HMAC-SHA256(imzaAnahtarı, t + "." + hamGövde))
Metrics-Event-TypeGövdedeki type
Metrics-Delivery-IdTeslimin kimliği; yeniden gönderimde aynı kalır
Metrics-Delivery-AttemptKaçıncı deneme
  • İmzayı gelen ham gövde üzerinden doğrulayın, t'nin 5 dakikadan eski olmadığını denetleyin; imza anahtarı (whsec_…) webhook oluşturulurken ya da yenilenirken panelde bir kez gösterilir.
  • 2xx dışındaki yanıtlar ve zaman aşımları (10 sn) 1, 5, 30 dakika ve 2, 6, 12 saat sonra yeniden denenir (en çok 24 saat); panelin teslim günlüğünden elle yeniden gönderilebilir. Aynı id iki kez gelebilir: işlemi id ile tekilleştirin.
  • documentPath yazı siteden kalkınca null'dır; o zaman yazının sayfasını 410 yapın.
JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';

/** Express ya da benzeri: gövde ham metin olarak alınır (JSON'a çevrilmeden). */
export function verifyMetricsSignature(rawBody, header, secret, now = Date.now()) {
  const parts = Object.fromEntries(header.split(',').map((part) => part.trim().split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(now / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
}

11. Hata kodları

HTTPKodAnlamı
400VALIDATION_FAILEDParametre geçersiz (alan başına mesaj fields içinde)
401API_KEY_INVALIDAnahtar yok, geçersiz, iptal edilmiş ya da süresi dolmuş
403API_KEY_SCOPE_MISSINGAnahtarın content:read kapsamı yok
404CONTENT_POST_NOT_FOUNDBu adreste yayında yazı yok (ya da uygulama anahtarın kapsamında değil)
404CONTENT_PREVIEW_INVALIDÖnizleme belirteci tanınmıyor
410CONTENT_POST_GONEYazı siteden kaldırıldı
410CONTENT_PREVIEW_EXPIREDÖnizleme belirtecinin süresi doldu ya da iptal edildi
429TOO_MANY_REQUESTSÇok sık istek; Retry-After kadar bekleyin

12. Örnek: Next.js sitesinde blog sayfası

JavaScript
const API = `https://api.<alan>/v1/content/apps/${process.env.METRICS_APP_ID}`;
const headers = { authorization: `Bearer ${process.env.METRICS_CONTENT_KEY}`, 'accept-language': 'tr' };

export async function getPost(slug) {
  const response = await fetch(`${API}/posts/${encodeURIComponent(slug)}`, { headers, next: { tags: [`post:${slug}`] } });
  if (response.status === 410) return { gone: true };
  if (response.status === 404) return { notFound: true };
  if (!response.ok) throw new Error(`İçerik API'si: ${response.status}`);
  const answer = await response.json();
  if (answer.kind === 'redirect') return { redirect: { destination: `/blog/${answer.redirect.slug}`, permanent: true } };
  return { post: answer.post };
}

Webhook geldiğinde post:<slug> etiketini (ve previousSlugs'ın etiketlerini) yenileyin; liste sayfaları için ayrı bir etiket kullanın.