Deploy serverless webhook handlers on Vercel to receive PostPenguin posts. Perfect for Next.js apps, static sites, or any JavaScript project.
๐ Quick Setup
1. Create Serverless Function
Create api/webhooks/postpenguin.js in your project:
// api/webhooks/postpenguin.js
import { createHmac } from 'crypto'
export default async function handler(req, res) {
// Enable CORS
res.setHeader('Access-Control-Allow-Credentials', true)
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'POST,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'X-PostPenguin-Signature,Content-Type')
if (req.method === 'OPTIONS') {
res.status(200).end()
return
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
try {
// Verify webhook signature (recommended)
const signature = req.headers['x-postpenguin-signature']
const webhookSecret = process.env.POSTPENGUIN_WEBHOOK_SECRET
if (webhookSecret && signature) {
const payloadString = JSON.stringify(req.body)
const expectedSignature = createHmac('sha256', webhookSecret)
.update(payloadString)
.digest('hex')
const receivedSignature = signature.replace('sha256=', '')
if (receivedSignature !== expectedSignature) {
return res.status(401).json({ error: 'Invalid signature' })
}
}
const { title, slug, html, meta_title, meta_description, featured_image } = req.body
// Validate required fields
if (!title || !slug || !html) {
return res.status(400).json({ error: 'Missing required fields' })
}
// Save to your database/service
const post = {
id: crypto.randomUUID(),
title,
slug,
html,
metaTitle: meta_title || title,
metaDescription: meta_description || '',
featuredImage: featured_image || '',
publishedAt: new Date().toISOString(),
}
// Example: Save to Vercel KV (Redis)
// const kv = require('@vercel/kv')
// await kv.set(`post:${post.slug}`, JSON.stringify(post))
// Example: Save to PlanetScale/MySQL
// const mysql = require('mysql2/promise')
// const connection = await mysql.createConnection(process.env.DATABASE_URL)
// await connection.execute('INSERT INTO posts SET ?', [post])
console.log('Received PostPenguin post:', title)
res.status(200).json({
success: true,
postId: post.id,
message: 'Post received successfully'
})
} catch (error) {
console.error('Webhook error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}
2. Environment Variables
Add to Vercel dashboard or .env.local:
POSTPENGUIN_WEBHOOK_SECRET=your-webhook-secret-here
DATABASE_URL=your-database-connection-string3. Configure PostPenguin
When adding your site to PostPenguin:
- Webhook URL:
https://your-app.vercel.app/api/webhooks/postpenguin - Secret Key: Same as
POSTPENGUIN_WEBHOOK_SECRET
๐พ Database Integration
Vercel KV (Redis)
// api/webhooks/postpenguin.js
import { kv } from '@vercel/kv'
export default async function handler(req, res) {
// ... validation ...
const post = {
id: crypto.randomUUID(),
...req.body,
publishedAt: new Date().toISOString(),
}
// Save post
await kv.set(`post:${post.slug}`, JSON.stringify(post))
// Add to posts list
await kv.lpush('posts', post.slug)
res.status(200).json({ success: true, postId: post.id })
}
PlanetScale (MySQL)
// api/webhooks/postpenguin.js
import mysql from 'mysql2/promise'
export default async function handler(req, res) {
let connection
try {
connection = await mysql.createConnection(process.env.DATABASE_URL)
const post = {
postpenguin_id: req.body.postPenguinId || crypto.randomUUID(),
title: req.body.title,
slug: req.body.slug,
html: req.body.html,
meta_title: req.body.meta_title || req.body.title,
meta_description: req.body.meta_description || '',
featured_image_url: req.body.featured_image || null,
published_at: new Date(),
}
const [result] = await connection.execute(
'INSERT INTO posts (postpenguin_id, title, slug, html, meta_title, meta_description, featured_image_url, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[post.postpenguin_id, post.title, post.slug, post.html, post.meta_title, post.meta_description, post.featured_image_url, post.published_at]
)
res.status(200).json({ success: true, postId: result.insertId })
} catch (error) {
console.error('Database error:', error)
res.status(500).json({ error: 'Database error' })
} finally {
if (connection) await connection.end()
}
}
๐ Fetch Posts
API Route to Fetch Posts
Create api/posts.js to retrieve posts:
// api/posts.js - Fetch posts from your storage
import { kv } from '@vercel/kv'
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
}
try {
const { slug, limit = 10, offset = 0 } = req.query
if (slug) {
// Fetch single post
const post = await kv.get(`post:${slug}`)
if (!post) {
return res.status(404).json({ error: 'Post not found' })
}
return res.status(200).json({ post: JSON.parse(post) })
}
// Fetch multiple posts
const postSlugs = await kv.lrange('posts', offset, offset + limit - 1)
const posts = []
for (const slug of postSlugs) {
const post = await kv.get(`post:${slug}`)
if (post) {
posts.push(JSON.parse(post))
}
}
res.status(200).json({ posts })
} catch (error) {
console.error('Error fetching posts:', error)
res.status(500).json({ error: 'Internal server error' })
}
}
๐จ Frontend Display
React Component
// components/BlogPosts.js
import { useState, useEffect } from 'react'
export default function BlogPosts() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data.posts || [])
setLoading(false)
})
.catch(error => {
console.error('Error fetching posts:', error)
setLoading(false)
})
}, [])
if (loading) return <div>Loading posts...</div>
return (
<div className="blog-posts">
{posts.map(post => (
<article key={post.id}>
{post.featuredImage && (
<img src={post.featuredImage} alt={post.title} />
)}
<h2 className="text-gray-900">
<a href={`/posts/${post.slug}`}>{post.title}</a>
</h2>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
</article>
))}
</div>
)
}
๐งช Testing
Local Development
# Install Vercel CLI
npm i -g vercel
# Start local development server
vercel dev
# Test webhook in another terminal
curl -X POST http://localhost:3000/api/webhooks/postpenguin \
-H "Content-Type: application/json" \
-d '{
"title": "Test Post",
"slug": "test-post",
"html": "<p className="text-gray-700">This is a test post.</p>",
"status": "publish"
}'
Production Testing
# Test production webhook
curl -X POST https://your-app.vercel.app/api/webhooks/postpenguin \
-H "Content-Type: application/json" \
-H "X-PostPenguin-Signature: sha256=YOUR_SIGNATURE" \
-d '{
"title": "Test Post",
"slug": "test-post",
"html": "<p className="text-gray-700">This is a test post.</p>",
"status": "publish"
}'
๐ Deployment
Automatic deployment when you push to your git repository.
Environment Variables
Set in Vercel Dashboard:
- Project Settings > Environment Variables
- Add
POSTPENGUIN_WEBHOOK_SECRET - Add database credentials (if using external DB)
๐ Troubleshooting
Function Timeout
Vercel functions have a 10-second timeout for hobby plan. If your database operations are slow:
- Optimize database queries
- Use connection pooling
- Consider Vercel Pro plan for longer timeouts
Cold Starts
Serverless functions have cold starts. For better performance:
- Use Vercel Pro plan (reduces cold starts)
- Implement caching in your application
- Use edge functions for better performance
CORS Issues
If testing from browser, ensure CORS headers are set.
Need Help?
Read our webhook documentation for technical details, or contact support for custom integrations.