โ† Back to Connections

Connect Spring Boot to PostPenguin

Medium SetupLanguage: Java / KotlinTime: 15 minutes

Spring Boot provides a robust framework for building Java applications. This guide shows you how to create a webhook endpoint that receives PostPenguin posts and stores them in your database.

๐Ÿš€ Quick Setup

1. Add Dependencies

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2. Create Post Entity

// src/main/java/com/example/model/Post.java
package com.example.model;

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.Type;

import java.time.LocalDateTime;
import java.util.List;

@Data
@Entity
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;

    @Column(name = "postpenguin_id", unique = true, nullable = false)
    private String postpenguinId;

    @Column(nullable = false, length = 500)
    private String title;

    @Column(unique = true, nullable = false)
    private String slug;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String html;

    @Column(name = "meta_title", length = 500)
    private String metaTitle;

    @Column(name = "meta_description", columnDefinition = "TEXT")
    private String metaDescription;

    @Column(name = "featured_image", length = 1000)
    private String featuredImage;

    @Column(columnDefinition = "jsonb")
    private String tags;

    @Column(length = 50)
    private String status = "publish";

    @Column(name = "published_at")
    private LocalDateTime publishedAt;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

3. Create Repository

// src/main/java/com/example/repository/PostRepository.java
package com.example.repository;

import com.example.model.Post;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface PostRepository extends JpaRepository<Post, String> {
    Optional<Post> findByPostpenguinId(String postpenguinId);
    Optional<Post> findBySlugAndStatus(String slug, String status);
    Page<Post> findByStatusOrderByPublishedAtDesc(String status, Pageable pageable);
}

4. Create DTOs

// src/main/java/com/example/dto/PostPenguinPayload.java
package com.example.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;

@Data
public class PostPenguinPayload {
    @JsonProperty("postPenguinId")
    private String postPenguinId;
    
    private String title;
    private String slug;
    private String html;
    
    @JsonProperty("meta_title")
    private String metaTitle;
    
    @JsonProperty("meta_description")
    private String metaDescription;
    
    @JsonProperty("featured_image")
    private String featuredImage;
    
    private List<String> tags;
}

// src/main/java/com/example/dto/WebhookResponse.java
package com.example.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class WebhookResponse {
    private boolean success;
    private String postId;
    private String postpenguinId;
    private String action;
}

5. Create Webhook Controller

// src/main/java/com/example/controller/WebhookController.java
package com.example.controller;

import com.example.dto.PostPenguinPayload;
import com.example.dto.WebhookResponse;
import com.example.model.Post;
import com.example.repository.PostRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;

@Slf4j
@RestController
@RequestMapping("/api/webhooks")
@RequiredArgsConstructor
public class WebhookController {

    private final PostRepository postRepository;
    private final ObjectMapper objectMapper;

    @Value("${postpenguin.webhook.secret:}")
    private String webhookSecret;

    @PostMapping("/postpenguin")
    public ResponseEntity<?> handleWebhook(
            @RequestBody String body,
            @RequestHeader(value = "X-PostPenguin-Signature", required = false) String signature
    ) {
        try {
            // Verify signature
            if (webhookSecret != null && !webhookSecret.isEmpty() && signature != null) {
                if (!verifySignature(body, signature, webhookSecret)) {
                    return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                            .body(Map.of("error", "Invalid signature"));
                }
            }

            PostPenguinPayload payload = objectMapper.readValue(body, PostPenguinPayload.class);

            // Validate required fields
            if (payload.getTitle() == null || payload.getSlug() == null || payload.getHtml() == null) {
                return ResponseEntity.badRequest()
                        .body(Map.of("error", "Missing required fields"));
            }

            String postpenguinId = payload.getPostPenguinId() != null 
                    ? payload.getPostPenguinId() 
                    : "pp_" + System.currentTimeMillis();

            // Check if post exists
            Optional<Post> existingPost = postRepository.findByPostpenguinId(postpenguinId);
            
            Post post;
            String action;

            if (existingPost.isPresent()) {
                post = existingPost.get();
                action = "updated";
            } else {
                post = new Post();
                post.setPostpenguinId(postpenguinId);
                action = "created";
            }

            post.setTitle(payload.getTitle());
            post.setSlug(payload.getSlug());
            post.setHtml(payload.getHtml());
            post.setMetaTitle(payload.getMetaTitle() != null ? payload.getMetaTitle() : payload.getTitle());
            post.setMetaDescription(payload.getMetaDescription() != null ? payload.getMetaDescription() : "");
            post.setFeaturedImage(payload.getFeaturedImage());
            post.setTags(payload.getTags() != null ? objectMapper.writeValueAsString(payload.getTags()) : "[]");
            post.setStatus("publish");
            post.setPublishedAt(LocalDateTime.now());

            post = postRepository.save(post);

            log.info("Post {}: {}", action, payload.getTitle());

            return ResponseEntity.ok(new WebhookResponse(true, post.getId(), postpenguinId, action));

        } catch (Exception e) {
            log.error("Webhook error", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("error", "Internal server error"));
        }
    }

    private boolean verifySignature(String payload, String signature, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                    secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
            
            StringBuilder hexString = new StringBuilder();
            for (byte b : hash) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) hexString.append('0');
                hexString.append(hex);
            }
            
            String expected = hexString.toString();
            String received = signature.replace("sha256=", "");
            
            return MessageDigest.isEqual(
                    expected.getBytes(StandardCharsets.UTF_8),
                    received.getBytes(StandardCharsets.UTF_8)
            );
        } catch (Exception e) {
            log.error("Signature verification error", e);
            return false;
        }
    }
}

6. Create Posts API Controller

// src/main/java/com/example/controller/PostController.java
package com.example.controller;

import com.example.model.Post;
import com.example.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostRepository postRepository;

    @GetMapping
    public ResponseEntity<?> getPosts(
            @RequestParam(defaultValue = "10") int limit,
            @RequestParam(defaultValue = "0") int offset
    ) {
        Page<Post> posts = postRepository.findByStatusOrderByPublishedAtDesc(
                "publish", 
                PageRequest.of(offset / limit, limit)
        );

        return ResponseEntity.ok(Map.of(
                "posts", posts.getContent(),
                "pagination", Map.of(
                        "total", posts.getTotalElements(),
                        "limit", limit,
                        "offset", offset
                )
        ));
    }

    @GetMapping("/{slug}")
    public ResponseEntity<?> getPostBySlug(@PathVariable String slug) {
        return postRepository.findBySlugAndStatus(slug, "publish")
                .map(post -> ResponseEntity.ok(Map.of("post", post)))
                .orElse(ResponseEntity.notFound().build());
    }
}

โš™๏ธ Configuration

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/postpenguin
    username: your_username
    password: your_password
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

postpenguin:
  webhook:
    secret: your-secret-key-here

๐Ÿงช Testing

# Start the application
./mvnw spring-boot:run

# Test webhook
curl -X POST http://localhost:8080/api/webhooks/postpenguin \
  -H "Content-Type: application/json" \
  -d '{
    "postPenguinId": "test_spring_123",
    "title": "Test Spring Boot Post",
    "slug": "test-spring-boot-post",
    "html": "<p className="text-gray-700">This is a test post in Spring Boot.</p>",
    "meta_title": "Test Post",
    "meta_description": "Testing Spring Boot webhook",
    "tags": ["spring", "java"]
  }'

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

# Get single post
curl http://localhost:8080/api/posts/test-spring-boot-post

Need Help?

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