# Draftful webhooks

Draftful sends an HTTPS `POST` to your endpoint when something happens to an article in your workspace. Endpoints are configured in Settings → Integrations (owners and admins; up to 5 per workspace), each subscribed to the events it should receive.

Human-readable page: https://draftful.app/integrations#webhooks

## Request

- Method: `POST`
- Content-Type: `application/json`
- Body: a JSON envelope (below), UTF-8
- Headers:
  - `X-Draftful-Event` — the event name, same as `event` in the body
  - `X-Draftful-Delivery` — the delivery id, same as `delivery_id` in the body
  - `X-Draftful-Signature` — `t=<unix timestamp>,v1=<hex HMAC-SHA256>` (see Verifying signatures)

## Envelope

Every delivery has the same outer shape:

```json
{
  "event": "article.published",
  "delivery_id": "0b8f1c1e-6a57-4d0e-9d6a-3f4c2b1a9e2a",
  "occurred_at": "2026-09-22T14:03:00+00:00",
  "data": { }
}
```

- `event` — one of the event names below.
- `delivery_id` — UUID. Stable across retries of the same delivery: use it to deduplicate.
- `occurred_at` — ISO 8601 timestamp of when the event happened (not when this attempt was sent).
- `data` — the event payload. Its shape depends on the event.

## Events

