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

EvenimentCând se declanșează
post.publishedUn articol devine vizibil — în momentul în care îl aprobi în revizuire, sau în momentul în care auto-publicarea îl publică
post.pending_reviewGenerarea s-a încheiat pe un blog fără auto-publicare, deci un articol așteaptă un om
post.unpublishedUn articol publicat a fost retras — nu mai este servit, iar copia din WordPress revine la ciornă
pingDoar 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âmpCe faci cu el
markdownSursa MD2. Ia-o pe asta dacă ai propriul motor de randare — un generator de site static, alt CMS, propriul tău pipeline
htmlCe a randat pipeline-ul nostru. Ia-o pe asta dacă vrei doar să afișezi articolul
linksBacklinkurile contextuale țesute în corpul articolului, pentru propriile tale rapoarte
urls.canonicalUnde trăiește articolul pe domeniul tău
urls.fragmentAcelași conținut, ca JSON, dacă preferi să faci fetch în loc să stochezi
css / css_hrefStilizare pentru componentele MD2 — vezi mai jos
json_ldDate 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.

  1. 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.

  2. 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.

  3. 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.

IntrareValoare
secretwhsec_test_secret
t1755600000
body{"id":"01994d5f-0000-7000-8000-000000000001","event":"post.published"}
v135002ec4c139aa19a23fb3b87644595af369203fd353e1440d2f365912edede0

Reîncercări

Continuăm să încercăm până răspunzi cu 2xx, după acest program:

ÎncercareTrimisă
1imediat
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.

  • ZapierWebhooks 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.
  • MakeCustom 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
RedirecturiNu sunt urmate. Un 30x e înregistrat drept răspunsul propriu-zis
Timeout10 secunde per încercare
Query stringsPermise (URL-urile de Zapier și n8n poartă adesea unul)
Credențiale în URLRefuzate — autentifică-te cu semnătura
Reținere jurnalCele mai recente 200 de livrări per endpoint
Domeniu de aplicareUn 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 veziCe înseamnă
Se reîncearcă fără cod de stareNu 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ăuReceptorul 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 endpointURL-ul a eșuat validarea — http, o adresă privată, credențiale înglobate, sau un #fragment
Endpoint-ul a trecut Pe pauză de la sineServerul tău a răspuns cu 410 Gone. Apasă Reia
Nu ajunge nimic delocVerifică 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
enro