Events · HTTP POST
Instant article delivery to your server — no polling, no API keys.
Instant article delivery to your server — no polling, no API keys.
Register your HTTPS endpoint URL below. VellumUp will POST to it whenever an event fires.
Each delivery is signed with HMAC-SHA256. Verify the X-VellumUp-Signature header to confirm it came from us.
Parse the JSON payload, save the article to your database, trigger a rebuild, or do anything you like.
Your endpoint receives one of these events on every delivery — read X-VellumUp-Event header to route accordingly
article.publishedFired when a new article is generated, or when the article status is changed to published
article.unpublishedFired when an article is moved back to draft status
article.updatedFired when an article's title, content, keywords, or cover image is edited and saved
article.translatedFired each time a language translation is saved — includes language_code and language_name fields in the data object
Every delivery contains the full article in its current state. All events share the same fields — only article.translated adds language_code and language_name.
slug field to build the article URL on your site. It is URL-safe, unique per domain, and stable across updates.| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique article ID in your account |
| slug | string | URL-friendly identifier — build your article URL from this (e.g. yourblog.com/blog/slug) |
| title | string | Full article title |
| content | string | Full article body. Markdown by default — or HTML if you selected HTML format when creating the endpoint |
| cover_image | string | null | AI-generated cover image URL, or null if image generation was skipped |
| cover_image_url | string | null | Same as cover_image (explicit naming for clarity) |
| meta_description | string | null | SEO meta description, up to 160 characters |
| focus_keyword | string | null | Primary SEO keyword the article targets |
| secondary_keywords | string[] | Supporting keywords included in the content |
| faq | { question, answer }[] | Array of question/answer objects — ready to render as an FAQ section |
| word_count | number | Approximate word count of the content |
| reading_time_minutes | number | Estimated reading time in minutes (word_count ÷ 200) |
| website_url | string | null | Full URL of the connected website |
| website_domain | string | Domain of the connected website (e.g. yourblog.com) |
| status | "published" | "draft" | Current article status |
| og_title | string | Article title for social sharing & OG tags |
| og_description | string | Meta description (fallback to first 155 chars) |
| og_type | "article" | Content type for Open Graph metadata |
| created_at | string (ISO 8601) | When the article was first generated |
| updated_at | string (ISO 8601) | When the article was last modified |
| language_code | string (BCP-47) | Only on article.translated — BCP-47 code e.g. "fr", "es", "de", "ar" |
| language_name | string | Only on article.translated — human-readable name e.g. "French" |
Example payload
{
"id": "del_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"created_at": "2026-04-22T10:30:00.000Z",
"data": {
"id": "f7e6d5c4-b3a2-1098-fedc-ba9876543210",
"slug": "how-to-improve-your-seo-in-2026",
"title": "How to Improve Your SEO in 2026",
"content": "# How to Improve Your SEO in 2026\n\nSearch engine optimization has evolved...",
"cover_image": "https://storage.supabase.co/article-images/user123/ai/hero_abc123.jpg",
"cover_image_url": "https://storage.supabase.co/article-images/user123/ai/hero_abc123.jpg",
"meta_description": "Learn proven SEO strategies for 2026. From technical optimization to content strategy.",
"focus_keyword": "improve SEO 2026",
"secondary_keywords": ["on-page SEO", "SEO tips", "search ranking", "technical SEO"],
"faq": [
{
"question": "How long does SEO take to show results?",
"answer": "Typically 3–6 months for competitive keywords."
},
{
"question": "What is the most important SEO factor?",
"answer": "High-quality, relevant content remains the top ranking factor."
}
],
"word_count": 1840,
"reading_time_minutes": 9,
"website_url": "https://yourblog.com",
"website_domain": "yourblog.com",
"status": "published",
"og_title": "How to Improve Your SEO in 2026",
"og_description": "Learn proven SEO strategies for 2026. From technical optimization to content strategy.",
"og_type": "article",
"created_at": "2026-04-22T10:30:00.000Z",
"updated_at": "2026-04-22T10:30:00.000Z"
}
}How signing works
Each delivery includes an X-VellumUp-Signature header formatted as t=TIMESTAMP,v1=SHA256_HEX. To verify: concatenate the timestamp and the raw request body as "TIMESTAMP.RAW_BODY", compute HMAC-SHA256 using your signing secret, and compare with v1 using a constant-time function. Always use the raw body — not parsed JSON — for the comparison.
Verify signature — code examples
const crypto = require('crypto');
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // whsec_...
function verifySignature(rawBody, sigHeader, secret) {
if (!sigHeader) return false;
// Parse "t=<timestamp>,v1=<hmac>"
const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
const { t: ts, v1: received } = parts;
if (!ts || !received) return false;
// Reject requests older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}Complete handlers — verify the signature, respond 200 immediately, then process the article data in the background.
// app/api/webhook/route.ts
import { NextRequest } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
const SECRET = process.env.WEBHOOK_SECRET!;
function verify(rawBody: string, sig: string): boolean {
const parts = Object.fromEntries(sig.split(',').map(p => p.split('=')));
const { t: ts, v1 } = parts;
if (!ts || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac('sha256', SECRET)
.update(`${ts}.${rawBody}`)
.digest('hex');
try {
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
} catch { return false; }
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get('x-vellumup-signature') ?? '';
const event = req.headers.get('x-vellumup-event') ?? '';
if (!verify(rawBody, sig)) {
return new Response('Unauthorized', { status: 401 });
}
const { data } = JSON.parse(rawBody);
if (event === 'article.published') {
// Save to your database
// await db.articles.upsert({ where: { slug: data.slug }, ... })
console.log('New article:', data.title, '→', data.slug);
}
if (event === 'article.translated') {
// Save translation
console.log(`[${data.language_code}] ${data.title}`);
}
if (event === 'article.updated') {
// Update existing article
console.log('Updated:', data.title);
}
return new Response('OK', { status: 200 });
}Sync to Database or CMS
Save incoming articles directly to your database or headless CMS the moment they are generated — no manual export needed.
// Upsert article into your database on publish
if (event === 'article.published') {
await db.articles.upsert({
where: { slug: data.slug },
update: { title: data.title, content: data.content, updatedAt: new Date() },
create: { slug: data.slug, title: data.title, content: data.content },
});
}Trigger a Site Rebuild
Call your hosting provider's deploy hook (Vercel, Netlify, etc.) to rebuild your static site whenever a new article arrives.
// Trigger a Vercel rebuild after publish
if (event === 'article.published') {
await fetch(process.env.VERCEL_DEPLOY_HOOK_URL, { method: 'POST' });
}
// Or for Netlify:
if (event === 'article.published') {
await fetch(process.env.NETLIFY_BUILD_HOOK, { method: 'POST' });
}Multi-language Content Sync
Listen for article.translated events to automatically sync translated versions to the correct locale pages on your site.
// Save translations to the correct locale path
if (event === 'article.translated') {
await db.translations.upsert({
where: { slug_locale: { slug: data.slug, locale: data.language_code } },
update: { title: data.title, content: data.content },
create: { slug: data.slug, locale: data.language_code,
title: data.title, content: data.content },
});
}Send Notifications
Post to Slack, send an email, or push a notification to your team whenever a new article is published or updated.
// Post a Slack notification when a new article is published
if (event === 'article.published') {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📝 New article published: *${data.title}*
${data.website_url}/${data.slug}`,
}),
});
}// 1. Install react-markdown
// npm install react-markdown
// 2. components/Article.tsx
import ReactMarkdown from 'react-markdown';
interface Article {
title: string;
content: string; // raw Markdown from webhook
slug: string;
cover_image?: string | null;
}
export function Article({ article }: { article: Article }) {
return (
<article>
{article.cover_image && (
<img src={article.cover_image} alt={article.title} />
)}
<h1>{article.title}</h1>
<div className="prose">
<ReactMarkdown>{article.content}</ReactMarkdown>
</div>
</article>
);
}
// 3. With syntax highlighting (optional)
// npm install react-markdown remark-gfm rehype-highlight
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{article.content}
</ReactMarkdown>Signature verification fails
Make sure you are using the raw request body — before any JSON parsing. Many frameworks parse the body automatically; configure them to pass raw bytes for your webhook route.
Receiving duplicate deliveries
VellumUp may retry failed deliveries. Make your handler idempotent by checking the article slug before inserting, or use a unique constraint on the slug column in your database.
Endpoint times out (delivery marked failed)
VellumUp waits up to 8 seconds for a response. Respond with HTTP 200 immediately, then process the payload asynchronously in a background job or queue.
Null fields in the payload
Fields like cover_image, meta_description, and focus_keyword can be null. Always handle null values explicitly in your code instead of assuming they are present.
Test delivery succeeds but real deliveries fail
Test events use a simplified payload. Ensure your handler handles all event types (article.published, article.updated, article.translated) and does not crash on unexpected fields.
Always verify the signature
Never process a payload without verifying the HMAC-SHA256 signature. This ensures the request came from VellumUp and was not tampered with.
Reject old timestamps
Discard any request where |now − timestamp| > 300 seconds. This prevents replay attacks where an attacker re-sends a previously captured valid request.
Use the raw request body
Sign the raw bytes as received — before any JSON parsing. Parsing and re-serialising can change whitespace and break the signature check.
Respond quickly, process async
Return HTTP 200 within a few seconds or VellumUp will mark the delivery as failed. Offload heavy processing (DB writes, rebuilds) to a background queue.