| Event | When it fires |
|---|---|
| `article.generation.started` | An article started generating. |
| `article.generation.completed` | The first draft of an article is written. Fact-check and images may still be running and can still change the content. |
| `article.generation.failed` | Generation failed, or was stopped after getting stuck. |
| `article.published` | An article was published (manually, by Autopilot at the Publish level, or when a scheduled article's date arrives — never at scheduling time). `published_at` is the moment it went live. |
| `article.publish_failed` | A publish attempt was refused or errored; the `article.published` you may be waiting for did not happen. |
| `article.updated` | An already-published article changed (edit or version restore). Not sent for the publish itself. |
| `article.scheduled` | A suggestion was scheduled for a date. Unscheduling sends nothing. |
| `article.unpublished` | A published article went back to draft: take the page down. (`article.updated` also fires for the same change.) A published article can't be deleted without unpublishing it first. |
| `webhook.test` | Only from "Send test" in Settings. Sent regardless of the endpoint's subscribed events; cannot be subscribed to. |

### Schema version

Every `data` payload carries `schema_version`. `1` is the shape documented below before this section; `2` is additive on top of it — every v1 key keeps the same meaning, nothing was removed or renamed. Check `schema_version` (or just read the new keys defensively) rather than assuming either shape.

### Article fields

Most events carry this article summary in `data`:

| Field | Type | Notes |
|---|---|---|
| `id` | integer | Article id. |
| `title` | string \| null | |
| `slug` | string \| null | |
| `status` | string | `draft`, `generating`, `ready`, `failed` or `published`. |
| `word_count` | integer \| null | |
| `published_at` | string \| null | ISO 8601; null until published. |
| `workspace_slug` | string | The workspace the article belongs to. |
| `schema_version` | integer | `2`. |

### `article.generation.started`, `article.generation.completed`, `article.generation.failed`

`data` is exactly the article summary above (schema_version 2, no new fields for these three — they fire before there's a live page to describe).

```json
{
  "id": 4821,
  "title": "How AI engines pick which pages to cite",
  "slug": "how-ai-engines-pick-pages-to-cite",
  "status": "ready",
  "word_count": 1420,
  "published_at": null,
  "workspace_slug": "acme",
  "schema_version": 2
}
```

`article.generation.completed` never includes `structured_data` or content: the pipeline may still rewrite both after it fires.

### `article.published`

The article summary plus everything needed to put the page live:

| Field | Type | Notes |
|---|---|---|
| `structured_data` | object \| null | schema.org JSON-LD (`BlogPosting` and related), ready for a `<script type="application/ld+json">` tag. Includes `author` (`Person` when a default author is set on the workspace, `Organization` otherwise) and, once the live URL is known, `url`/`mainEntityOfPage`. |
| `refresh_of_url` | string \| null | When set, this article is a refresh of an existing page: replace the content at this URL instead of creating a new page. |
| `content_markdown` | string | The article body in Markdown. |
| `content_html` | string | The same body rendered to an HTML fragment — the same rendering as the HTML export (`<picture>`/AVIF source, `width`/`height`, lazy loading, `rel="noopener"` on external links). No `<html>`/`<head>` wrapper. Wrap it in your own layout. |
| `meta_title` | string \| null | SEO title. |
| `meta_description` | string \| null | SEO description. |
| `excerpt` | string \| null | Short summary; defaults to `meta_description` when the writer never set one. |
| `canonical_url` | string \| null | Set by the writer, or (once `published_url` is known and no canonical was set) mirrors `published_url`. |
| `language` | string | BCP 47 (e.g. `en`, `pt-BR`, `es`). |
| `scheduled_for` | string \| null | ISO 8601. Set only when this publish came from a scheduled suggestion. |
| `featured_image` | object \| null | `{ "url", "avif_url": string \| null, "alt", "width": number \| null, "height": number \| null }` — the article's cover image. |
| `images` | array | Inline body images in reading order, same shape as `featured_image`'s object, excluding the cover. |
| `categories` | string[] | Defaults to the pillar topic of the article's keyword; `[]` if none. |
| `tags` | string[] | Up to 5 of the brief's secondary keywords by default. |
| `author` | object | `{ "type": "person" \| "organization", "name": string, "url": string \| null }` — the workspace's default author when set, otherwise the workspace itself. |
| `published_url` | string \| null | The live URL, when Draftful already knows it (e.g. a republish). Usually `null` on the first `article.published` for a page — see "Learning the published URL" below. |
| `published_url_callback` | string | A signed URL, valid for 7 days, to `POST` `{"url": "..."}` to once you know where the page lives. |
| `og_image_url` | string \| null | A 1200×630 JPG share card cut from the cover, for `og:image`/`twitter:image` (WhatsApp, LinkedIn and Facebook handle WebP/AVIF unevenly). `null` when the article has no cover. |
| `faqs` | array | `[{ "question", "answer" }]` — the article's FAQ, the same pairs used for a `FAQPage` in `structured_data`. `[]` when there is none. |
| `content_markdown_without_faq` | string | `content_markdown` without the FAQ section, for when you render `faqs` as a block of its own (so it isn't shown twice). Equal to `content_markdown` when there is no FAQ. |
| `content_html_without_faq` | string | The same, rendered like `content_html`. |
| `reading_minutes` | integer \| null | Estimated reading time at ~200 words per minute. |

Image URLs are absolute and stable: a file a published article used is never deleted, even when its images are regenerated (new images get new URLs and arrive in `article.updated`), so you can store and hotlink them.

```json
{
  "id": 4821,
  "title": "How AI engines pick which pages to cite",
  "slug": "how-ai-engines-pick-pages-to-cite",
  "status": "published",
  "word_count": 1420,
  "published_at": "2026-09-22T14:03:00+00:00",
  "workspace_slug": "acme",
  "schema_version": 2,
  "structured_data": { "@context": "https://schema.org", "@type": "BlogPosting", "headline": "How AI engines pick which pages to cite" },
  "refresh_of_url": null,
  "content_markdown": "# How AI engines pick which pages to cite\n\n...",
  "content_html": "<h1>How AI engines pick which pages to cite</h1>\n<p>...</p>",
  "meta_title": "How AI engines pick which pages to cite | Acme",
  "meta_description": "A look at the signals AI answer engines use to choose citations.",
  "excerpt": "A look at the signals AI answer engines use to choose citations.",
  "canonical_url": null,
  "language": "en",
  "scheduled_for": null,
  "featured_image": { "url": "https://cdn.draftful.app/a/4821/cover.jpg", "avif_url": "https://cdn.draftful.app/a/4821/cover.avif", "alt": "A search results page with AI-generated citations", "width": 1600, "height": 900 },
  "images": [],
  "categories": ["AI Search"],
  "tags": ["ai overviews", "answer engines", "citations"],
  "author": { "type": "organization", "name": "Acme", "url": "https://acme.com" },
  "published_url": null,
  "published_url_callback": "https://draftful.app/api/v1/publish-callbacks/articles/4821?expires=1790172180&signature=…",
  "og_image_url": "https://cdn.draftful.app/a/4821/cover-og-x7Kp2QaZ.jpg",
  "faqs": [{ "question": "Do AI engines cite paywalled pages?", "answer": "Rarely: they cite what they can read." }],
  "content_markdown_without_faq": "# How AI engines pick which pages to cite\n\n...",
  "content_html_without_faq": "<h1>How AI engines pick which pages to cite</h1>\n<p>...</p>",
  "reading_minutes": 8
}
```

### `article.updated`

For an article whose `status` is `published`, `data` is the same full v2 shape as `article.published` (so you can update the live post's content, images, categories/tags, author and structured data). For a non-published article, `data` stays the minimal article summary plus `schema_version: 2` — there's no live page to update yet. That includes the unpublish itself (`status: "draft"`): don't upsert that minimal shape over a stored post; take the post down (or use `article.unpublished`).

### `article.unpublished`

| Field | Type | Notes |
|---|---|---|
| `id` | integer | The article id. |
| `slug` | string | The slug it was published under. |
| `status` | string | `draft`. |
| `workspace_slug` | string | |
| `published_url` | string \| null | The live URL, when Draftful knows it. |
| `unpublished_at` | string | ISO 8601. |
| `schema_version` | integer | `2`. |

```json
{
  "id": 4821,
  "slug": "how-ai-engines-pick-pages-to-cite",
  "status": "draft",
  "workspace_slug": "acme",
  "published_url": "https://acme.com/blog/how-ai-engines-pick-pages-to-cite",
  "unpublished_at": "2026-10-02T09:12:00+00:00",
  "schema_version": 2
}
```

### `article.publish_failed`

| Field | Type | Notes |
|---|---|---|
| `id`, `title`, `slug`, `status`, `workspace_slug` | | As in the article summary. No `word_count` or `published_at`. |
| `reason` | string | The first blocker. |
| `blockers` | string[] | Every reason the publish was refused. |

Blocker values:

- `fact_check_pending` — the fact-check has not finished.
- `images_pending` — images are still being generated.
- `unresolved_placeholder` — the content still contains a placeholder that needs a real value.
- `blocked_terms` — the title, meta or body contains a term the workspace blocks from publishing (Settings → Workspace).
- `emoji` — the article contains an emoji and the workspace blocks them.
- `internal_error` — the publish failed on Draftful's side; retrying later may work.

```json
{
  "id": 4821,
  "title": "How AI engines pick which pages to cite",
  "slug": "how-ai-engines-pick-pages-to-cite",
  "status": "ready",
  "workspace_slug": "acme",
  "reason": "images_pending",
  "blockers": ["images_pending"]
}
```

### `article.scheduled`

This event is about a **suggestion**, not an article: the article does not exist until the suggestion is accepted. `id` is the suggestion id — do not look it up as an article id.

| Field | Type | Notes |
|---|---|---|
| `id` | integer | The suggestion id (same as `suggestion_id`). |
| `suggestion_id` | integer | |
| `title` | string | Suggested title. |
| `scheduled_for` | string | Date, `YYYY-MM-DD`. |
| `workspace_slug` | string | |

```json
{
  "id": 912,
  "suggestion_id": 912,
  "title": "Striking distance: the 4–10 position trap",
  "scheduled_for": "2026-10-01",
  "workspace_slug": "acme"
}
```

### `webhook.test`

Never contains real article data.

```json
{
  "message": "This is a test delivery from Draftful.",
  "workspace_slug": "acme"
}
```

## Verifying signatures

Each endpoint has a signing secret, shown once when the endpoint is created or its secret is rotated. The `X-Draftful-Signature` header looks like:

```
t=1790085780,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

To verify:

1. Read the **raw request body** exactly as received. Do not parse and re-serialize the JSON first — any byte difference breaks the signature.
2. Split the header on `,` and take `t` (unix seconds) and `v1` (hex).
3. Compute `HMAC-SHA256(secret, "{t}.{raw_body}")` as lowercase hex.
4. Compare it to `v1` with a constant-time comparison.
5. Reject the delivery if `t` is more than 5 minutes away from your current time (replay protection).

Respond `401` (or any 4xx) when verification fails.

### Node.js

```js
import crypto from "node:crypto";

export function verifyDraftfulSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(header ?? "").split(",").map((part) => part.split("=", 2))
  );
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(parts.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: keep the raw body for this route.
// app.post("/webhooks/draftful", express.raw({ type: "application/json" }), (req, res) => {
//   const raw = req.body.toString("utf8");
//   if (!verifyDraftfulSignature(raw, req.get("X-Draftful-Signature"), process.env.DRAFTFUL_WEBHOOK_SECRET)) {
//     return res.sendStatus(401);
//   }
//   const event = JSON.parse(raw);
//   res.sendStatus(200);
// });
```

### PHP

```php
function verifyDraftfulSignature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', (string) $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, null);
        $parts[$key] = $value;
    }

    if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
        return false;
    }

    $timestamp = (int) $parts['t'];
    if (abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);

    return hash_equals($expected, $parts['v1']);
}

// $raw = file_get_contents('php://input');           // plain PHP
// $raw = $request->getContent();                       // Laravel / Symfony
// verifyDraftfulSignature($raw, $_SERVER['HTTP_X_DRAFTFUL_SIGNATURE'] ?? null, getenv('DRAFTFUL_WEBHOOK_SECRET'));
```

### Python

```python
import hashlib
import hmac
import time


def verify_draftful_signature(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part)
    try:
        timestamp = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(time.time() - timestamp) > tolerance:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)


