> For the complete documentation index, see [llms.txt](https://docs.tryholo.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.tryholo.ai/holo-docs/seo/seo-webhooks.md).

# SEO Webhooks

Webhooks send published Holo SEO articles to a URL you control — your CMS, a static site, Zapier, Make, n8n, or any service that accepts HTTPS POST with JSON.

## Quick start

You need a **public HTTPS address** that can receive JSON (from Zapier, your host, or your own server). Local HTTP works only in Holo local/dev.

In the brand **SEO publishing** settings, open the **Webhook** card and follow the steps in the sidepanel:

{% stepper %}
{% step %}

#### Paste your webhook URL

Paste the address Holo should POST to when an article publishes or updates.
{% endstep %}

{% step %}

#### Choose authentication

* **Bearer token** — easiest for Zapier, Make, and n8n. Holo sends `Authorization: Bearer <secret>`.
* **Signature (HMAC)** — best when you write custom server code and want tamper-proof verification.
  {% endstep %}

{% step %}

#### Connect

Holo creates the webhook and shows a **signing secret once**.
{% endstep %}

{% step %}

#### Copy and save the secret

Paste it into the same tool where you set the URL (a “Secret” or “Password” field in Zapier, your hosting dashboard, or a server setting). Holo does not show the full secret again.
{% endstep %}

{% step %}

#### Send test

Confirm your endpoint returns **2xx**. The test payload includes `"test": true`.
{% endstep %}
{% endstepper %}

After that, every publish or update POSTs the article JSON to your URL.

### No-code (Zapier / Make / n8n)

Use **Bearer token**. Create a webhook trigger, paste Holo’s URL, paste the secret into the tool’s auth/password field, send test, then map fields from the JSON (title, slug, `contentHtml`, etc.).

### Custom code

Use **Signature (HMAC)** when you control the server. See [Authentication](#authentication) and [Code examples](#code-examples) below.

## Use cases

* Custom CMS / static site generators (Next.js, Hugo, Jekyll, Astro)
* PHP platforms (PrestaShop, Drupal, custom PHP apps)
* Automation tools (Zapier, Make, n8n)
* Serverless functions (AWS Lambda, Vercel, Cloudflare Workers)
* Notification channels (Slack, Discord, email bridges)

## Authentication

Every request includes headers so you can verify it came from Holo.

### HMAC signature (recommended for custom servers)

* Header: `X-Holo-Signature: sha256=<hex>`
* Compute `HMAC-SHA256(secret, rawBody)` over the **exact raw request body bytes**, hex-encode the digest, and prefix with `sha256=`.
* Compare with a **constant-time** equality check (`crypto.timingSafeEqual` in Node, `hash_equals` in PHP).
* Also sent: `X-Holo-Event`, `X-Holo-Delivery`, `User-Agent: Holo-Webhooks/1.0`.

{% hint style="warning" %}
**Raw-body gotcha:** middleware that parses JSON before your verifier (Express `express.json()`, Next.js default body parser, many frameworks) replaces the body and breaks the signature. Read the raw bytes first, verify, then `JSON.parse`.
{% endhint %}

### Bearer token (simpler — no-code and static headers)

* Header: `Authorization: Bearer <secret>`
* Ideal for Zapier / Make / n8n or endpoints that cannot access the raw body.
* No signature header in this mode.

Pick HMAC when you control the server and want tamper evidence. Pick bearer when the platform only supports a static Authorization header.

## Code examples

{% tabs %}
{% tab title="Node.js — HMAC (recommended)" %}

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.HOLO_WEBHOOK_SECRET;

export const holoWebhookHandler = async (req, res) => {
  if (req.method !== 'POST') {
    res.statusCode = 405;
    res.end('Method Not Allowed');
    return;
  }

  const rawBody = await readRawBody(req);
  const signatureHeader = req.headers['x-holo-signature'];
  const expected = `sha256=${createHmac('sha256', SECRET).update(rawBody).digest('hex')}`;

  const provided = Buffer.from(String(signatureHeader ?? ''), 'utf8');
  const expectedBuf = Buffer.from(expected, 'utf8');
  if (provided.length !== expectedBuf.length || !timingSafeEqual(provided, expectedBuf)) {
    res.statusCode = 401;
    res.end(JSON.stringify({ message: 'Invalid signature' }));
    return;
  }

  const payload = JSON.parse(rawBody.toString('utf8'));

  if (payload.test) {
    res.statusCode = 200;
    res.end(JSON.stringify({ message: 'Test webhook received' }));
    return;
  }

  res.statusCode = 200;
  res.end(JSON.stringify({ url: `https://example.com/blog/${payload.article.slug}` }));
};

const readRawBody = (req) =>
  new Promise((resolve, reject) => {
    const chunks = [];
    req.on('data', (chunk) => chunks.push(chunk));
    req.on('end', () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });
```

Disable the framework body parser on this route (Next.js: `export const config = { api: { bodyParser: false } }`, Express: register this route before `express.json()`).
{% endtab %}

{% tab title="Node.js — Bearer" %}

```js
export const holoWebhookBearerHandler = async (req, res) => {
  if (req.method !== 'POST') {
    res.statusCode = 405;
    res.end('Method Not Allowed');
    return;
  }

  const expected = `Bearer ${process.env.HOLO_WEBHOOK_SECRET}`;
  if (req.headers.authorization !== expected) {
    res.statusCode = 401;
    res.end(JSON.stringify({ message: 'Invalid authorization' }));
    return;
  }

  const payload = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;

  if (payload.test) {
    res.statusCode = 200;
    res.end(JSON.stringify({ message: 'Test webhook received' }));
    return;
  }

  res.statusCode = 200;
  res.end(JSON.stringify({ message: 'ok' }));
};
```

{% endtab %}

{% tab title="PHP — HMAC (recommended)" %}

```php
<?php
$rawBody = file_get_contents('php://input');
$secret = getenv('HOLO_WEBHOOK_SECRET');

$signature = $_SERVER['HTTP_X_HOLO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['message' => 'Invalid signature']);
    exit;
}

$payload = json_decode($rawBody, true);

if (!empty($payload['test'])) {
    http_response_code(200);
    header('Content-Type: application/json');
    echo json_encode(['message' => 'Test webhook received']);
    exit;
}

http_response_code(200);
header('Content-Type: application/json');
echo json_encode([
    'url' => 'https://example.com/blog/' . $payload['article']['slug'],
]);
```

{% endtab %}

{% tab title="PHP — Bearer" %}

```php
<?php
$secret = getenv('HOLO_WEBHOOK_SECRET');
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
    ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
    ?? '';

if ($authHeader !== 'Bearer ' . $secret) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['message' => 'Invalid authorization']);
    exit;
}

$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true);

