โ† 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
end
rails db:migrate

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

3. 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
end

4. 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
end

5. 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/1

RSpec 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-key

Docker

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.