# Flask:   verify_draftful_signature(request.get_data(), request.headers.get("X-Draftful-Signature"), os.environ["DRAFTFUL_WEBHOOK_SECRET"])
# FastAPI: verify_draftful_signature(await request.body(), request.headers.get("x-draftful-signature"), os.environ["DRAFTFUL_WEBHOOK_SECRET"])
```

## Delivery and retries

- **At least once.** The same delivery can arrive more than once. Deduplicate on `delivery_id` (stable across retries) and make your handler idempotent.
- **Success** is any `2xx` response. Respond quickly and do slow work after responding.
- **Timeout:** each attempt waits up to 10 seconds for your response.
- **Retries:** a `5xx`, a timeout or a connection error is retried — up to 3 attempts in total, backing off 10 s, 60 s and 300 s. Draftful stops retrying within 1 hour of the event.
- **Not retried:** a `4xx` means your endpoint rejected the event on purpose. A `3xx` is also treated as a rejection: redirects are never followed, so register the final URL.
- **Order is not guaranteed.** Use `occurred_at`, and the article's current state, rather than arrival order.
- **URL rules:** the endpoint must be a public `https://` URL. Private, loopback and internal addresses are refused, both when registering and at delivery time.
- **Current settings win:** each attempt uses the endpoint as it is at that moment. If the endpoint was deleted, deactivated or unsubscribed from the event meanwhile, the delivery is dropped.

