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

:::callout{severity=info title="Unde se configurează"}
Deschide un blog în dashboard și găsește cardul **Webhook-uri**. Adaugă un
URL de endpoint, bifează evenimentele dorite și apasă **Adaugă endpoint**.
Secretul de semnare este afișat **o singură dată**, chiar acolo — copiază-l
înainte să părăsești pagina.
:::

## 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ă.

```json
{
  "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 |

:::callout{severity=info title="De ce foaia de stil e un link, nu un șir"}
`html` poate conține componente MD2 (callout-uri, pași, comparații, grafice).
Acestea au nevoie de două foi de stil: **paleta** blogului tău, care are
câteva sute de octeți și sosește inline ca `css`, și **foaia de stil a
componentelor**, care are aproximativ 40 KB, e identică pentru fiecare
articol al fiecărui blog, și se schimbă doar când lansăm un motor de randare
nou.

De aceea `css_href` indică spre ea, în loc s-o includă inline. Fă fetch o
singură dată, cache-uiește-o, și randează articolul într-un element care
poartă ambele clase:

```html
<link rel="stylesheet" href="https://api.dailysmith.com/v1/sdk/md2.css">
<style>/* valoarea `css` din payload */</style>

<div class="baas-wrap md2"><!-- valoarea `html` din payload --></div>
```

Ambele câmpuri sunt **absente** când un articol nu conține nicio componentă
MD2 — markdown-ul simplu nu are nevoie de niciunul.
:::

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

:::callout{severity=warning title="Semnează octeții bruți, nu un obiect re-serializat"}
Verifică pe baza corpului cererii **exact așa cum a ajuns**. Dacă
framework-ul tău parsează JSON-ul și îl re-encodează, ordinea cheilor și
spațiile albe se schimbă, iar semnătura nu se va mai potrivi. Fiecare
exemplu de mai jos citește întâi corpul brut.
:::

::::steps

:::step[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.
:::

:::step[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.
:::

:::step[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

```js
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

```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ă.

:::callout{severity=warning title="410 Gone înseamnă dezabonare"}
Singura excepție. Dacă endpoint-ul tău răspunde cu **410 Gone**, oprim
reîncercările *și* punem endpoint-ul pe pauză, cu motivul înregistrat în
dashboard. Acesta e sensul pe care HTTP însuși îl dă acestui status, și îți
permite să renunți la o automatizare fără să te mai întorci aici.
Reactivează-l cu **Reia** când receptorul revine.
:::

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 |

:::callout{severity=nit title="De ce regulile pentru adresă sunt stricte"}
URL-ul tău e preluat de serverele noastre, din interiorul rețelei noastre.
Dacă am urma redirecturile sau am permite adrese private, oricine ar putea
folosi acest formular ca să ne facă să preluăm propriile noastre servicii
interne și să raportăm înapoi — clasicul SSRF. Verificarea rulează pe IP-ul
rezolvat la fiecare conexiune, nu doar pe șirul pe care l-ai scris.
:::

## 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 |
