โ† Back to Connections

Connect Astro to PostPenguin

Easy SetupFramework: AstroTime: 10 minutes

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/posts

Need Help?

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