## Reporting the live URL

Draftful needs to learn where each article actually went live, for the canonical URL, structured data and your dashboard. Two ways to report it — use whichever fits your receiver:

1. **Respond with the URL.** If your `2xx` response to `article.published` or `article.updated` has a JSON body with a string `url` (or `published_url`) field that is a public `http(s)` URL, Draftful reads it from the response — no extra request needed.

   ```json
   { "url": "https://acme.com/blog/how-ai-engines-pick-pages-to-cite" }
   ```

2. **Call back later.** If your receiver publishes asynchronously (a queue, a build step, a CMS webhook of its own), `POST` `{"url": "..."}` to the `published_url_callback` URL from the payload once the page is live. It's a signed URL — no session or API key needed — valid for 7 days, and returns `204` on success.

   ```bash
   curl -X POST "$PUBLISHED_URL_CALLBACK" \
     -H "Content-Type: application/json" \
     -d '{"url": "https://acme.com/blog/how-ai-engines-pick-pages-to-cite"}'
   ```

You can also set `published_url` by hand in the article editor. Whichever way it arrives, Draftful records when it changed and, if the URL's host matches your workspace's domain (or one of its brand domains), adds it to your tracked links.

## Testing and rotating the secret

- **Send test** (Settings → Integrations) sends a signed `webhook.test` delivery immediately and shows the status code and latency. It is not retried.
- **Rotate secret** issues a new signing secret and shows it once. The old secret stops working immediately, so update your receiver's secret right after rotating.
- Each endpoint shows its last delivery result (success or failure) and when it happened.

## Autopilot

Autopilot at the Publish level uses an active endpoint subscribed to `article.published` as its publish destination, the same as a manual publish.
