↓Skip to main content

Built a Webhook Inspector from Scratch and Shipped It — Here's Everything That Went Wrong

·8 mins

Live at: https://hookspy.inHookSpy - Walk Through

Every developer integrating Stripe, Razorpay, or GitHub has been there. You set up a webhook, fire a test event, and… nothing. The endpoint didn’t respond. Or it did but your handler crashed silently. Or you just want to see the exact payload the service sends before writing a single line of handler code.

Tools like RequestBin and Webhook.site exist. But I wanted to build my own — one I understood end to end, could deploy myself, and could use as a real product to learn distributed systems in practice. That’s HookSpy.

This is the full story — architecture decisions, a painful Vaadin mistake, 18 production bugs, and what I’d do differently.

What HookSpy Does #

You register, get a unique webhook URL like https://hookspy.in/h/{slug}, and point any service at it. Every incoming request appears in your dashboard in real time. You can inspect headers, body, query params — replay the request to any target URL — and export it as a ready-to-run cURL command.

Three tiers:

  • Free (1 endpoint, 100 requests/day),
  • Pro (₹299/month, 10 endpoints, 30-day retention),
  • Team (₹799/month, 50 endpoints, 90-day retention).

The Architecture #

Three Spring Boot microservices connected via Kafka.

External Service (Stripe, Razorpay etc.)
        │
        │  POST https://hookspy.in/h/{slug}
        ▼
┌─────────────────────────────────────┐
│  capture-service :8080              │
│  Spring Boot WebFlux + Kafka Binder │
│  Returns 200 immediately            │
│  Publishes to Kafka topic           │
└─────────────────────────────────────┘
        │ Kafka KRaft (no Zookeeper)
        ▼
┌─────────────────────────────────────┐
│  processor-service :8081            │
│  @KafkaListener                     │
│  Validates tier limits              │
│  Saves to PostgreSQL                │
│  RetryableTopic (3 retries + DLT)   │
└─────────────────────────────────────┘
        │ PostgreSQL 16
        ▼
┌─────────────────────────────────────┐
│  ui-service :8082                   │
│  Spring Boot REST + React 18        │
│  SSE live updates                   │
│  Auth, replay, Razorpay payments    │
└─────────────────────────────────────┘

Why three services? #

The most critical constraint: Stripe and Razorpay expect a 200 response within 5 seconds. If processing is slow, they retry — duplicate events. capture-service returns 200 immediately and hands off to Kafka. The caller never waits.

Why Kafka and not write directly to DB?

I wanted to learn Kafka in a real context. But it’s also architecturally sound — capture can scale independently from the processor. Flash sale burst of webhooks? Kafka absorbs the spike. The processor works through the backlog at its own pace.

Kafka KRaft — no Zookeeper

Kafka’s KRaft mode removes the Zookeeper dependency entirely. One less service, one less thing to fail. The docker-compose setup is clean:

kafka:
  image: confluentinc/cp-kafka:7.7.0
  environment:
    KAFKA_NODE_ID: 1
    KAFKA_PROCESS_ROLES: broker,controller
    KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093

Two different Kafka client styles — intentionally

  • capture-service uses Spring Cloud Stream Kafka Binder — a reactive Supplier<Flux> backed by Sinks.Many. The Binder wires it to the topic automatically.
  • processor-service uses Spring Kafka directly — @KafkaListener with manual acknowledgement and @RetryableTopic for fine-grained reliability. Three retries with exponential backoff, then dead letter topic.

Mixing both taught me where each approach shines — Binder for reactive fire-and-forget producers, plain Spring Kafka for consumers that need reliability control.

The Vaadin Mistake #

I vibe coded the UI in Vaadin 24 first. Java all the way down, no context switching, built-in push support for live updates. Seemed reasonable.

It was a mistake.

  • Vaadin’s shadow DOM makes CSS control nearly impossible.
  • Every rule needs !important.
  • Dark mode is unreliable.
  • Heavy JS bundle.
  • The UI fought the framework constantly.

