Webhook-uri
Înregistrezi un endpoint HTTPS și trimitem prin POST un corp JSON semnat de fiecare dată când unul dintre articolele tale ajunge la un eveniment din ciclul lui de viață. Payload-ul poartă articolul de două ori — ca sursă markdown MD2 și ca HTML randat — plus metadatele SEO, backlinkurile contextuale și URL-urile la care trăiește.
Asta face din acesta calea „funcționează cu orice”: Zapier, Make, n8n, un releu Slack, o reconstruire de site static, alt CMS, sau un script de cincisprezece linii. Fără SDK, fără WordPress, fără polling.
Evenimente
| Eveniment | Când se declanșează |
|---|---|
post.published | Un articol devine vizibil — în momentul în care îl aprobi în revizuire, sau în momentul în care auto-publicarea îl publică |
post.pending_review | Generarea s-a încheiat pe un blog fără auto-publicare, deci un articol așteaptă un om |
post.unpublished | Un articol publicat a fost retras — nu mai este servit, iar copia din WordPress revine la ciornă |
ping | Doar de la butonul Trimite test. Niciodată trimis pentru conținut real, și nu poate fi abonat |
post.pending_review este cel care merită conectat la Slack: poartă
articolul finalizat, ca un revizor să îl poată citi acolo unde deja e, și să
deschidă dashboard-ul doar ca să aprobe.
Abonează-te la post.unpublished dacă ceva din aval păstrează o copie a
articolului. Poartă aceleași date ca post.published, doar că status este
acum archived: acela e semnalul să scoți articolul din indexul, cache-ul
sau oglinda ta. Fără el, o retragere de aici lasă copia ta live — singurul
loc la care nu ajungem. Un articol publicat din nou declanșează încă o dată
post.published, cu published_at-ul original.
Cererea
POST https://your-endpoint.example.com/hook
Content-Type: application/json
User-Agent: baas-webhooks/1
Baas-Signature: t=1755600000,v1=35002ec4c139aa19a23fb3b87644595af369203f…
Baas-Event: post.published
Baas-Delivery: 01994d5f-1e77-7a31-bd2b-9c0a3f5e2d10
Baas-Endpoint: 01994d5e-2b09-7f14-8c77-5a1e6b0d4c22
Baas-Delivery este id-ul evenimentului și este stabil: nu se schimbă
între reîncercările noastre sau o retrimitere manuală din dashboard.
Înregistrează id-urile pe care le-ai procesat deja și poți trata acest canal
ca fiind at-least-once, ceea ce și este.
Payload
Fiecare eveniment are același plic; doar data se schimbă.
{
"id": "01994d5f-1e77-7a31-bd2b-9c0a3f5e2d10",
"event": "post.published",
"created": "2026-08-19T09:00:00Z",
"api_version": "2026-08-19",
"org_id": "01a015d1-31bc-7659-b631-9e0a093995ca",
"site_id": "01a015d1-770a-776e-bb9e-92c4f6d48d87",
"blog_id": "01a0160b-d40b-7182-b8ac-5fc11458e933",
"endpoint_id": "01994d5e-2b09-7f14-8c77-5a1e6b0d4c22",
"data": {
"blog": {
"id": "01a0160b-d40b-7182-b8ac-5fc11458e933",
"name": "Engineering Blog",
"base_path": "/blog",
"locale": "en",
"site_name": "Technical Inside",
"domain": "technicalinside.com"
},
"post": {
"id": "05e646c2-8c8b-49d5-84f6-787085af1ad3",
"status": "published",
"locale": "en",
"slug": "kubernetes-cost-optimization",
"title": "Kubernetes cost optimization",
"excerpt": "Where the money actually goes.",
"markdown": ":::callout{severity=info}\nMD2 source\n:::",
"html": "<div class=\"md2-callout\">…</div>",
"word_count": 1420,
"reading_minutes": 7,
"published_at": "2026-08-19T09:00:00Z",
"created_at": "2026-08-19T08:41:02Z",
"updated_at": "2026-08-19T09:00:00Z",
"seo": {
"title_tag": "Kubernetes cost optimization",
"meta_description": "Where the money actually goes.",
"canonical": "https://technicalinside.com/blog/kubernetes-cost-optimization"
},
"json_ld": { "@type": "BlogPosting" },
"links": [
{
"url": "https://technicalinside.com/pricing",
"anchor_text": "our pricing",
"target_title": "Pricing",
"position": 1
}
],
"urls": {
"path": "/blog/kubernetes-cost-optimization",
"canonical": "https://technicalinside.com/blog/kubernetes-cost-optimization",
"render": "https://api.dailysmith.com/v1/render/pk_test_.../blog/kubernetes-cost-optimization",
"fragment": "https://api.dailysmith.com/v1/render/pk_test_.../blog/kubernetes-cost-optimization?format=fragment"
},
"css": "/* blog theme */ .baas-wrap, [data-baas-blog] { --md2-bg: #0a1128; … }",
"css_href": "https://api.dailysmith.com/v1/sdk/md2.css"
}
}
}
Câmpuri care merită explicate
| Câmp | Ce faci cu el |
|---|---|
markdown | Sursa MD2. Ia-o pe asta dacă ai propriul motor de randare — un generator de site static, alt CMS, propriul tău pipeline |
html | Ce a randat pipeline-ul nostru. Ia-o pe asta dacă vrei doar să afișezi articolul |
links | Backlinkurile contextuale țesute în corpul articolului, pentru propriile tale rapoarte |
urls.canonical | Unde trăiește articolul pe domeniul tău |
urls.fragment | Același conținut, ca JSON, dacă preferi să faci fetch în loc să stochezi |
css / css_href | Stilizare pentru componentele MD2 — vezi mai jos |
json_ld | Date structurate gata făcute pentru <head>-ul paginii |
Verificarea semnăturii
Fiecare cerere poartă Baas-Signature: t=<unix-seconds>,v1=<hex>, unde
hex-ul este HMAC-SHA256 peste șirul <t>.<raw request body>, folosind ca
cheie secretul de semnare al endpoint-ului tău.
Respinge orice fără o semnătură validă Oricine îți află URL-ul poate face POST către el. Semnătura e ce deosebește cererea noastră de a lui.
Verifică timestamp-ul Respinge cererile mai vechi de aproximativ 5 minute — asta e toleranța noastră recomandată. Timestamp-ul e în interiorul MAC-ului, deci cine reia cererea nu îl poate rescrie.
Răspunde rapid Așteptăm 10 secunde un răspuns. Pune munca în coadă și răspunde imediat cu
2xx, în loc s-o procesezi pe loc.
Node
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.BAAS_WEBHOOK_SECRET;
// express.raw păstrează octeții exacți — express.json() nu i-ar păstra.
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Baas-Signature') ?? '';
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('='))
);
if (!parts.t || !parts.v1) return res.status(400).end();
// Fereastra de replay.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return res.status(400).end();
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.`)
.update(req.body)
.digest('hex');
// Timp constant: un === simplu scurge MAC-ul câte un octet o dată.
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// Idempotență: event.id e stabil între reîncercări și retrimiteri manuale.
console.log(event.event, event.id, event.data.post?.title);
res.status(200).json({ ok: true });
});
app.listen(9099);
Go
func verify(secret string, header string, body []byte, now time.Time) bool {
var ts, sig string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
ts = v
case "v1":
sig = v
}
}
if ts == "" || sig == "" {
return false
}
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if d := now.Sub(time.Unix(unix, 0)); d > 5*time.Minute || d < -5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.", ts)
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(sig))
}
Un vector de test
Ambele fragmente trebuie să producă această semnătură. Dacă al tău nu o produce, bug-ul e în implementarea ta, nu în livrare.
| Intrare | Valoare |
|---|---|
| secret | whsec_test_secret |
t | 1755600000 |
| body | {"id":"01994d5f-0000-7000-8000-000000000001","event":"post.published"} |
v1 | 35002ec4c139aa19a23fb3b87644595af369203fd353e1440d2f365912edede0 |
Reîncercări
Continuăm să încercăm până răspunzi cu 2xx, după acest program:
| Încercare | Trimisă |
|---|---|
| 1 | imediat |
| 2 | +10 secunde |
| 3 | +30 de secunde |
| 4 | +2 minute |
| 5 | +5 minute |
| 6 | +15 minute |
| 7 | +1 oră |
| 8 | +3 ore |
Astea sunt opt încercări întinse pe aproximativ patru ore și jumătate.
Fiecare încercare refolosește același id Baas-Delivery și același corp,
semnat din nou.
Orice altceva decât 2xx e reîncercat — un 404, un 500, un timeout, o
conexiune refuzată. Secțiunea Livrări recente din dashboard arată codul
de stare returnat de serverul tău și primii 2 KB din corpul răspunsului său,
ca să poți depana din perspectiva noastră.
Fiecare livrare e vizibilă și în cardul Webhook-uri al blogului, cu un buton Retrimite, care retrimite corpul original cu același id.
Rotirea secretului
Rotește secretul emite unul nou și îl afișează o singură dată. Schimbarea are efect imediat — chiar următoarea livrare e semnată cu noul secret — deci actualizează-ți întâi receptorul, sau acceptă o fereastră scurtă de cereri respinse.
Stocăm secretul criptat și chiar nu îl mai putem afișa din nou. Dacă îl pierzi, rotirea e singura cale înapoi.
Zapier, Make și n8n
Toate trei îți dau un URL către care faci POST; lipește-l în câmpul de endpoint.
- Zapier — Webhooks by Zapier → Catch Raw Hook. Folosește varianta raw dacă vrei să verifici semnătura; Catch Hook parsează corpul și pierde octeții exacți.
- Make — Custom webhook. Make arată structura primită după prima livrare, deci apasă Trimite test cât timp listenerul lui „determine data structure” rulează.
- n8n — nodul Webhook, metoda
POST. Pe un n8n auto-găzduit, ține minte că URL-ul trebuie să fie accesibil din internetul public (vezi mai jos).
Cerințe și limite
| Schemă | doar https |
| Adresă | Trebuie să rezolve către o adresă publică — intervalele private, loopback, link-local și de metadate cloud sunt refuzate, chiar la momentul rezolvării DNS |
| Redirecturi | Nu sunt urmate. Un 30x e înregistrat drept răspunsul propriu-zis |
| Timeout | 10 secunde per încercare |
| Query strings | Permise (URL-urile de Zapier și n8n poartă adesea unul) |
| Credențiale în URL | Refuzate — autentifică-te cu semnătura |
| Reținere jurnal | Cele mai recente 200 de livrări per endpoint |
| Domeniu de aplicare | Un endpoint aparține unui singur blog. Un site cu mai multe bloguri înregistrează un endpoint per blog; payload-ul poartă blog_id, site_id și org_id, ca un singur receptor să le poată direcționa |
Depanare
| Ce vezi | Ce înseamnă |
|---|---|
| Se reîncearcă fără cod de stare | Nu ne-am putut conecta deloc — DNS, TLS, firewall, sau un timeout. Textul erorii e în rândul din jurnal |
403 sau 401 de la serverul tău | Receptorul tău ne respinge. Verifică dacă compari cu corpul brut, și dacă folosești secretul curent |
| Semnătura nu se potrivește niciodată | Aproape mereu un corp re-serializat. Verifică octeții așa cum au ajuns, înainte de orice parsare JSON |
422 la adăugarea unui endpoint | URL-ul a eșuat validarea — http, o adresă privată, credențiale înglobate, sau un #fragment |
| Endpoint-ul a trecut Pe pauză de la sine | Serverul tău a răspuns cu 410 Gone. Apasă Reia |
| Nu ajunge nimic deloc | Verifică dacă endpoint-ul e Activ și abonat la evenimentul pe care îl aștepți; post.published nu se declanșează pentru articole încă în revizuire |