โ† Back to Connections

Connect Go to PostPenguin

Medium SetupLanguage: GoTime: 15 minutes

Go provides excellent performance for webhook handling. This guide shows you how to create a webhook endpoint using the standard library or popular frameworks like Gin or Echo.

๐Ÿš€ Quick Setup with Standard Library

// main.go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "database/sql"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "strings"
    "time"

    _ "github.com/lib/pq"
)

type PostPenguinPayload struct {
    PostPenguinID   string   `json:"postPenguinId"`
    Title           string   `json:"title"`
    Slug            string   `json:"slug"`
    HTML            string   `json:"html"`
    MetaTitle       string   `json:"meta_title"`
    MetaDescription string   `json:"meta_description"`
    FeaturedImage   string   `json:"featured_image"`
    Tags            []string `json:"tags"`
}

type Post struct {
    ID              string    `json:"id"`
    PostPenguinID   string    `json:"postpenguinId"`
    Title           string    `json:"title"`
    Slug            string    `json:"slug"`
    HTML            string    `json:"html"`
    MetaTitle       string    `json:"metaTitle"`
    MetaDescription string    `json:"metaDescription"`
    FeaturedImage   *string   `json:"featuredImage"`
    Tags            []string  `json:"tags"`
    Status          string    `json:"status"`
    PublishedAt     time.Time `json:"publishedAt"`
    CreatedAt       time.Time `json:"createdAt"`
    UpdatedAt       time.Time `json:"updatedAt"`
}

var db *sql.DB

func main() {
    var err error
    db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    http.HandleFunc("/api/webhooks/postpenguin", webhookHandler)
    http.HandleFunc("/api/posts", postsHandler)
    http.HandleFunc("/api/posts/", postBySlugHandler)

    port := os.Getenv("PORT")
    if port == "" {
        port = "3001"
    }

    log.Printf("Server running on port %s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

func verifySignature(payload, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(payload))
    expected := hex.EncodeToString(mac.Sum(nil))
    received := strings.TrimPrefix(signature, "sha256=")
    return hmac.Equal([]byte(expected), []byte(received))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // Read body
    var payload PostPenguinPayload
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Verify signature
    secret := os.Getenv("POSTPENGUIN_WEBHOOK_SECRET")
    signature := r.Header.Get("X-PostPenguin-Signature")
    
    if secret != "" && signature != "" {
        bodyBytes, _ := json.Marshal(payload)
        if !verifySignature(string(bodyBytes), signature, secret) {
            http.Error(w, "Invalid signature", http.StatusUnauthorized)
            return
        }
    }

    // Validate required fields
    if payload.Title == "" || payload.Slug == "" || payload.HTML == "" {
        http.Error(w, "Missing required fields", http.StatusBadRequest)
        return
    }

    postpenguinID := payload.PostPenguinID
    if postpenguinID == "" {
        postpenguinID = fmt.Sprintf("pp_%d", time.Now().UnixMilli())
    }

    now := time.Now()
    tagsJSON, _ := json.Marshal(payload.Tags)

    // Upsert post
    var id string
    var action string

    err := db.QueryRow(`
        SELECT id FROM posts WHERE postpenguin_id = $1
    `, postpenguinID).Scan(&id)

    if err == sql.ErrNoRows {
        // Insert
        err = db.QueryRow(`
            INSERT INTO posts (
                postpenguin_id, title, slug, html, meta_title,
                meta_description, featured_image, tags, status, published_at
            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'publish', $9)
            RETURNING id
        `, postpenguinID, payload.Title, payload.Slug, payload.HTML,
            payload.MetaTitle, payload.MetaDescription, payload.FeaturedImage,
            tagsJSON, now).Scan(&id)
        action = "created"
    } else if err == nil {
        // Update
        _, err = db.Exec(`
            UPDATE posts SET
                title = $1, slug = $2, html = $3, meta_title = $4,
                meta_description = $5, featured_image = $6, tags = $7,
                published_at = $8, updated_at = $9
            WHERE postpenguin_id = $10
        `, payload.Title, payload.Slug, payload.HTML, payload.MetaTitle,
            payload.MetaDescription, payload.FeaturedImage, tagsJSON, now, now,
            postpenguinID)
        action = "updated"
    }

    if err != nil {
        log.Printf("Database error: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    log.Printf("Post %s: %s", action, payload.Title)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "success":       true,
        "postId":        id,
        "postpenguinId": postpenguinID,
        "action":        action,
    })
}

func postsHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    rows, err := db.Query(`
        SELECT id, postpenguin_id, title, slug, html, meta_title,
               meta_description, featured_image, tags, status, published_at
        FROM posts
        WHERE status = 'publish'
        ORDER BY published_at DESC
        LIMIT 10
    `)
    if err != nil {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    var posts []Post
    for rows.Next() {
        var p Post
        var tagsJSON []byte
        err := rows.Scan(&p.ID, &p.PostPenguinID, &p.Title, &p.Slug, &p.HTML,
            &p.MetaTitle, &p.MetaDescription, &p.FeaturedImage, &tagsJSON,
            &p.Status, &p.PublishedAt)
        if err != nil {
            continue
        }
        json.Unmarshal(tagsJSON, &p.Tags)
        posts = append(posts, p)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "posts": posts,
    })
}

func postBySlugHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    slug := strings.TrimPrefix(r.URL.Path, "/api/posts/")

    var p Post
    var tagsJSON []byte
    err := db.QueryRow(`
        SELECT id, postpenguin_id, title, slug, html, meta_title,
               meta_description, featured_image, tags, status, published_at
        FROM posts
        WHERE slug = $1 AND status = 'publish'
    `, slug).Scan(&p.ID, &p.PostPenguinID, &p.Title, &p.Slug, &p.HTML,
        &p.MetaTitle, &p.MetaDescription, &p.FeaturedImage, &tagsJSON,
        &p.Status, &p.PublishedAt)

    if err == sql.ErrNoRows {
        http.Error(w, "Post not found", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    json.Unmarshal(tagsJSON, &p.Tags)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "post": p,
    })
}

๐ŸŽฏ With Gin Framework

// main.go with Gin
package main

import (
    "github.com/gin-gonic/gin"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type Post struct {
    gorm.Model
    PostPenguinID   string `gorm:"uniqueIndex" json:"postpenguinId"`
    Title           string `json:"title"`
    Slug            string `gorm:"uniqueIndex" json:"slug"`
    HTML            string `json:"html"`
    MetaTitle       string `json:"metaTitle"`
    MetaDescription string `json:"metaDescription"`
    FeaturedImage   string `json:"featuredImage"`
    Tags            string `json:"tags"` // JSON string
    Status          string `gorm:"default:publish" json:"status"`
    PublishedAt     time.Time `json:"publishedAt"`
}

func main() {
    db, _ := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), &gorm.Config{})
    db.AutoMigrate(&Post{})

    r := gin.Default()

    r.POST("/api/webhooks/postpenguin", func(c *gin.Context) {
        var payload PostPenguinPayload
        if err := c.ShouldBindJSON(&payload); err != nil {
            c.JSON(400, gin.H{"error": "Invalid JSON"})
            return
        }

        // Verify signature...

        postpenguinID := payload.PostPenguinID
        if postpenguinID == "" {
            postpenguinID = fmt.Sprintf("pp_%d", time.Now().UnixMilli())
        }

        var post Post
        result := db.Where("postpenguin_id = ?", postpenguinID).First(&post)
        
        action := "updated"
        if result.Error == gorm.ErrRecordNotFound {
            action = "created"
            post = Post{PostPenguinID: postpenguinID}
        }

        post.Title = payload.Title
        post.Slug = payload.Slug
        post.HTML = payload.HTML
        post.MetaTitle = payload.MetaTitle
        post.MetaDescription = payload.MetaDescription
        post.FeaturedImage = payload.FeaturedImage
        post.PublishedAt = time.Now()

        tagsJSON, _ := json.Marshal(payload.Tags)
        post.Tags = string(tagsJSON)

        db.Save(&post)

        c.JSON(200, gin.H{
            "success": true,
            "postId": post.ID,
            "action": action,
        })
    })

    r.GET("/api/posts", func(c *gin.Context) {
        var posts []Post
        db.Where("status = ?", "publish").Order("published_at desc").Limit(10).Find(&posts)
        c.JSON(200, gin.H{"posts": posts})
    })

    r.GET("/api/posts/:slug", func(c *gin.Context) {
        var post Post
        if err := db.Where("slug = ? AND status = ?", c.Param("slug"), "publish").First(&post).Error; err != nil {
            c.JSON(404, gin.H{"error": "Post not found"})
            return
        }
        c.JSON(200, gin.H{"post": post})
    })

    r.Run(":3001")
}

โš™๏ธ Environment Variables

DATABASE_URL=postgres://user:password@localhost:5432/database?sslmode=disable
POSTPENGUIN_WEBHOOK_SECRET=your-secret-key-here
PORT=3001

๐Ÿงช Testing

# Run the server
go run main.go

# Test webhook
curl -X POST http://localhost:3001/api/webhooks/postpenguin \
  -H "Content-Type: application/json" \
  -d '{
    "postPenguinId": "test_go_123",
    "title": "Test Go Post",
    "slug": "test-go-post",
    "html": "<p className="text-gray-700">This is a test post in Go.</p>",
    "tags": ["go", "golang"]
  }'

# Fetch posts
curl http://localhost:3001/api/posts

Need Help?

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