After shipping the backend, I ripped out Vaadin entirely and rebuilt in React 18 + Vite + TailwindCSS. The frontend is a separate Vite project that builds into ui-service/src/main/resources/static/. Spring Boot serves React at / and the REST API at /api/*. One process in production, clean separation in development.

The migration took a week. The backend didn’t change at all — same entities, same services, same Kafka pipeline. Only ui-service changed.

Lesson: For any developer-facing product with design ambition, Vaadin will fight you the whole way. Start with React.

SSE Instead of Polling #

The Vaadin UI polled the database every 3 seconds. The React version uses Server-Sent Events.

The SSE endpoint holds a long-lived connection per slug. When processor-service saves a new webhook, a second Kafka consumer in ui-service receives the same event and pushes it to all open SSE emitters for that slug. New requests appear instantly.

@GetMapping(value = "/endpoints/{slug}/requests/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String slug) {
    var emitter = new SseEmitter(Long.MAX_VALUE);
    emitterRegistry.register(slug, emitter);

    // Send a keep-alive comment every 15s — prevents idle disconnect
    ScheduledFuture<?> ping = scheduler.scheduleAtFixedRate(() -> {
        try {
            emitter.send(SseEmitter.event().comment("ping"));
        } catch (IOException e) {
            emitter.completeWithError(e);
        }
    }, Instant.now(), Duration.ofSeconds(15));

    emitter.onCompletion(() -> {
        emitterRegistry.remove(slug, emitter);
        ping.cancel(true);
    });
    emitter.onTimeout(() -> {
        emitterRegistry.remove(slug, emitter);
        ping.cancel(true);
    });
    emitter.onError((e) -> {
        emitterRegistry.remove(slug, emitter);
        ping.cancel(true);
    });

    return emitter;
}

On the React side:

useEffect(() => {
    const source = new EventSource(
        `/api/endpoints/${slug}/requests/stream`,
        { withCredentials: true }
    )
    source.onmessage = (e) => {
        const newReq = JSON.parse(e.data)
        queryClient.setQueryData(['requests', slug], (old = []) => [newReq, ...old])
    }
    return () => source.close()
}, [slug])

SSE is underrated for developer tools. Simpler than WebSockets, perfect for one-directional live data. The only gotcha is nginx buffering — solved with proxy_buffering off.

18 Bugs That Cost Me Time #

1. CSRF Blocking Every POST Request #

Every POST to /api/** returned 403.

No logs, no error. Spring Security’s default CSRF protection. One line fix:

.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))

2. SSE Leaking Content-Type Into Exception Handler #

The SSE endpoint produces text/event-stream. Spring MVC cached this and applied it to error handler responses. GlobalExceptionHandler crashed with HttpMessageNotWritableException: No converter for ApiError with preset Content-Type ’text/event-stream'.

Fix: write directly to HttpServletResponse, skip SSE requests entirely. Move SSE to its own controller class so the produces type doesn’t leak.

3. Vite Proxy Hanging on SSE #

Socket hang up errors on the SSE stream.

Vite’s HTTP proxy buffers responses — SSE frames were never flushed.

Fix: separate proxy rule for SSE endpoints with x-accel-buffering: no header, plus spring.mvc.async.request-timeout: -1 in application.yml.

4. Razorpay Authentication Failed — Three Causes, Same Error #

This one took the longest. Curl worked, SDK didn’t.

  • Cause 1: RazorpayClient instantiated at bean creation time before @Value injected. Fix: constructor parameters.
  • Cause 2: application.yml had ${RAZORPAY_KEY_ID:placeholder} — the default value was overriding the actual env var. Fix: remove all defaults for production secrets.
  • Cause 3: Invisible trailing whitespace in copy-pasted keys. Fix: .trim() on both values.

5. Razorpay JS SDK Undefined #

tags inside React JSX don’t execute. window.Razorpay was always undefined.

Fix: load in index.html before React mounts.

6. Razorpay Order Response Shape Wrong #

Order created successfully but “could not initiate payment”. Backend was returning the raw Razorpay Order object — Jackson serialised it as the full Razorpay payload.

Frontend expected { orderId, keyId } but got { id, amount, currency, … }.

Fix: explicit Map.of(“orderId”, …, “keyId”, …) in the controller.

7. @Value Null in Component #

captureBaseUrl was null. The mapper was being instantiated with new MapperFromEntityToDTO() — Spring never touched it. @Value only works on Spring-managed beans. Always inject via constructor.

8. Tier Lowercase Mismatch #

Upgrade button not showing for FREE users. Backend returned “free”, frontend compared against “TEAM”.

Fix: user?.tier?.toUpperCase() everywhere.

9. Docker Services Connecting to localhost #

Inside a Docker network, services must use container service names, not localhost. application.yml had hardcoded localhost for DB and Kafka.

Fix: environment variable overrides in docker-compose.prod.yml:

SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${DB_NAME}
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092

10. Razorpay Env Vars Not Reaching Container #

Could not resolve placeholder ‘RAZORPAY_KEY_ID’.

Docker Compose environment: does variable substitution at compose level but doesn’t inject into the container.

Fix: add env_file: - .env to every service that needs secrets.

11. nginx Certificate Not Found #

nginx crash-looping. docker-compose.prod.yml had ./nginx/certs as cert path — certs were at /etc/letsencrypt.

Fix: mount the actual letsencrypt directory:

volumes:
  - /etc/letsencrypt:/etc/letsencrypt:ro

12. Wrong JAR Running in Container #

ui-service logs showed Started CaptureServiceApplication.

Dockerfile used COPY target/*.jar app.jar — wildcard picked up the wrong JAR.

Fix: specific artifact name in each Dockerfile:

COPY target/ui-service-*.jar app.jar

13. Maven Not Found #

mvn: command not found.

IntelliJ uses a bundled Maven — system terminal doesn’t have it.

Fix: brew install maven.

14. Maven Compiler Plugin 3.14.1 Bug With Java 21 + Lombok #

ExceptionInInitializerError: TypeTag :: UNKNOWN. Known bug.

Fix: pin to 3.13.0, upgrade Lombok to 1.18.34.

15. SCP Copying node_modules #

File transfer taking 20+ minutes. scp -r copies everything.

Fix: use rsync with excludes:

rsync -av --exclude='node_modules' --exclude='target' --exclude='.git' ...

16. Docker Images Not Found on Server #

hookspy/ui-service image not found. docker-compose.prod.yml used image: references — images were never pushed to a registry.

Fix: switch to build: with context pointing to each service directory.

17. AuthenticatedUser Broke After Vaadin Removed #

AuthenticationContext not found after removing Vaadin.

Fix: rewrite to use SecurityContextHolder:

var auth = SecurityContextHolder.getContext().getAuthentication();

18. Registration Failing in Browser, Working via curl #

No backend errors. Browser was serving a cached old JS bundle. Hard refresh (Cmd+Shift+R) resolved it.

Production deployment lesson: always hard refresh after deploying new frontend.

Production Stack #

LayerTechnology
LanguageJava 21
FrameworkSpring Boot 4.0.5
Reactive producerSpring Cloud Stream Kafka Binder
Reliable consumerSpring Kafka + @RetryableTopic
Message brokerKafka KRaft (no Zookeeper)
DatabasePostgreSQL 16 + Flyway
ORMSpring Data JPA
AuthSpring Security + BCrypt + HTTP Sessions
FrontendReact 18 + Vite + TailwindCSS
Live updatesServer-Sent Events (SSE)
PaymentsRazorpay (HMAC-SHA256 verification)
Reverse proxynginx
HostingHetzner CX23, Helsinki (~₹465/month)
SSLLet’s Encrypt (auto-renewing)

What I’d Do Differently #

Start with React. The Vaadin migration week was completely avoidable.

Use a proper internal event bus. ui-service has a second Kafka consumer just for SSE notifications. That’s processor-service’s concern leaking into ui-service. Better design: Spring ApplicationEvent for internal communication, Kafka as transport only.

One service, one responsibility — strictly. When a service grows a second Kafka consumer for a UI concern, that’s a smell worth stopping.

Remove YAML default values for all secrets. ${SECRET:default} seems safe but the default silently wins when the env var isn’t loaded. Fail fast on missing secrets instead.

Try It #

HookSpy is live at https://hookspy.in — free tier, free forever.

Follow along at @backendbrilliance.

Technical blog: dynamicallyblunttech.com