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/postsNeed Help?
Check our webhook documentation for technical details, or contact support for custom integrations.