if (!empty($payload['test'])) {
    http_response_code(200);
    header('Content-Type: application/json');
    echo json_encode(['message' => 'Test webhook received']);
    exit;
}

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['message' => 'ok']);
```

{% endtab %}
{% endtabs %}

## Payload reference

Canonical source: `apps/api/src/shared/seo/webhooks/` (`SeoWebhookPayloadSchema`, constants, sample fixture). Regenerate with `pnpm --filter api docs:seo-webhooks`.

### Envelope + article fields

| Field                     | Type                                     | Description                                                                               |
| ------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------- |
| `event`                   | `article.published` or `article.updated` | Webhook event name. `article.published` on first publish; `article.updated` on republish. |
| `test`                    | boolean                                  | True for payloads sent via Send Test; false for real publish/update deliveries.           |
| `version`                 | `1`                                      | Payload contract version. Always `1` for this release.                                    |
| `deliveryId`              | string (uuid)                            | Stable UUID for this delivery. Identical across retry attempts — use it to dedupe.        |
| `sentAt`                  | string (datetime)                        | ISO-8601 timestamp when Holo sent this attempt.                                           |
| `brand`                   | object                                   | Brand that owns the article.                                                              |
| `brand.id`                | string (uuid)                            | Brand UUID.                                                                               |
| `brand.name`              | string                                   | Brand display name.                                                                       |
| `article`                 | object                                   | Published article body and metadata.                                                      |
| `article.id`              | string (uuid)                            | Stable blog-asset UUID. Prefer this (or slug) as your upsert key.                         |
| `article.slug`            | string                                   | URL-safe slug. Locked after first publish; stable across updates.                         |
| `article.title`           | string                                   | Article title.                                                                            |
| `article.excerpt`         | string                                   | Short summary / teaser.                                                                   |
| `article.contentHtml`     | string                                   | Full article body as HTML.                                                                |
| `article.contentMarkdown` | string                                   | Full article body as Markdown.                                                            |
| `article.metaTitle`       | string                                   | SEO title.                                                                                |
| `article.metaDescription` | string                                   | SEO meta description.                                                                     |
| `article.featuredImage`   | string (url) or null                     | Featured / OG image URL on Holo CDN, or null.                                             |
| `article.images`          | array of object                          | Inline images referenced by the article (Holo CDN URLs).                                  |
| `article.images.url`      | string (url)                             | Public image URL hosted on Holo CDN.                                                      |
| `article.images.alt`      | string                                   | Alt text for the image.                                                                   |
| `article.publishedAt`     | string (datetime)                        | ISO-8601 publish timestamp for the article.                                               |
| `article.isRepublish`     | boolean                                  | True when this delivery updates an already-published article; false on first publish.     |

### Events

| Value               | When                                                                     |
| ------------------- | ------------------------------------------------------------------------ |
| `article.published` | First publish of an article (`article.isRepublish: false`).              |
| `article.updated`   | Republish / update of an existing article (`article.isRepublish: true`). |

Test deliveries reuse these event names with `test: true`.

### HTTP headers

| Header             | Value                                                  |
| ------------------ | ------------------------------------------------------ |
| `X-Holo-Event`     | Event name (`article.published` or `article.updated`). |
| `X-Holo-Delivery`  | Same UUID as `deliveryId` — use for dedupe.            |
| `X-Holo-Signature` | HMAC mode only: `sha256=<hex>`.                        |
| `Authorization`    | Bearer mode only: `Bearer <secret>`.                   |
| `User-Agent`       | `Holo-Webhooks/1.0`                                    |
| `Content-Type`     | `application/json`                                     |

Auth types: `hmac` (recommended) or `bearer`. Payload `version` is always `1`.

### Example JSON

Sample fixture used by Send Test (`test: true`):

```json
{
  "event": "article.published",
  "test": true,
  "version": 1,
  "deliveryId": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
  "sentAt": "2026-03-15T10:30:00.000Z",
  "brand": {
    "id": "11111111-1111-4111-8111-111111111111",
    "name": "Acme"
  },
  "article": {
    "id": "22222222-2222-4222-8222-222222222222",
    "slug": "how-to-build-backlinks-for-better-seo",
    "title": "How to Build Backlinks for Better SEO",
    "excerpt": "Learn proven strategies to build high-quality backlinks.",
    "contentHtml": "<h1>How to Build Backlinks for Better SEO</h1><p>Building high-quality backlinks...</p>",
    "contentMarkdown": "# How to Build Backlinks for Better SEO\n\nBuilding high-quality backlinks...",
    "metaTitle": "How to Build Backlinks for Better SEO",
    "metaDescription": "Learn proven strategies to build high-quality backlinks.",
    "featuredImage": "https://project.supabase.co/storage/v1/object/public/creative-assets/brand/featured.png",
    "images": [
      {
        "url": "https://project.supabase.co/storage/v1/object/public/creative-assets/brand/inline.png",
        "alt": "Backlink strategy diagram"
      }
    ],
    "publishedAt": "2026-03-15T10:30:00.000Z",
    "isRepublish": false
  }
}
```

## Handling updates (upsert)

`article.slug` and `article.id` are stable across republishes. When an article is updated, Holo sends `event: "article.updated"` with `article.isRepublish: true`.

Upsert on `slug` (or `id`) so retries and updates do not create duplicates:

```js
await db.articles.upsert({
  where: { slug: payload.article.slug },
  create: {
    id: payload.article.id,
    slug: payload.article.slug,
    title: payload.article.title,
    contentHtml: payload.article.contentHtml,
  },
  update: {
    title: payload.article.title,
    contentHtml: payload.article.contentHtml,
    contentMarkdown: payload.article.contentMarkdown,
  },
});
```

Also dedupe with `deliveryId` / `X-Holo-Delivery` — retries of the same delivery reuse the same id.

## Delivery semantics

| Behavior      | Detail                                                                                                                                  |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Method        | `POST`, `Content-Type: application/json`                                                                                                |
| Timeout       | 10 seconds per attempt                                                                                                                  |
| Success       | Any HTTP **2xx** counts as delivered                                                                                                    |
| Live URL      | Optional JSON body `{ "url": "https://…" }` sets the article’s live URL in Holo (malformed bodies are ignored)                          |
| Retries       | Up to **8** attempts with exponential backoff (starts at ≥ 30s)                                                                         |
| Retryable     | Timeouts, network errors, **5xx**, **408**, **429**                                                                                     |
| Terminal      | Other **4xx** (for example 401/403) — no further retries                                                                                |
| Down endpoint | Delivery is retried on the schedule above; after retries exhaust, the connection is marked error until a later successful test/delivery |
| Images        | `featuredImage` and `article.images[].url` are Holo CDN URLs — render them directly; no re-hosting required                             |
| Endpoints     | **One webhook URL per brand** in v1 — multiple endpoints are not supported yet                                                          |

## Troubleshooting

### 401 — invalid signature

* Confirm you hash the **raw body**, not a re-serialized JSON object.
* Expect `X-Holo-Signature` value shaped `sha256=<hex>` (include the prefix in the comparison).
* Confirm the secret matches the connection (rotate if unsure).
* Disable body parsers that consume the stream before verification.

### 401 — invalid bearer

* Header must be exactly `Authorization: Bearer <secret>` (single space).
* Some PHP setups need `REDIRECT_HTTP_AUTHORIZATION` or an explicit `CGIPassAuth` / rewrite rule.

### 403 from a WAF / CDN

* Allowlist Holo’s User-Agent `Holo-Webhooks/1.0` and your webhook path.
* Disable bot challenges / body inspection that alter or block POSTs.

### Webhook not firing

* Confirm the brand’s SEO publishing provider is **Webhook** and status is connected.
* Publish or use **Send Test** — drafts do not deliver.
* Check delivery history in the product for status codes and truncated response bodies.

### Connection test failed

* Endpoint must be publicly reachable over HTTPS (except local Holo).
* Return 2xx quickly; slow handlers hit the 10s timeout.
* For HMAC, fix raw-body verification before expecting tests to pass.

## FAQ

<details>

<summary>Can I use Zapier / Make / n8n?</summary>

Yes. Use **bearer** auth and map JSON fields from the payload. HMAC is optional if the tool cannot expose the raw body.

</details>

<details>

<summary>Do I need to write code?</summary>

No for no-code tools with bearer auth. Custom sites typically use the Node or PHP examples above.

</details>

<details>

<summary>What if my endpoint is down?</summary>

Holo retries with backoff (see Delivery semantics). After retries are exhausted the connection shows an error until a later success.

</details>

<details>

<summary>Can I update articles after publishing?</summary>

Yes. Updates send `article.updated` with the same `slug` / `id`. Upsert on those keys.

</details>

<details>

<summary>Can I have multiple webhook URLs?</summary>

Not in v1 — one webhook endpoint per brand.

</details>

<details>

<summary>Is the data secure?</summary>

Use HTTPS. Prefer HMAC so the body cannot be forged without the secret. Keep the secret in a password field or secure server setting (developers often use an environment variable such as `HOLO_WEBHOOK_SECRET`); rotate from the product if it leaks.

</details>

<details>

<summary>HMAC vs bearer?</summary>

HMAC proves the body was signed with your secret. Bearer only proves the caller knows the secret; choose it for platforms that cannot verify signatures.

</details>

<details>

<summary>Can I test without publishing?</summary>

Yes — **Send Test** sends `test: true` through the same auth path.

</details>

<details>

<summary>Do webhooks include images?</summary>

Yes. Featured and inline image URLs are Holo-hosted CDN links ready to render.

</details>

<details>

<summary>How do I rotate the secret?</summary>

Use secret rotation in the webhook settings, update the secret wherever you saved it (Zapier, hosting, or `HOLO_WEBHOOK_SECRET` on your server), then Send Test. The previous secret stops working after rotation.

</details>

\
If something doesn’t work as expected, try the [troubleshooting steps](/holo-docs/troubleshooting/troubleshooting-steps.md) — and if the issue continues, feel free to reach out to <support@tryholo.ai>, and we’ll help you out.
