โ Back to Connections
Connect Cloudflare Workers to PostPenguin
Easy SetupPlatform: Cloudflare WorkersTime: 10 minutes
Cloudflare Workers run at the edge with incredibly fast response times. This guide shows you how to receive PostPenguin webhooks and store posts in D1 or KV.
Benefits: Global edge deployment, sub-millisecond cold starts, built-in D1 database and KV storage.
๐ Quick Setup with D1
1. Create D1 Database
# Create database
wrangler d1 create postpenguin-posts
# Apply schema
wrangler d1 execute postpenguin-posts --file=schema.sql-- schema.sql
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
postpenguin_id TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
html TEXT NOT NULL,
meta_title TEXT,
meta_description TEXT,
featured_image TEXT,
tags TEXT DEFAULT '[]',
status TEXT DEFAULT 'publish',
published_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_published_at ON posts(published_at);2. Configure wrangler.toml
# wrangler.toml
name = "postpenguin-webhook"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "postpenguin-posts"
database_id = "your-database-id"
[vars]
# Set via wrangler secret
# POSTPENGUIN_WEBHOOK_SECRET = "your-secret"3. Create Worker
// src/index.ts
export interface Env {
DB: D1Database
POSTPENGUIN_WEBHOOK_SECRET: string
}
async function verifySignature(
payload: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(payload)
)
const expected = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
const received = signature.replace('sha256=', '')
return expected === received
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// Webhook endpoint
if (url.pathname === '/api/webhooks/postpenguin' && request.method === 'POST') {
return handleWebhook(request, env)
}
// Posts API
if (url.pathname === '/api/posts' && request.method === 'GET') {
return getPosts(url, env)
}
if (url.pathname.startsWith('/api/posts/') && request.method === 'GET') {
const slug = url.pathname.replace('/api/posts/', '')
return getPostBySlug(slug, env)
}
return new Response('Not Found', { status: 404 })
}
}
async function handleWebhook(request: Request, env: Env): Promise<Response> {
try {
const body = await request.text()
const data = JSON.parse(body)
// Verify signature
const signature = request.headers.get('x-postpenguin-signature')
if (env.POSTPENGUIN_WEBHOOK_SECRET && signature) {
const valid = await verifySignature(body, signature, env.POSTPENGUIN_WEBHOOK_SECRET)
if (!valid) {
return Response.json({ error: 'Invalid signature' }, { status: 401 })
}
}
// Validate required fields
if (!data.title || !data.slug || !data.html) {
return Response.json({ error: 'Missing required fields' }, { status: 400 })
}
const postId = data.postPenguinId || `pp_${Date.now()}`
const now = new Date().toISOString()
// Check if post exists
const existing = await env.DB.prepare(
'SELECT id FROM posts WHERE postpenguin_id = ?'
).bind(postId).first()
if (existing) {
// Update
await env.DB.prepare(`
UPDATE posts SET
title = ?, slug = ?, html = ?, meta_title = ?,
meta_description = ?, featured_image = ?, tags = ?,
published_at = ?, updated_at = ?
WHERE postpenguin_id = ?
`).bind(
data.title,
data.slug,
data.html,
data.meta_title || data.title,
data.meta_description || '',
data.featured_image || null,
JSON.stringify(data.tags || []),
now,
now,
postId
).run()
return Response.json({ success: true, postId, action: 'updated' })
}
// Insert
const id = crypto.randomUUID()
await env.DB.prepare(`
INSERT INTO posts (
id, postpenguin_id, title, slug, html, meta_title,
meta_description, featured_image, tags, status, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'publish', ?)
`).bind(
id,
postId,
data.title,
data.slug,
data.html,
data.meta_title || data.title,
data.meta_description || '',
data.featured_image || null,
JSON.stringify(data.tags || []),
now
).run()
return Response.json({ success: true, postId: id, action: 'created' })
} catch (error) {
console.error('Webhook error:', error)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
}
async function getPosts(url: URL, env: Env): Promise<Response> {
const limit = parseInt(url.searchParams.get('limit') || '10')
const offset = parseInt(url.searchParams.get('offset') || '0')
const { results } = await env.DB.prepare(`
SELECT * FROM posts
WHERE status = 'publish'
ORDER BY published_at DESC
LIMIT ? OFFSET ?
`).bind(limit, offset).all()
const { total } = await env.DB.prepare(
"SELECT COUNT(*) as total FROM posts WHERE status = 'publish'"
).first() as { total: number }
// Parse tags JSON
const posts = results.map(post => ({
...post,
tags: JSON.parse(post.tags as string || '[]')
}))
return Response.json({
posts,
pagination: { total, limit, offset }
})
}
async function getPostBySlug(slug: string, env: Env): Promise<Response> {
const post = await env.DB.prepare(`
SELECT * FROM posts WHERE slug = ? AND status = 'publish'
`).bind(slug).first()
if (!post) {
return Response.json({ error: 'Post not found' }, { status: 404 })
}
return Response.json({
post: {
...post,
tags: JSON.parse(post.tags as string || '[]')
}
})
}๐ฆ Alternative: Using KV
// For simpler storage without SQL
export interface Env {
POSTS: KVNamespace
POSTPENGUIN_WEBHOOK_SECRET: string
}
async function handleWebhook(request: Request, env: Env): Promise<Response> {
const data = await request.json()
const postId = data.postPenguinId || `pp_${Date.now()}`
const post = {
id: postId,
title: data.title,
slug: data.slug,
html: data.html,
metaTitle: data.meta_title || data.title,
metaDescription: data.meta_description || '',
featuredImage: data.featured_image,
tags: data.tags || [],
publishedAt: new Date().toISOString(),
}
// Store by ID and slug for lookup
await env.POSTS.put(`post:${postId}`, JSON.stringify(post))
await env.POSTS.put(`slug:${data.slug}`, postId)
// Update posts index
const indexStr = await env.POSTS.get('posts:index') || '[]'
const index = JSON.parse(indexStr)
if (!index.includes(postId)) {
index.unshift(postId)
await env.POSTS.put('posts:index', JSON.stringify(index))
}
return Response.json({ success: true, postId })
}โ๏ธ Set Secret
wrangler secret put POSTPENGUIN_WEBHOOK_SECRET๐งช Testing
# Start local dev
wrangler dev
# Test webhook
curl -X POST http://localhost:8787/api/webhooks/postpenguin \
-H "Content-Type: application/json" \
-d '{
"postPenguinId": "test_cf_123",
"title": "Test Cloudflare Post",
"slug": "test-cloudflare-post",
"html": "<p className="text-gray-700">This is a test post on Cloudflare Workers.</p>",
"tags": ["cloudflare", "edge"]
}'
# Fetch posts
curl http://localhost:8787/api/posts๐ Deploy
wrangler deployNeed Help?
Check our webhook documentation for technical details, or contact support for custom integrations.