feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch

This commit is contained in:
2026-07-27 10:37:53 +01:00
parent fbd93d0ea5
commit 6832bfd7bb
3 changed files with 127 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/billing"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
)
// paddleWebhook is the ingress for every Paddle event.
//
// Order is load-bearing: read the RAW body first (the signature is over the
// exact bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature
// is 401 and processes nothing; a duplicate of a handled event is 200 and does
// nothing; a handler error is 500 so Paddle retries, and is recorded for staff.
func paddleWebhook(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
return
}
if !paddle.VerifySignature(cfg.PaddleWebhookSecret,
c.GetHeader("Paddle-Signature"), body) {
log.Printf("paddle webhook: bad signature from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
return
}
var ev billing.Event
if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"})
return
}
ctx := c.Request.Context()
claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
return
}
if !claimed {
// Already handled (or in flight). 200 so Paddle stops retrying.
c.JSON(http.StatusOK, gin.H{"duplicate": true})
return
}
if err := billing.Dispatch(ctx, ev); err != nil {
log.Printf("paddle webhook: handler %s failed for %s: %v",
ev.EventType, ev.EventID, err)
_ = models.MarkEventProcessed(ctx, ev.EventID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"})
return
}
_ = models.MarkEventProcessed(ctx, ev.EventID, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
+4
View File
@@ -39,6 +39,10 @@ func Routes(cfg config.Config) http.Handler {
r.POST("/auth/signup", auth.HandleSignup)
r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
// Public: Paddle carries no session cookie; its signature is its auth. Must
// NOT sit under the cust group's session middleware.
r.POST("/api/paddle/webhook", paddleWebhook(cfg))
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())
{