โ† 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 deploy

Need Help?

Check our webhook documentation for technical details, or contact support for custom integrations.