โ Back to Connections
Connect Ruby on Rails to PostPenguin
Medium SetupLanguage: RubyTime: 15 minutes
Ruby on Rails makes it easy to receive and store PostPenguin blog posts. This guide shows you how to create a webhook endpoint and integrate with your Rails application.
๐ Quick Setup
1. Generate Migration
rails generate migration CreatePosts# db/migrate/xxxx_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :postpenguin_id, null: false, index: { unique: true }
t.string :title, null: false
t.string :slug, null: false, index: { unique: true }
t.text :html, null: false
t.string :meta_title
t.text :meta_description
t.string :featured_image
t.jsonb :tags, default: []
t.string :status, default: 'publish'
t.datetime :published_at
t.timestamps
end
add_index :posts, :status
add_index :posts, :published_at
end
endrails db:migrate2. Create Post Model
# app/models/post.rb
class Post < ApplicationRecord
validates :postpenguin_id, presence: true, uniqueness: true
validates :title, presence: true
validates :slug, presence: true, uniqueness: true
validates :html, presence: true
scope :published, -> { where(status: 'publish') }
scope :recent, -> { order(published_at: :desc) }
def as_json(options = {})
super(options.merge(
only: [:id, :title, :slug, :html, :meta_title, :meta_description,
:featured_image, :tags, :status, :published_at, :created_at],
methods: [:postpenguin_id]
))
end
end3. Create Webhooks Controller
# app/controllers/webhooks/postpenguin_controller.rb
module Webhooks
class PostpenguinController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_signature
def create
post_data = webhook_params
# Find or initialize post
post = Post.find_or_initialize_by(postpenguin_id: post_data[:postPenguinId] || generate_id)
was_new = post.new_record?
# Update attributes
post.assign_attributes(
title: post_data[:title],
slug: post_data[:slug],
html: post_data[:html],
meta_title: post_data[:meta_title] || post_data[:title],
meta_description: post_data[:meta_description] || '',
featured_image: post_data[:featured_image],
tags: post_data[:tags] || [],
status: 'publish',
published_at: Time.current
)
if post.save
render json: {
success: true,
postId: post.id,
postPenguinId: post.postpenguin_id,
action: was_new ? 'created' : 'updated'
}
else
render json: { error: post.errors.full_messages }, status: :unprocessable_entity
end
rescue => e
Rails.logger.error "PostPenguin webhook error: #{e.message}"
render json: { error: 'Internal server error' }, status: :internal_server_error
end
private
def verify_signature
secret = ENV['POSTPENGUIN_WEBHOOK_SECRET']
return true if secret.blank?
signature = request.headers['X-PostPenguin-Signature']
return render_unauthorized unless signature.present?
payload = request.raw_post
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
received = signature.sub('sha256=', '')
unless ActiveSupport::SecurityUtils.secure_compare(expected, received)
render_unauthorized
end
end
def render_unauthorized
render json: { error: 'Invalid signature' }, status: :unauthorized
end
def webhook_params
params.permit(
:postPenguinId, :title, :slug, :html,
:meta_title, :meta_description, :featured_image,
tags: []
)
end
def generate_id
"pp_#{SecureRandom.hex(8)}"
end
end
end4. Add Routes
# config/routes.rb
Rails.application.routes.draw do
namespace :webhooks do
post 'postpenguin', to: 'postpenguin#create'
end
# API for fetching posts
namespace :api do
resources :posts, only: [:index, :show] do
collection do
get :by_slug
end
end
end
end5. Create API Controller
# app/controllers/api/posts_controller.rb
module Api
class PostsController < ApplicationController
def index
posts = Post.published
.recent
.limit(params[:limit] || 10)
.offset(params[:offset] || 0)
total = Post.published.count
render json: {
posts: posts,
pagination: {
total: total,
limit: (params[:limit] || 10).to_i,
offset: (params[:offset] || 0).to_i
}
}
end
def show
post = Post.find(params[:id])
render json: { post: post }
rescue ActiveRecord::RecordNotFound
render json: { error: 'Post not found' }, status: :not_found
end
def by_slug
post = Post.published.find_by!(slug: params[:slug])
render json: { post: post }
rescue ActiveRecord::RecordNotFound
render json: { error: 'Post not found' }, status: :not_found
end
end
endโ๏ธ Environment Variables
# .env or config/credentials.yml.enc
POSTPENGUIN_WEBHOOK_SECRET=your-secret-key-here๐งช Testing
Test Webhook
curl -X POST http://localhost:3000/webhooks/postpenguin \
-H "Content-Type: application/json" \
-d '{
"postPenguinId": "test_rails_123",
"title": "Test Rails Post",
"slug": "test-rails-post",
"html": "<p className="text-gray-700">This is a test post using Rails.</p>",
"meta_title": "Test Post",
"meta_description": "Testing Rails webhook",
"tags": ["test", "rails", "ruby"]
}'Test Posts API
# Get all posts
curl http://localhost:3000/api/posts
# Get single post by slug
curl "http://localhost:3000/api/posts/by_slug?slug=test-rails-post"
# Get single post by ID
curl http://localhost:3000/api/posts/1RSpec Tests
# spec/requests/webhooks/postpenguin_spec.rb
require 'rails_helper'
RSpec.describe 'Webhooks::Postpenguin', type: :request do
let(:payload) do
{
postPenguinId: 'test_123',
title: 'Test Post',
slug: 'test-post',
html: '<p className="text-gray-700">Test content</p>',
tags: ['test']
}
end
describe 'POST /webhooks/postpenguin' do
it 'creates a new post' do
expect {
post '/webhooks/postpenguin', params: payload, as: :json
}.to change(Post, :count).by(1)
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)['success']).to be true
end
it 'updates existing post' do
Post.create!(
postpenguin_id: 'test_123',
title: 'Old Title',
slug: 'old-slug',
html: '<p className="text-gray-700">Old content</p>'
)
post '/webhooks/postpenguin', params: payload, as: :json
expect(response).to have_http_status(:ok)
expect(Post.find_by(postpenguin_id: 'test_123').title).to eq('Test Post')
end
end
end๐ Production Deployment
Heroku
heroku config:set POSTPENGUIN_WEBHOOK_SECRET=your-secret-keyDocker
FROM ruby:3.2
WORKDIR /app
COPY Gemfile* ./
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]Need Help?
Check our webhook documentation for technical details, or contact support for custom integrations.