@42flowsdotcom/webhook
v0.1.1
Published
Receive 42flows-published articles via webhook in any JS framework. Handles Bearer auth, ping, payload validation, response shaping; you only write persistence.
Maintainers
Readme
@42flowsdotcom/webhook
Receive 42flows-published articles via webhook. Library handles Bearer auth, ping, payload parsing, validation, and response shaping. You write only the persistence callback.
Use this when your stack isn't WordPress / Shopify / Nuxt Content. For Nuxt Content sites, install
@42flowsdotcom/nuxt-contentinstead.
⚠️ READ FIRST: which body field do you store?
Every Flows42Article carries BOTH content_markdown and content_html. They are always populated. Pick one. Picking wrong is the #1 source of customer pain.
- Custom DB-backed blog (Express / Next / SvelteKit / custom CMS / WordPress via webhook): use
article.content_html. It's pre-rendered semantic HTML with inline styling andx42-*class names. Drops into any HTML renderer, no MDC processor needed. - Nuxt Content / Astro with MDX / any renderer that understands
::componentblocks: usearticle.content_markdown. Components hydrate as actual rich UI.
Do not strip MDC blocks from content_markdown. If you find yourself writing regex to remove ::hook-statistic, ::strategy-list, ::powered-by, ::json-ld, you're solving a problem that doesn't exist. The same payload has content_html, pre-rendered, no MDC. Read that field instead.
Install
npm install @42flowsdotcom/webhookQuick start (Express)
import express from 'express'
import { flows42Webhook } from '@42flowsdotcom/webhook/express'
const app = express()
app.use(express.json({ limit: '5mb' }))
app.post('/api/42flows/webhook', flows42Webhook({
apiKey: process.env.FLOWS42_API_KEY!,
onPublish: async (article) => {
// Persist however you want (DB, file, search index, queue).
await db.blog_posts.upsert({
where: { slug: article.slug },
create: {
slug: article.slug,
title: article.title,
body_html: article.content_html, // or content_markdown
meta_title: article.meta_title,
meta_description: article.meta_description,
published_at: new Date(article.published_at),
source: '42flows',
},
update: { /* same fields */ },
})
return { postUrl: `https://yoursite.com/blog/${article.slug}` }
},
}))
app.listen(3000)That's it. The library handles auth, ping, validation, error responses. Your onPublish callback runs only when a real article arrives, with a fully-typed article object. Returning a postUrl lets 42flows track where the article landed (used for delivery verification + dashboards).
Mental model
42flows publishes articles. You expose one HTTPS endpoint. We POST. Your onPublish callback decides what happens next.
The library prevents the most common bugs:
- Auth check: Bearer token comparison via constant-time compare
- Ping short-circuit: connection-test pings reply 2xx without ever reaching your
onPublish - Payload validation:
data.article.slug+ body presence checked before your callback runs - Error response shape: 4xx / 5xx responses include diagnostic body so 42flows surfaces clear errors in the dashboard
Adapters
Pick the adapter that matches your framework. The customer-side code (onPublish) is identical across all of them.
Express / Connect
import { flows42Webhook } from '@42flowsdotcom/webhook/express'
app.use(express.json({ limit: '5mb' }))
app.post('/api/42flows/webhook', flows42Webhook({ apiKey, onPublish }))Next.js (app router)
// app/api/42flows/webhook/route.ts
import { flows42NextRoute } from '@42flowsdotcom/webhook/next'
export const POST = flows42NextRoute({
apiKey: process.env.FLOWS42_API_KEY!,
onPublish: async (article) => {
await db.posts.upsert(/* ... */)
return { postUrl: `https://yoursite.com/blog/${article.slug}` }
},
})SvelteKit
// src/routes/api/42flows/webhook/+server.ts
import { flows42WebRouteHandler } from '@42flowsdotcom/webhook/web'
import { FLOWS42_API_KEY } from '$env/static/private'
export const POST = flows42WebRouteHandler({
apiKey: FLOWS42_API_KEY,
onPublish: async (article) => {
await db.posts.upsert(/* ... */)
return { postUrl: `/blog/${article.slug}` }
},
})Astro
// src/pages/api/42flows/webhook.ts
import type { APIRoute } from 'astro'
import { flows42WebRouteHandler } from '@42flowsdotcom/webhook/web'
const handler = flows42WebRouteHandler({
apiKey: import.meta.env.FLOWS42_API_KEY,
onPublish: async (article) => {
// Persist however
return { postUrl: `https://yoursite.com/blog/${article.slug}` }
},
})
export const POST: APIRoute = ({ request }) => handler(request)Hono
import { Hono } from 'hono'
import { flows42WebRouteHandler } from '@42flowsdotcom/webhook/web'
const handler = flows42WebRouteHandler({
apiKey: process.env.FLOWS42_API_KEY!,
onPublish: async (article) => {
/* ... */
return { postUrl: `https://yoursite.com/blog/${article.slug}` }
},
})
const app = new Hono()
app.post('/api/42flows/webhook', (c) => handler(c.req.raw))Cloudflare Workers / Bun.serve / Deno
import { flows42WebRouteHandler } from '@42flowsdotcom/webhook/web'
const handler = flows42WebRouteHandler({
apiKey: env.FLOWS42_API_KEY,
onPublish: async (article) => { /* ... */ },
})
export default {
async fetch(request: Request): Promise<Response> {
if (new URL(request.url).pathname === '/api/42flows/webhook') {
return handler(request)
}
return new Response('Not Found', { status: 404 })
},
}The article shape
interface Flows42Article {
slug: string // URL-safe, max 80 chars
title: string
content_type: 'guide' | 'strategy' | 'comparison' | 'problem_solution' | 'concept' | 'tool'
meta_title: string // ≤60 chars
meta_description: string // ≤160 chars
primary_keyword: string
secondary_keywords: string[]
published_at: string // ISO 8601
content_markdown: string // MDC markdown body. Use if you store markdown
content_html: string // Semantic HTML body. Use if you render HTML
json_ld: Array<Record<string, unknown>> // schema.org Article + FAQ + HowTo blocks
backbone: Record<string, unknown> // Full structured backbone (advanced renderers)
}All fields are always present. Pick content_markdown OR content_html for your storage; ignore the other.
Options reference
flows42Webhook({
// Required
apiKey: 'string',
onPublish: async (article, meta) => { /* return { postUrl? } */ },
// Optional
onPing: async () => { /* invoked on connection-test pings; library replies 2xx regardless */ },
onError: async ({ stage, message, httpStatus }) => {
// Invoked on any 4xx/5xx response. Useful for structured logging.
// stage: 'auth' | 'parse' | 'validate' | 'persist'
},
})How 42flows talks to your endpoint
Connection test (ping)
When you connect a webhook site in the 42flows dashboard, 42flows POSTs:
POST <your-webhook-url>
Authorization: Bearer <FLOWS42_API_KEY>
Content-Type: application/json
{ "event_type": "ping", "timestamp": "...", "data": { "message": "..." } }The library short-circuits this and replies 200 { ok: true, event_type: "ping" }. Your onPublish is never called for pings.
Real article delivery
POST <your-webhook-url>
Authorization: Bearer <FLOWS42_API_KEY>
Content-Type: application/json
{
"event_type": "publish_article",
"timestamp": "...",
"flow_type": "origin",
"data": {
"article": { /* Flows42Article, see shape above */ }
}
}The library validates auth + payload, then calls await onPublish(article, meta). Your callback's return value ({ postUrl }) becomes the response 42flows tracks.
Errors
The library returns these on its own (your onPublish is never invoked):
| Status | When |
|---|---|
| 401 | Missing or wrong Bearer token |
| 400 | Body isn't valid JSON, or event_type missing/unknown |
| 400 | data.article.slug missing, or both content_markdown and content_html empty |
If your onPublish throws, the library returns 500 with the exception message. 42flows captures the response body (truncated to 2KB) and shows it in the activity log so you can debug.
Migration from a hand-rolled handler
If you already have a working webhook handler, the library replaces about 30 lines of boilerplate with 5 lines of persistence logic. Before:
app.post('/api/42flows/webhook', async (req, res) => {
const auth = req.headers.authorization
if (auth !== `Bearer ${process.env.FLOWS42_API_KEY}`) {
return res.status(401).json({ error: 'unauthorized' })
}
if (req.body.event_type === 'ping') {
return res.status(200).json({ ok: true })
}
if (req.body.event_type !== 'publish_article') {
return res.status(400).json({ error: 'bad event_type' })
}
const article = req.body.data?.article
if (!article?.slug) {
return res.status(400).json({ error: 'missing slug' })
}
// ... actually persist
})After:
app.post('/api/42flows/webhook', flows42Webhook({
apiKey: process.env.FLOWS42_API_KEY!,
onPublish: async (article) => {
// ... actually persist
return { postUrl: `https://yoursite.com/blog/${article.slug}` }
},
}))Auth, ping, parsing, validation, error responses: all gone from your code. Library handles them with the exact contract 42flows backend expects.
License
MIT
