Webhooki
Dwie warstwy zdarzeń, podpis HMAC po surowym body, osiem prób z backoffem - i jedno zdarzenie, które trzyma headlessowy frontend świeżym.
Webhook to sposób, w jaki cmssy mówi Twojej aplikacji, że coś się zmieniło - zamiast tego, żeby aplikacja pytała. Są dwie warstwy zdarzeń, jeden kształt dostawy i jeden szczegół ważniejszy od reszty: content.changed zamienia publikację w żywą stronę na scache'owanym froncie.
Dwie warstwy, dwa pytania
Warstwy odpowiadają na różne pytania i większość integracji potrzebuje tylko jednej.
content.changed- "zmieniło się coś, co serwuję, unieważnij cache". Jedno zgrubne zdarzenie. To subskrybuje headlessowy front.- Zdarzenia autorskie - "ktoś coś zrobił". Dwadzieścia jeden granularnych zdarzeń do automatyzacji, audytu i powiadomień: wiadomość na Slacku przy publikacji strony, rekord w CRM przy wysłaniu formularza.
order.*- dziesięć zdarzeń cyklu życia zamówienia.
list_webhook_event_types to autorytatywna lista i jest filtrowana Twoimi uprawnieniami - odczytaj ją, zamiast zgadywać nazwę.
content.changed, do unieważniania cache'u
Jedno zdarzenie obejmuje każdą zmianę tego, co serwuje delivery API, a payload mówi, co się stało:
{
"kind": "page", // page | record | model | form | media | settings
"action": "published", // published | unpublished | created | updated | deleted
"ids": ["6a63..."],
"slug": "/pricing", // tylko strony
"modelSlug": null // tylko rekordy i modele
}Zasubskrybuj raz i traktuj każdą parę jako "unieważnij". W tym rzecz: nowy rodzaj podmiotu dodany później dotrze do Ciebie bez nowej subskrypcji.
Trzy brzegi warte obsłużenia. Operacja masowa, której zbiór dotkniętych obiektów jest nieznany albo większy niż 100, wysyła pustą tablicę ids - potraktuj to jako "unieważnij wszystko". Przy wsadowym usunięciu stron slug to korzeń usuniętego poddrzewa; potomkowie z ids mieszkali pod innymi slugami. A kind: "settings" zawsze niesie pustą tablicę ids i slug równy null - nic nie wskazuje na jedną stronę, więc unieważnij całe drzewo.
Usunięcie modelu emituje dwa zdarzenia: jedno dla definicji i jedno dla rekordów, które poszły razem z nią. Konsument cache'ujący definicje i konsument cache'ujący rekordy potrzebują unieważnienia czegoś innego.
Warstwa autorska
Nie zastępują content.changed - odpowiadają na drugie pytanie. Subskrybuj je do automatyzacji, nie do unieważniania cache'u.
- Strony -
page.created,page.updated,page.deleted,page.published,page.unpublished. - Rekordy -
record.created,record.updated,record.deleted. - Modele -
model.created,model.updated,model.deleted. - Formularze -
form.created,form.updated,form.deleted,form.submitted. - Media -
media.uploaded,media.updated,media.deleted. - Członkowie -
member.added,member.updated,member.removed. - Ustawienia -
settings.updated.
Mają ten sam kształt payloadu co content.changed, więc jeden parser obsłuży obie warstwy. Payloady to szczupłe referencje, nigdy treść: form.submitted daje id zgłoszenia i slug formularza, a nie przesłane wartości - te pobierz przez API własnymi poświadczeniami.
Dwie asymetrie są celowe. page.updated leci przy zmianie pola dokumentu niezależnie od tego, czy strona jest opublikowana, ale tylko strona opublikowana emituje dodatkowo content.changed - edycja nieopublikowanego draftu to praca autorska, nie zmiana tego, co serwujemy. A media.uploaded jest wyłącznie granularne: asset utworzony sekundę temu nie może być użyty na żadnej opublikowanej stronie, więc nie ma czego unieważniać. Edycja albo usunięcie assetu emituje już content.changed.
Jakich uprawnień wymaga subskrypcja
Zasubskrybowanie endpointu do zdarzenia wymaga uprawnienia do odczytu tego, co zdarzenie opisuje - obok webhooks:manage. Subskrypcja mieszana wymaga wszystkich naraz.
order.*-orders:viewpage.*-pages:viewrecord.*,model.*-models:viewform.created|updated|deleted-forms:viewform.submitted-forms:submissions:viewmedia.*-media:viewmember.*-users:viewsettings.updated-site:config:edit
content.changed może nieść dowolny z rodzajów dostarczanych, więc wymaga sumy tego, czego wymagają te rodzaje: pages:view + models:view + forms:view + media:view. Ten sam zestaw sprawdzany jest przy edycji istniejącego endpointu - rola, która nie może zasubskrybować zdarzenia, nie utrzyma też przy życiu endpointu, który je niesie.
Jak utrzymać świeżość scache'owanego frontu
Publikacja nie deployuje i nie omija też Twojego cache'u. Strona z revalidate = 3600 serwuje starą kopię nawet przez godzinę, dopóki coś jej nie unieważni. Tym czymś jest webhook content.changed wycelowany w route rewalidacji:
// app/api/revalidate/route.ts
import { createCmssyRevalidateRoute } from "@cmssy/next/server";
export const POST = createCmssyRevalidateRoute({
secret: process.env.CMSSY_WEBHOOK_SECRET,
});npx @cmssy/cli init zapisuje dokładnie tę trasę. Weryfikuje ona podpis dostarczenia i unieważnia wszystko scache'owane pod tagiem cmssy-content, więc kolejny odwiedzający renderuje opublikowaną treść; sekret podpisu z Settings → Webhooks wpisz do CMSSY_WEBHOOK_SECRET. Piszesz własną trasę? Najpierw zweryfikuj body przez verifyCmssyWebhook z @cmssy/core - wtedy stosują się uwagi poniżej.
Jeśli Twoje URL-e niosą prefiks języka, rewaliduj też ścieżki zlokalizowane - każda jest cache'owana osobno. A jeśli nawigacja, sitemapa albo listingi rodzica są cache'owane pod tagiem, czyść ten tag przy każdym zdarzeniu: świeżo opublikowana strona jest żywa, ale brakuje jej w każdym menu, dopóki tego nie zrobisz.
Wybieraj unieważnianie per podmiot zamiast pełnej przebudowy, gdy się da. subject.ids daje dokładne rekordy, które się zmieniły przy zapisach pojedynczych, więc otagowanie fetchy po id rekordu pozwala odświeżyć jedną stronę produktu zamiast całego serwisu.
Jak wygląda dostawa
Każda dostawa to POST z content-type: application/json i takim body:
{
"id": "6a64...", // id endpointu webhooka
"event": "content.changed",
"createdAt": "2026-07-25T18:30:00.000Z",
"data": { "workspaceId": "...", "subject": { } }
}Towarzyszą jej trzy nagłówki: x-cmssy-event, x-cmssy-webhook-id i x-cmssy-signature.
Zweryfikuj podpis
Nagłówek podpisu ma postać t=<unix-ms>,v1=<hex>, gdzie hex to HMAC-SHA256 z <t>.<surowe ciało> kluczowany sekretem endpointu. W trakcie rotacji sekretu nagłówek niesie po jednym v1 na każdy aktywny sekret, więc weryfikator musi uznać dopasowanie do któregokolwiek z nich - sprawdzanie tylko ostatniego po cichu wywala połowę dostaw w trakcie rotacji.
Zamiast pisać to ręcznie użyj helpera z SDK. Czyta każde v1, porównuje w stałym czasie, odrzuca znacznik czasu starszy niż 5 minut (przechwycona dostawa nie da się odtworzyć) i zwraca otypowane zdarzenie. Jest eksportowany z @cmssy/next, @cmssy/remix i @cmssy/astro (oraz z @cmssy/core, jeśli nie używasz żadnego z nich):
import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";
export async function POST(request: Request) {
const body = await request.text();
try {
const event = await verifyCmssyWebhook({
body,
signatureHeader: request.headers.get("x-cmssy-signature"),
secret: process.env.CMSSY_WEBHOOK_SECRET!,
});
handle(event);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof CmssyWebhookError) {
return new Response(null, { status: 400 });
}
throw error;
}
}Przekaż surowe ciało - await request.text(), nigdy ponownie zserializowanego obiektu. Sparsowanie i ponowne złożenie JSON-a zmienia bajty i psuje porównanie.
Ponowienia
cmssy czeka na odpowiedź 5 sekund i nie podąża za przekierowaniami: 3xx to nieudana próba, nie skok - zarejestruj docelowy URL. Wszystko poza 2xx, plus timeouty, jest ponawiane do 8 prób, z odstępami 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h.
Drabinę kończy wcześniej wyłącznie 410 Gone. Każde inne 4xx jest ponawiane, bo "ten endpoint skończył się na zawsze" i "wdrożenie, które obsługuje tę trasę, przez chwilę odpowiada 404" wyglądają z zewnątrz identycznie. Zwróć 410, kiedy naprawdę to masz na myśli.
Nagłówek Retry-After - sekundy albo data HTTP - jest respektowany, gdy prosi o dłużej niż drabina, z limitem 4 godzin. Nigdy nie skraca odstępu.
Po 20 kolejnych niepowodzeniach endpoint jest automatycznie wyłączany i przestaje dostawać dostawy. Napraw handler, potem włącz go z powrotem przez update_webhook - to zeruje licznik.
Dwie konsekwencje dla twojego handlera. Musi być szybki: potwierdź 2xx i zrób robotę potem, inaczej wolny endpoint zamienia się w burzę ponowień. I musi być idempotentny: deduplikuj po x-cmssy-webhook-id, które jest takie samo w każdej próbie danej dostawy - podobnie jak createdAt w ciele.
list_webhook_deliveries pokazuje ostatnie próby ze statusem i kodem odpowiedzi; dostawy trzymamy 30 dni.
Zarządzanie endpointami
create_webhook- zwraca endpoint i jego sekret, raz. Zapisz go od razu; nigdy nie wróci.rotate_webhook_secret- zwraca nowy sekret, też raz. Rotacja z panelu admina zostawia poprzedni sekret podpisujący równolegle przez 24 godziny, więc możesz wdrożyć zmianę bez gubienia dostaw; rotacja przez MCP przełącza natychmiast. Tak czy inaczej stary sekret przestaje weryfikować w momencie końca swojego okna.update_webhook- aktualizacja częściowa; przekażenabled, żeby wstrzymać endpoint bez kasowania. Wyłączenie nigdy nie wymaga uprawnień do zdarzeń, więc skompromitowany endpoint zawsze da się zgasić.list_webhooks,delete_webhook,list_webhook_deliveries.
Do 20 endpointów na workspace. URL-e muszą być https na produkcji, a prywatne cele są odrzucane: localhost i jego subdomeny, *.local, *.internal, literały IPv6 oraz zakresy IPv4, które nie są publicznym internetem - RFC 1918, loopback, link-local, CGNAT (100.64/10), benchmarking (198.18/15) i wszystko od 224.0.0.0 w górę. Nazwa hosta jest dodatkowo rozwiązywana przed każdą dostawą, więc publiczna nazwa odpowiadająca prywatnym adresem też zostanie odrzucona. Webhook, który sięga do twojej sieci wewnętrznej, to powierzchnia SSRF, nie funkcja.
Następne kroki
- Serwer MCP - narzędzia tworzące i podglądające endpointy.
- Podgląd roboczy - publikacja, cache i co znaczy nieświeża strona.
- Tokeny API - poświadczenie stojące za tymi narzędziami.