โ Back to Connections
Connect AWS Lambda to PostPenguin
Medium SetupPlatform: AWS LambdaTime: 15 minutes
AWS Lambda provides serverless compute that scales automatically. This guide shows you how to create a Lambda function that receives PostPenguin webhooks and stores posts in DynamoDB or RDS.
๐ Quick Setup with DynamoDB
1. Create DynamoDB Table
# Using AWS CLI
aws dynamodb create-table \
--table-name PostPenguinPosts \
--attribute-definitions \
AttributeName=id,AttributeType=S \
AttributeName=slug,AttributeType=S \
AttributeName=status,AttributeType=S \
AttributeName=publishedAt,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--global-secondary-indexes \
'[{
"IndexName": "slug-index",
"KeySchema": [{"AttributeName": "slug", "KeyType": "HASH"}],
"Projection": {"ProjectionType": "ALL"}
},
{
"IndexName": "status-publishedAt-index",
"KeySchema": [
{"AttributeName": "status", "KeyType": "HASH"},
{"AttributeName": "publishedAt", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}]' \
--billing-mode PAY_PER_REQUEST2. Create Lambda Function
// index.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb')
const { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand } = require('@aws-sdk/lib-dynamodb')
const crypto = require('crypto')
const client = new DynamoDBClient({})
const docClient = DynamoDBDocumentClient.from(client)
const TABLE_NAME = process.env.TABLE_NAME || 'PostPenguinPosts'
function verifySignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
const received = signature.replace('sha256=', '')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}
exports.handler = async (event) => {
const path = event.requestContext?.http?.path || event.path
const method = event.requestContext?.http?.method || event.httpMethod
// Route requests
if (path === '/webhooks/postpenguin' && method === 'POST') {
return handleWebhook(event)
}
if (path === '/posts' && method === 'GET') {
return getPosts(event)
}
if (path.startsWith('/posts/') && method === 'GET') {
const slug = path.replace('/posts/', '')
return getPostBySlug(slug)
}
return {
statusCode: 404,
body: JSON.stringify({ error: 'Not found' })
}
}
async function handleWebhook(event) {
try {
const body = event.body
const data = JSON.parse(body)
// Verify signature
const signature = event.headers['x-postpenguin-signature']
const secret = process.env.POSTPENGUIN_WEBHOOK_SECRET
if (secret && signature) {
if (!verifySignature(body, signature, secret)) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Invalid signature' })
}
}
}
// Validate required fields
if (!data.title || !data.slug || !data.html) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Missing required fields' })
}
}
const postId = data.postPenguinId || `pp_${Date.now()}`
const now = new Date().toISOString()
// Check if exists
const existing = await docClient.send(new GetCommand({
TableName: TABLE_NAME,
Key: { id: postId }
}))
const post = {
id: postId,
postpenguinId: 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: existing.Item?.createdAt || now,
updatedAt: now,
}
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: post
}))
const action = existing.Item ? 'updated' : 'created'
console.log(`Post ${action}: ${data.title}`)
return {
statusCode: 200,
body: JSON.stringify({ success: true, postId, action })
}
} catch (error) {
console.error('Webhook error:', error)
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
}
}
}
async function getPosts(event) {
try {
const params = event.queryStringParameters || {}
const limit = parseInt(params.limit) || 10
const result = await docClient.send(new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'status-publishedAt-index',
KeyConditionExpression: '#status = :status',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: { ':status': 'publish' },
ScanIndexForward: false, // Descending order
Limit: limit
}))
return {
statusCode: 200,
body: JSON.stringify({
posts: result.Items,
pagination: { count: result.Count }
})
}
} catch (error) {
console.error('Error:', error)
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
}
}
}
async function getPostBySlug(slug) {
try {
const result = await docClient.send(new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'slug-index',
KeyConditionExpression: 'slug = :slug',
ExpressionAttributeValues: { ':slug': slug },
Limit: 1
}))
if (!result.Items?.length) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Post not found' })
}
}
return {
statusCode: 200,
body: JSON.stringify({ post: result.Items[0] })
}
} catch (error) {
console.error('Error:', error)
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
}
}
}3. SAM Template
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Timeout: 30
Runtime: nodejs18.x
Resources:
PostPenguinFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
CodeUri: ./
Environment:
Variables:
TABLE_NAME: !Ref PostsTable
POSTPENGUIN_WEBHOOK_SECRET: '{{resolve:ssm:/postpenguin/webhook-secret}}'
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref PostsTable
Events:
Webhook:
Type: HttpApi
Properties:
Path: /webhooks/postpenguin
Method: POST
GetPosts:
Type: HttpApi
Properties:
Path: /posts
Method: GET
GetPost:
Type: HttpApi
Properties:
Path: /posts/{slug}
Method: GET
PostsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: PostPenguinPosts
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
- AttributeName: slug
AttributeType: S
- AttributeName: status
AttributeType: S
- AttributeName: publishedAt
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
GlobalSecondaryIndexes:
- IndexName: slug-index
KeySchema:
- AttributeName: slug
KeyType: HASH
Projection:
ProjectionType: ALL
- IndexName: status-publishedAt-index
KeySchema:
- AttributeName: status
KeyType: HASH
- AttributeName: publishedAt
KeyType: RANGE
Projection:
ProjectionType: ALL
Outputs:
ApiUrl:
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"โ๏ธ Store Secret
aws ssm put-parameter \
--name "/postpenguin/webhook-secret" \
--value "your-secret-key-here" \
--type SecureString๐ Deploy
# Build and deploy
sam build
sam deploy --guided๐งช Testing
# Get your API URL from the deployment output
API_URL="https://abc123.execute-api.us-east-1.amazonaws.com"
# Test webhook
curl -X POST $API_URL/webhooks/postpenguin \
-H "Content-Type: application/json" \
-d '{
"postPenguinId": "test_lambda_123",
"title": "Test Lambda Post",
"slug": "test-lambda-post",
"html": "<p className="text-gray-700">This is a test post on AWS Lambda.</p>",
"tags": ["aws", "lambda", "serverless"]
}'
# Fetch posts
curl $API_URL/posts
# Get single post
curl $API_URL/posts/test-lambda-post๐ Alternative: RDS PostgreSQL
For SQL-based storage, use RDS with the pg package:
const { Client } = require('pg')
const client = new Client({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: { rejectUnauthorized: false }
})
await client.connect()
// Use SQL queries instead of DynamoDB commandsNeed Help?
Check our webhook documentation for technical details, or contact support for custom integrations.