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

2. 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 commands

Need Help?

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