Astro's API endpoints make it simple to receive PostPenguin webhooks. This guide covers both static and SSR modes for displaying your blog posts.
Note: For webhook handling, you'll need Astro in SSR mode or use a serverless function alongside your static Astro site.
๐ Quick Setup (SSR Mode)
1. Enable SSR
// astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
})
})2. Create Webhook Endpoint
// src/pages/api/webhooks/postpenguin.ts
import type { APIRoute } from 'astro'
import { createHmac, timingSafeEqual } from 'crypto'
// In-memory storage for demo (replace with database)
const posts = new Map<string, any>()
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(payload).digest('hex')
const received = signature.replace('sha256=', '')
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(received))
} catch {
return false
}
}
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.text()
const data = JSON.parse(body)
// Verify signature
const signature = request.headers.get('x-postpenguin-signature')
const secret = import.meta.env.POSTPENGUIN_WEBHOOK_SECRET
if (secret && signature) {
if (!verifySignature(body, signature, secret)) {
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
}
}
// Validate required fields
if (!data.title || !data.slug || !data.html) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
const postId = data.postPenguinId || `pp_${Date.now()}`
const now = new Date().toISOString()
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 || null,
tags: data.tags || [],
status: 'publish',
publishedAt: now,
createdAt: posts.has(postId) ? posts.get(postId).createdAt : now,
updatedAt: now,
}
const action = posts.has(postId) ? 'updated' : 'created'
posts.set(postId, post)
console.log(`โ
Post ${action}: ${post.title}`)
return new Response(JSON.stringify({ success: true, postId, action }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Webhook error:', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
}
// Export for other pages
export { posts }3. Create Posts API
// src/pages/api/posts/index.ts
import type { APIRoute } from 'astro'
import { posts } from '../webhooks/postpenguin'
export const GET: APIRoute = async ({ url }) => {
const limit = parseInt(url.searchParams.get('limit') || '10')
const offset = parseInt(url.searchParams.get('offset') || '0')
const allPosts = Array.from(posts.values())
.filter(p => p.status === 'publish')
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
const paginatedPosts = allPosts.slice(offset, offset + limit)
return new Response(JSON.stringify({
posts: paginatedPosts,
pagination: { total: allPosts.length, limit, offset }
}), {
headers: { 'Content-Type': 'application/json' }
})
}// src/pages/api/posts/[slug].ts
import type { APIRoute } from 'astro'
import { posts } from '../webhooks/postpenguin'
export const GET: APIRoute = async ({ params }) => {
const post = Array.from(posts.values()).find(
p => p.slug === params.slug && p.status === 'publish'
)
if (!post) {
return new Response(JSON.stringify({ error: 'Post not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
return new Response(JSON.stringify({ post }), {
headers: { 'Content-Type': 'application/json' }
})
}๐จ Astro Pages
Blog Index
---
// src/pages/blog/index.astro
import Layout from '../../layouts/Layout.astro'
import { posts } from '../api/webhooks/postpenguin'
const allPosts = Array.from(posts.values())
.filter(p => p.status === 'publish')
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
---
<Layout title="Blog">
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold mb-8">Blog</h1>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{allPosts.map(post => (
<a
href={`/blog/${post.slug}`}
class="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition"
>
{post.featuredImage && (
<img
src={post.featuredImage}
alt={post.title}
class="w-full h-48 object-cover rounded mb-4"
/>
)}
<h2 class="text-xl font-semibold mb-2">{post.title}</h2>
<p class="text-gray-600 text-sm">
{new Date(post.publishedAt).toLocaleDateString()}
</p>
<div class="flex flex-wrap gap-2 mt-3">
{post.tags.map((tag: string) => (
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
{tag}
</span>
))}
</div>
</a>
))}
</div>
</div>
</Layout>Blog Post
---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Layout.astro'
import { posts } from '../api/webhooks/postpenguin'
const { slug } = Astro.params
const post = Array.from(posts.values()).find(
p => p.slug === slug && p.status === 'publish'
)
if (!post) {
return Astro.redirect('/404')
}
---
<Layout title={post.metaTitle} description={post.metaDescription}>
<article class="container mx-auto px-4 py-8 max-w-3xl">
{post.featuredImage && (
<img
src={post.featuredImage}
alt={post.title}
class="w-full h-64 object-cover rounded-lg mb-8"
/>
)}
<h1 class="text-4xl font-bold mb-4">{post.title}</h1>
<div class="flex items-center gap-4 text-gray-600 mb-8">
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
<div class="flex gap-2">
{post.tags.map((tag: string) => (
<span class="px-2 py-1 bg-gray-100 text-sm rounded">{tag}</span>
))}
</div>
</div>
<div class="prose prose-lg prose-gray max-w-none" set:html={post.html} />
</article>
</Layout>๐ฆ Static Site with External Webhook
If you're using Astro in static mode, you can use a serverless function to handle webhooks:
// netlify/functions/postpenguin-webhook.js
// or vercel/api/webhooks/postpenguin.js
// Handle webhook and save to database (Supabase, Planetscale, etc.)
// Then rebuild your Astro site via deploy hookโ๏ธ Environment Variables
# .env
POSTPENGUIN_WEBHOOK_SECRET=your-secret-key-here
DATABASE_URL=postgresql://user:password@localhost:5432/database๐งช Testing
# Start dev server
npm run dev
# Test webhook
curl -X POST http://localhost:4321/api/webhooks/postpenguin \
-H "Content-Type: application/json" \
-d '{
"postPenguinId": "test_astro_123",
"title": "Test Astro Post",
"slug": "test-astro-post",
"html": "<p className="text-gray-700">This is a test post in Astro.</p>",
"tags": ["astro", "static"]
}'
# Fetch posts
curl http://localhost:4321/api/postsNeed Help?
Check our webhook documentation for technical details, or contact support for custom integrations.