go
57 lines · 9 steps
Building a resilient webhook handler in Gin
A Gin handler that verifies, persists, and dispatches incoming webhooks while degrading gracefully into retry on failure.
Explained by
highlit
1func HandleWebhook(store WebhookStore, dispatcher *EventDispatcher) gin.HandlerFunc {
2 return func(c *gin.Context) {
3 body, err := c.GetRawData()
4 if err != nil {
5 c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "unable to read request body"})
6 return
7 }
8
9 signature := c.GetHeader("X-Signature-256")
10 if !verifySignature(body, signature) {
11 c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
12 return
13 }
14
15 delivery := &WebhookDelivery{
16 ID: uuid.NewString(),
17 Source: c.Param("provider"),
18 EventType: c.GetHeader("X-Event-Type"),
19 RawBody: body,
20 Headers: flattenHeaders(c.Request.Header),
21 Status: DeliveryPending,
22 ReceivedAt: time.Now().UTC(),
23 }
24
25 if err := store.Save(c.Request.Context(), delivery); err != nil {
26 c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not persist delivery"})
27 return
28 }
29
30 if err := dispatcher.Dispatch(c.Request.Context(), delivery); err != nil {
31 delivery.Status = DeliveryFailed
32 delivery.LastError = err.Error()
33 delivery.Attempts++
34 _ = store.Update(c.Request.Context(), delivery)
35
36 c.JSON(http.StatusAccepted, gin.H{
37 "id": delivery.ID,
38 "status": "queued_for_retry",
39 })
40 return
41 }
42
43 delivery.Status = DeliveryProcessed
44 delivery.Attempts++
45 _ = store.Update(c.Request.Context(), delivery)
46
47 c.JSON(http.StatusOK, gin.H{"id": delivery.ID, "status": "processed"})
48 }
49}
50
51func flattenHeaders(h http.Header) map[string]string {
52 out := make(map[string]string, len(h))
53 for k := range h {
54 out[k] = h.Get(k)
55 }
56 return out
57}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Injecting dependencies through a factory that returns a handler keeps request logic testable and free of globals.
- 2Persisting an incoming request before processing it lets you recover or retry even if downstream work fails.
- 3Distinguishing failure modes with specific status codes tells clients exactly what happened and what to expect next.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
go
package streaming import ( "bufio"
Streaming NDJSON logs over HTTP in Go
http-streaming
channels
select
Advanced
10 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
java
@Component @Converter public class EncryptedStringConverter implements AttributeConverter<String, String> {
Transparent column encryption in Spring & JPA
encryption
aes-gcm
jpa-converter
Advanced
10 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-resilient-webhook-handler-in-gin-explained-go-1508/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.