← Course Index | WebhookFlow — Code Explained
Weeks 2–3
WebhookFlow · Code Explained · Weeks 2–3

Understand Every Line of
WebhookFlow

This page explains WHY every annotation, pattern, and design decision exists in our codebase — so you can answer interview questions, not just copy code.

🏗️ Package Architecture 🗄️ JPA Entities 🔒 HMAC Security 🔄 Retry Logic ❓ Interview Q&A
🏗️
Design
Package Architecture — Why We Split Code This Way
The layered architecture every Java project should follow
Analogy — A Restaurant: The controller is the waiter (takes your order, brings food). The service is the chef (knows how to cook, applies business logic). The repository is the ingredient store (gets/stores data). The model is the recipe card (defines the structure of data). Each layer talks only to the layer directly below it — the waiter never goes to the store, the chef never talks to customers directly.
com.campustoai.webhook/ ├── model/ ← JPA Entities — Java classes that become DB tables │ ├── WebhookEvent.java → table: webhook_events │ ├── WebhookSubscription.java → table: subscriptions │ ├── DeliveryLog.java → table: delivery_logs │ ├── EventSource.java → table: event_sources │ └── EventStatus.java → enum (not a table) │ ├── repository/ ← Spring Data JPA interfaces — auto-generated SQL │ ├── WebhookEventRepository.java │ ├── WebhookSubscriptionRepository.java │ ├── DeliveryLogRepository.java │ └── EventSourceRepository.java │ ├── service/ ← Business logic — the "brain" of the application │ ├── WebhookEventService.java → orchestrates receive flow │ ├── EventDeliveryService.java → HTTP delivery + retry │ ├── SubscriptionService.java → subscription CRUD + matching │ ├── EventSourceService.java → source CRUD │ ├── HmacValidationService.java → signature security │ └── AnalyticsService.java → metrics aggregation │ ├── controller/ ← REST Controllers — HTTP in, HTTP out │ ├── EventController.java │ ├── SubscriptionController.java │ ├── SourceController.java │ └── AnalyticsController.java │ ├── dto/ ← Data Transfer Objects — what we send/receive over HTTP │ ├── EventResponse.java → safe view of WebhookEvent │ ├── CreateSubscriptionRequest.java → validated input for POST /subscriptions │ └── ... │ └── exception/ ← Centralized error handling ├── GlobalExceptionHandler.java └── ResourceNotFoundException.java, ...
Why separate entities from DTOs? Entities are for the database — they may contain sensitive fields (like secretKey) or lazy-loaded collections that cause errors when serialized directly to JSON. DTOs are what we choose to expose over HTTP. In SourceResponse, we deliberately omit secretKey so it never leaves the server.
LayerAnnotationTalks ToNever Does
Controller@RestControllerService layerDirect DB calls or business rules
Service@ServiceRepository + other servicesHTTP concepts (request/response)
Repository@Repository (implicit)DatabaseBusiness logic
Model@EntityHibernate (ORM)Logic or HTTP
🏷️
Reference
Spring Annotations — What Each One Does
Every annotation used in WebhookFlow explained in one line

Class-level Annotations

AnnotationWhat it tells Spring
@SpringBootApplicationMain class. Auto-configure everything, scan all packages for beans.
@RestControllerThis class handles HTTP requests. All methods return JSON automatically.
@ServiceThis class contains business logic. Make it a Spring bean.
@RepositoryThis class accesses the database. (JpaRepository adds this automatically.)
@EntityThis class maps to a database table.
@Table(name="...")Override the default table name and add indexes.
@RestControllerAdviceThis class handles exceptions thrown by any controller globally.

Method-level Annotations (HTTP Mapping)

AnnotationHTTP Method + Usage
@GetMapping("/path")GET request — used to READ/list data
@PostMapping("/path")POST request — used to CREATE new data
@PutMapping("/path")PUT request — used to REPLACE/UPDATE data (full update)
@PatchMapping("/path")PATCH request — used to PARTIALLY update data (e.g. toggle one field)
@DeleteMapping("/path")DELETE request — used to REMOVE data
@ExceptionHandler(X.class)This method runs when exception X is thrown
@PrePersistThis method runs automatically BEFORE inserting a row into DB

Parameter Annotations (What to Extract from the Request)

AnnotationExtracts FromExample
@PathVariableURL path: /events/42Long id = 42
@RequestParamQuery string: ?status=FAILEDString status = "FAILED"
@RequestBodyHTTP request body (JSON)String rawPayload = "{...}"
@RequestHeaderHTTP header: X-Signature: abcString sig = "abc"
@ValidTriggers validation annotations (@NotBlank etc.)Throws 400 if invalid

JPA Field Annotations

AnnotationWhat it does to the DB column
@IdThis field is the Primary Key
@GeneratedValue(IDENTITY)DB auto-generates the value (auto-increment)
@Column(nullable=false)Column cannot be NULL — required field
@Column(unique=true)No two rows can have the same value
@Column(updatable=false)Column is set on INSERT and never changed by UPDATE
@LobStore as TEXT/CLOB instead of VARCHAR(255) — for large content
@Enumerated(STRING)Store enum as its name ("RECEIVED") not its number (0)
@OneToManyOne entity has a list of another entity (one event → many logs)
@ManyToOneMany entities belong to one (many logs → one event)
@JoinColumnDefines which column is the foreign key
🗄️
Model Layer
JPA Entities Deep Dive
How Java classes become database tables
What is JPA? JPA (Java Persistence API) is a specification that maps Java objects to database tables. Hibernate is the implementation that does the actual work. You write Java classes → Hibernate creates tables, generates SQL, handles transactions. You never write CREATE TABLE or basic SELECT/INSERT SQL manually.

WebhookEvent — Annotated with Explanations

WebhookEvent.java — key sections
@Entity // → Hibernate creates: CREATE TABLE webhook_events (...) @Table(name = "webhook_events", indexes = { @Index(columnList = "source"), // → CREATE INDEX ... ON webhook_events(source) @Index(columnList = "status") // → CREATE INDEX ... ON webhook_events(status) }) public class WebhookEvent { @Id // PRIMARY KEY @GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT private Long id; @Column(nullable = false) // NOT NULL constraint in DB private String source; // "razorpay", "github" @Column(unique = true) // UNIQUE constraint — for idempotency private String externalId; @Lob // Store as TEXT, not VARCHAR(255) @Column(columnDefinition = "TEXT") // Explicit type for H2 + PostgreSQL private String payload; @Enumerated(EnumType.STRING) // Stores "RECEIVED" not 0 — safe to add new values later private EventStatus status; @OneToMany(mappedBy = "event", // DeliveryLog.event is the owner of the relationship cascade = CascadeType.ALL, // Delete event → auto-delete all its delivery logs fetch = FetchType.LAZY) // Don't load logs from DB unless explicitly needed private List<DeliveryLog> deliveryLogs; @PrePersist // Runs before INSERT — auto-sets timestamps protected void onCreate() { if (receivedAt == null) receivedAt = LocalDateTime.now(); } }
Why NOT use @Enumerated(EnumType.ORDINAL)? ORDINAL stores 0, 1, 2, 3... If you later add a new status between existing ones (e.g. add QUEUED between RECEIVED and PROCESSING), every existing row's number shifts — you corrupt all existing data. Always use STRING.

The 4 Entities and Their Relationships

EntityTableRelationshipPurpose
WebhookEventwebhook_eventsHas many DeliveryLogsStores incoming webhook — the "letter" in the postal analogy
WebhookSubscriptionsubscriptionsHas many DeliveryLogsA subscriber endpoint — who wants to receive what event types
DeliveryLogdelivery_logsBelongs to Event + SubscriptionOne row per delivery attempt — full audit trail
EventSourceevent_sourcesIndependentAllowed senders — stores their secret key for HMAC validation

Lazy vs Eager Loading — Performance Critical Concept

// LAZY (default for @OneToMany) — CORRECT ✅ @OneToMany(fetch = FetchType.LAZY) private List<DeliveryLog> deliveryLogs; // deliveryLogs is NOT loaded from DB when you load the WebhookEvent // Only loaded when you call event.getDeliveryLogs() — saves DB round trips // EAGER — DANGEROUS ❌ (don't do this for lists) @OneToMany(fetch = FetchType.EAGER) // Every time you load ANY WebhookEvent, ALL delivery logs are also loaded // Load 100 events → 100 extra queries for delivery logs → N+1 problem
🔍
Repository Layer
Spring Data JPA — Query Derivation Magic
How method names become SQL queries automatically

Spring Data reads your method names and generates the SQL. You write zero SQL for standard queries.

WebhookEventRepository.java — every method explained
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> { // Spring generates: SELECT * FROM webhook_events WHERE external_id = ? // Optional means it might return null — caller must handle both cases Optional<WebhookEvent> findByExternalId(String externalId); // SELECT * FROM webhook_events ORDER BY received_at DESC List<WebhookEvent> findAllByOrderByReceivedAtDesc(); // SELECT * FROM webhook_events WHERE source = ? ORDER BY received_at DESC List<WebhookEvent> findBySourceOrderByReceivedAtDesc(String source); // SELECT * FROM webhook_events WHERE status = ? ORDER BY received_at DESC List<WebhookEvent> findByStatusOrderByReceivedAtDesc(EventStatus status); // SELECT * FROM webhook_events WHERE source = ? AND status = ? ORDER BY ... List<WebhookEvent> findBySourceAndStatusOrderByReceivedAtDesc(String source, EventStatus status); // SELECT * FROM webhook_events WHERE received_at > ? ORDER BY ... // "After" keyword maps to the > operator in SQL List<WebhookEvent> findByReceivedAtAfterOrderByReceivedAtDesc(LocalDateTime from); // SELECT COUNT(*) FROM webhook_events WHERE received_at > ? long countByReceivedAtAfter(LocalDateTime since); }
Keyword Dictionary — how Spring reads method names:
findBy → WHERE | And → AND | Or → OR | OrderBy → ORDER BY | Desc → DESC | After → > | Before → < | Like → LIKE | In → IN (...) | countBy → SELECT COUNT(*) WHERE

What JpaRepository gives you for FREE

MethodGenerated SQL
save(entity)INSERT if new, UPDATE if existing (checks if id is null)
findById(id)SELECT * FROM ... WHERE id = ? (returns Optional)
findAll()SELECT * FROM ...
deleteById(id)DELETE FROM ... WHERE id = ?
count()SELECT COUNT(*) FROM ...
existsById(id)SELECT COUNT(*) > 0 WHERE id = ?
⚙️
Service Layer
WebhookEventService — The Orchestrator
Constructor injection, Optional, and the 5-step receive flow

Constructor Injection — Why We Do It This Way

// ❌ Field injection — works but bad practice @Autowired private WebhookEventRepository repository; // Problems: can't unit test without Spring, dependencies are hidden, allows null fields // ✅ Constructor injection — the correct way private final WebhookEventRepository repository; // final = cannot be reassigned after construction public WebhookEventService(WebhookEventRepository repository) { this.repository = repository; // Spring calls this constructor and passes the dependency } // Benefits: explicit dependencies, testable without Spring, object always fully initialized

The 5-Step Receive Flow

public WebhookEvent receive(String sourceName, String rawPayload, String signatureHeader, String eventType, String externalId) { // STEP 1 — Verify source exists (throws 404 if not registered) EventSource source = eventSourceService.getByName(sourceName); // STEP 2 — Validate HMAC signature (throws 401 if tampered) hmacValidationService.validate(rawPayload, signatureHeader, source.getSecretKey(), sourceName); // STEP 3 — Idempotency check (return 200 if already processed) if (externalId != null && repository.findByExternalId(externalId).isPresent()) { throw new DuplicateEventException(externalId); } // STEP 4 — Build and save the entity WebhookEvent event = new WebhookEvent(); event.setSource(sourceName); event.setEventType(eventType != null ? eventType : "unknown"); event.setPayload(rawPayload); WebhookEvent saved = repository.save(event); // INSERT — id is auto-assigned here // STEP 5 — Deliver to matching subscribers deliveryService.deliver(saved); return saved; // Controller gets the saved event and returns eventId to the sender }

Optional — Handling "Might Not Exist" Results

// findById returns Optional<WebhookEvent> — might be empty if id doesn't exist Optional<WebhookEvent> result = repository.findById(id); // Bad way — might throw NullPointerException ❌ WebhookEvent event = result.get(); // Crashes if empty! // Good way — throw a meaningful 404 error ✅ WebhookEvent event = result .orElseThrow(() -> new ResourceNotFoundException("Event not found: " + id)); // orElseThrow: if value is present → return it; if empty → throw the given exception
🔒
Security
HMAC-SHA256 Signature Validation — Explained Simply
How we verify that a webhook is genuine and not forged
Analogy — A Wax Seal: In the old days, a king would seal a letter with his unique wax stamp. Anyone could read the letter, but only the king could create that exact seal. If the seal was broken or didn't match, the letter was tampered with. HMAC is a digital wax seal — only someone who knows the secret key can create the correct signature.

How It Works — Step by Step

1

Razorpay has a shared secret key

When you register your webhook in Razorpay dashboard, you set a secret (e.g. mySecret123). Both Razorpay and WebhookFlow know this secret — nobody else does.

2

Razorpay signs the payload before sending

Razorpay computes HMAC-SHA256 of the JSON body using the secret key → gets a hash string (e.g. a3f2c1...). This hash goes in the X-Razorpay-Signature header.

3

WebhookFlow computes its own hash

When we receive the request, we compute HMAC-SHA256 of the SAME body using OUR copy of the secret key.

4

Compare the two hashes

If our computed hash matches the header value → payload is genuine. If not → someone modified the payload in transit → reject with 401.

HmacValidationService.java — how we compute HMAC in Java
public String computeHmac(String payload, String secretKey) { // Mac = Message Authentication Code class from Java's crypto library Mac mac = Mac.getInstance("HmacSHA256"); // Wrap the secret key string in a SecretKeySpec so Mac can use it SecretKeySpec keySpec = new SecretKeySpec( secretKey.getBytes(StandardCharsets.UTF_8), // Key as bytes "HmacSHA256" ); mac.init(keySpec); // Initialize with the key byte[] hash = mac.doFinal( // Compute HMAC of the payload payload.getBytes(StandardCharsets.UTF_8) ); return HexFormat.of().formatHex(hash); // Convert bytes [10,255] → "0aff" }
Security rule: Never log the secretKey. Never include it in API responses. The SourceResponse DTO deliberately omits the secretKey field — even GET /api/sources never returns it.
🔄
Service Layer
EventDeliveryService — Retry with Exponential Backoff
How we deliver events reliably even when subscribers are down
Analogy — Courier with Patience: Imagine a courier who rings your doorbell. If you don't answer, they wait 1 minute and try again. Still no answer — wait 5 minutes. Then 30 minutes. After 4 tries they leave a "we tried" card. That's exactly what our delivery service does — but in seconds instead of minutes.

Exponential Backoff — The Pattern

// Wait times between retry attempts private static final int[] RETRY_DELAYS_SECONDS = {0, 1, 5, 30}; // ↑ ↑ ↑ ↑ // attempt1 2 3 4 // attempt 1 (index 0): 0s — no wait, try immediately // attempt 2 (index 1): 1s — wait 1 second before retry // attempt 3 (index 2): 5s — wait 5 seconds // attempt 4 (index 3): 30s — wait 30 seconds // After 4 failures → return false → event marked FAILED

RestTemplate — Making Outgoing HTTP Calls

// RestTemplate is Spring's HTTP client for calling other services HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); // Content-Type: application/json headers.set("X-WebhookFlow-Event", event.getEventType()); // Custom headers so subscriber knows what arrived // Bundle the payload + headers into one object HttpEntity<String> request = new HttpEntity<>(event.getPayload(), headers); // Make the HTTP POST call — returns status code + response body ResponseEntity<String> response = restTemplate.postForEntity( sub.getTargetUrl(), // e.g. https://orders.myapp.com/webhook request, // body + headers String.class // parse response body as a String ); boolean success = response.getStatusCode().is2xxSuccessful(); // 200–299 = success

Why We Record Every Attempt in DeliveryLog

// After EVERY attempt (success or failure), we save a DeliveryLog row DeliveryLog log = new DeliveryLog(); log.setEvent(event); // Foreign key: event_id log.setSubscription(sub); // Foreign key: subscription_id log.setAttemptNumber(attempt); // 1, 2, 3, or 4 log.setResponseStatus(statusCode); // 200, 500, or 0 (timeout) log.setSuccess(success); log.setDurationMs(durationMs); // How long the call took — for latency analytics deliveryLogRepository.save(log); // Why? Full observability — you can answer: // "Event 42 failed to deliver to Order Service — what happened?" // → Check delivery_logs WHERE event_id = 42 → see all 4 attempts, status codes, errors

Event Status State Machine

ScenarioFinal Status
No active subscriptions match this event typeIGNORED
All subscribers received it successfullyDELIVERED
Some subscribers got it, some didn'tPARTIAL
No subscriber received it after all retriesFAILED
Manual retry triggered on FAILED eventReset to RECEIVED → reprocessed
🌐
Controller Layer
REST Controllers — HTTP In, JSON Out
How annotations map URLs to Java methods
EventController.java — key endpoint annotated
@RestController // = @Controller + @ResponseBody (auto-serialize to JSON) @RequestMapping("/api/events") // All methods in this class start with /api/events public class EventController { @PostMapping("/receive/{source}") // Maps POST /api/events/receive/razorpay public ResponseEntity<ReceiveEventResponse> receive( @PathVariable String source, // URL: /receive/razorpay → source = "razorpay" @RequestBody String rawPayload, // HTTP body → raw JSON string @RequestHeader(value = "X-Signature", // Read this HTTP header required = false) String signature, // null if not sent @RequestHeader(value = "X-Event-Type", required = false) String eventType, @RequestHeader(value = "X-External-Id", required = false) String externalId ) { WebhookEvent event = eventService.receive(source, rawPayload, signature, eventType, externalId); return ResponseEntity.ok( // ResponseEntity.ok() = 200 OK status new ReceiveEventResponse(event.getId(), "RECEIVED", "Event accepted") ); } @GetMapping // Maps GET /api/events public ResponseEntity<List<EventResponse>> listEvents( @RequestParam(required = false) String source, // ?source=razorpay (optional) @RequestParam(required = false) String status, // ?status=FAILED (optional) @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from // ?from=2025-01-01T00:00:00 ) { return ResponseEntity.ok(eventService.findAll(source, status, from)); } }

HTTP Status Codes We Use and Why

CodeMeaningWhen We Use It
200 OKSuccessGET, PATCH, POST receive (returns data)
201 CreatedNew resource createdPOST /subscriptions, POST /sources
204 No ContentSuccess with no bodyDELETE /subscriptions/{id}
400 Bad RequestClient sent invalid dataValidation fails (@NotBlank, @Pattern)
401 UnauthorizedNot authenticatedInvalid HMAC signature
404 Not FoundResource doesn't existEvent/subscription/source ID not found
500 Internal Server ErrorOur bugUnexpected exceptions (caught globally)
⚠️
Cross-cutting
Global Exception Handling — Clean Errors Without Boilerplate
How @RestControllerAdvice centralizes all error responses
Without @RestControllerAdvice: Every controller method needs try-catch blocks. If you forget one, the client gets a raw Java stack trace (500 error with confusing text). With it: Throw exceptions anywhere in any service/controller → GlobalExceptionHandler automatically catches it and returns a clean JSON error.
@RestControllerAdvice // Intercepts exceptions from ALL @RestController classes public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<...> handleNotFound(ResourceNotFoundException ex) { return errorResponse(HttpStatus.NOT_FOUND, ex.getMessage()); // → 404 } @ExceptionHandler(DuplicateEventException.class) public ResponseEntity<...> handleDuplicate(DuplicateEventException ex) { // Duplicates return 200 — Razorpay retries on any non-2xx! ← crucial design decision return ResponseEntity.ok(body); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<...> handleValidation(MethodArgumentNotValidException ex) { // Collect field errors: { "targetUrl": "must be a valid URL", "name": "required" } Map<String, String> fieldErrors = new HashMap<>(); for (FieldError err : ex.getBindingResult().getFieldErrors()) { fieldErrors.put(err.getField(), err.getDefaultMessage()); } return ResponseEntity.badRequest().body(body); // → 400 } @ExceptionHandler(Exception.class) // Catches ANYTHING not caught above public ResponseEntity<...> handleGeneral(Exception ex) { return errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); // We don't expose ex.getMessage() — might leak internal details to the client } }
Why is DuplicateEventException handled with 200? Razorpay (and most webhook senders) consider any non-2xx response as a failure and will retry the webhook — potentially dozens of times. If we return 409 Conflict for a duplicate, Razorpay keeps retrying. By returning 200 with "duplicate": true, we signal "we got it, don't retry" without processing it again.
🔄
Big Picture
Full Request Lifecycle — Trace a Razorpay Payment Event
Follow one request from entry to database through all layers
Scenario: Rahul pays ₹2,499. Razorpay sends a payment.captured webhook to POST /api/events/receive/razorpay.
1

HTTP Request arrives at Tomcat

Spring's DispatcherServlet receives the request. It looks at the method (POST) and URL (/api/events/receive/razorpay) and finds the matching method: EventController.receive()

2

Controller extracts inputs

@PathVariable gives us source = "razorpay". @RequestBody gives us the raw JSON payload. @RequestHeader gives us the HMAC signature. Controller calls eventService.receive(...)

3

WebhookEventService — Step 1: Look up source

eventSourceService.getByName("razorpay") → repository calls SELECT * FROM event_sources WHERE name = 'razorpay' → returns the EventSource with secretKey. If not found → 404.

4

WebhookEventService — Step 2: Validate signature

hmacValidationService.validate() computes HMAC-SHA256 of the payload using the source's secretKey. Compares to the header. If mismatch → throws InvalidSignatureException → GlobalExceptionHandler returns 401.

5

WebhookEventService — Step 3: Idempotency check

repository.findByExternalId(externalId) → if already exists → throws DuplicateEventException → GlobalExceptionHandler returns 200 with duplicate: true. Razorpay stops retrying.

6

WebhookEventService — Step 4: Save to DB

repository.save(event) → Hibernate generates: INSERT INTO webhook_events (source, event_type, payload, status, ...) VALUES (?, ?, ?, 'RECEIVED', ...). DB assigns id = 42.

7

EventDeliveryService — Find matching subscribers

subscriptionService.findMatchingSubscriptions("payment.captured") loads all active subscriptions and filters with the matches() method. Finds: Order Service (subscribed to payment.*) and Email Service (subscribed to *).

8

EventDeliveryService — HTTP POST to each subscriber

RestTemplate.postForEntity(orderServiceUrl, payload, String.class). If 2xx → success. If error → wait and retry (1s, 5s, 30s). Every attempt saved to delivery_logs table.

9

Final status updated + response sent

Event status updated to DELIVERED (or PARTIAL/FAILED). Controller returns 200 OK {"eventId": 42, "status": "RECEIVED"} to Razorpay. Razorpay is happy — stops retrying.

Interview Prep
Interview Questions — With Answers You Can Actually Give
Every question answerable using the code you've built

Spring Boot & Architecture

Q What does @SpringBootApplication do?
It combines three annotations: @SpringBootConfiguration (marks it as a config class), @EnableAutoConfiguration (auto-configures DataSource, Tomcat, JPA based on classpath dependencies), and @ComponentScan (scans sub-packages for @Service, @Controller, @Repository beans). In our project, WebhookApplication.java has this — Spring finds all our controllers, services, and repositories automatically.
Q What is Dependency Injection and why do we use constructor injection?
Dependency Injection means you don't create objects yourself — Spring creates them and passes them in. Constructor injection (used throughout WebhookFlow) is preferred because: 1) Dependencies are explicit and visible. 2) Fields can be final, preventing accidental reassignment. 3) The class is testable without Spring — you can pass mock objects directly to the constructor in unit tests. We declare private final WebhookEventRepository repository and Spring calls our constructor with the real repository bean.
Q Why separate controller, service, and repository layers?
Single Responsibility Principle. Controllers handle HTTP (input validation, status codes, routing) — they should not know SQL. Services contain business rules — they should not know about HTTP. Repositories talk to DB — they should not contain business rules. This separation makes each layer independently testable, swappable, and understandable. In WebhookFlow, EventController just calls eventService.receive() — the controller doesn't care how delivery works.
Q What is the difference between @Controller and @RestController?
@RestController = @Controller + @ResponseBody. The @ResponseBody part means every method's return value is automatically serialized to JSON and written to the HTTP response body (using Jackson). Without it, Spring would try to find a view template (like Thymeleaf) matching the returned string — which we don't have in a REST API.

JPA & Database

Q What is JPA and what is Hibernate?
JPA (Jakarta Persistence API) is a specification — a set of interfaces and rules that defines how Java objects should map to database tables. Hibernate is the most popular implementation of that specification. In WebhookFlow, we write JPA annotations (@Entity, @Column, @OneToMany) and Spring Data interfaces — Hibernate generates all the SQL at runtime. We never write CREATE TABLE or basic SELECT/INSERT.
Q Why use @Enumerated(EnumType.STRING) instead of ORDINAL?
ORDINAL stores enum values as 0, 1, 2... If you add a new value in the middle of the enum, every subsequent value's number shifts — corrupting all existing data. STRING stores the name ("RECEIVED", "FAILED") — adding new values never affects existing rows. Always use STRING in production code. We use it for EventStatus in WebhookEvent.
Q What is the N+1 query problem? How does Lazy loading help?
N+1 means: you load N events (1 query), then for each event you load its delivery logs (N more queries) = N+1 total. With FetchType.LAZY, delivery logs are NOT loaded automatically — only when you call event.getDeliveryLogs(). In our list endpoint (GET /api/events), we never call getDeliveryLogs(), so only 1 query runs. In the single event endpoint (GET /api/events/{id}), we pass true to EventResponse.from() which does call it — only 1 extra query for the 1 event you requested.
Q What is Spring Data JPA query derivation?
Spring reads the method name and generates the SQL. findBySourceOrderByReceivedAtDesc(String source)SELECT * FROM webhook_events WHERE source = ? ORDER BY received_at DESC. Keywords: findBy=WHERE, And=AND, Or=OR, OrderBy=ORDER BY, Desc=DESC, After= >, Before= <. This works for most queries — for very complex ones you'd use @Query with JPQL.

REST API Design

Q What is idempotency and how do we implement it?
Idempotency means calling an operation multiple times has the same effect as calling it once. In WebhookFlow, if Razorpay retries an event (because they didn't get a 200 fast enough), we don't want to process it twice. We implement this via the externalId field with a UNIQUE constraint. On receive, we check: repository.findByExternalId(externalId).isPresent() — if found, we throw DuplicateEventException which returns 200 OK with duplicate: true. Razorpay sees 200 and stops retrying.
Q What is exponential backoff and why is it used?
Exponential backoff means increasing the wait time between retries. We wait 0s → 1s → 5s → 30s. Why? If a subscriber's server is down or overloaded, bombarding it with constant retries makes things worse. Waiting progressively longer gives the server time to recover. This is the industry standard pattern used in AWS SQS, Kafka, and most distributed systems. In EventDeliveryService, we implement this with a simple int[] RETRY_DELAYS_SECONDS = {0, 1, 5, 30} array.
Q Why do we accept a raw String as @RequestBody instead of a Java object?
Two reasons: 1) HMAC validation: We must validate the signature against the EXACT bytes that arrived — if we let Jackson parse the JSON first, whitespace and key ordering might change, breaking the hash comparison. 2) Flexibility: Razorpay, GitHub, Stripe all have different JSON structures. A raw String lets us accept any payload without writing a different DTO for each provider. We store the raw payload in the DB and let downstream subscribers deal with their own format.
Q What is the difference between PUT and PATCH?
PUT = replace the entire resource. You send all fields — missing fields are set to null/default. PATCH = partial update. You only send the fields you want to change. In WebhookFlow, PUT /api/subscriptions/{id} requires all fields (name, targetUrl, eventTypes, maxRetries). PATCH /api/subscriptions/{id}/toggle just flips the active boolean — you don't send any body. Use PATCH for small, targeted changes.

Security

Q How does HMAC-SHA256 webhook validation work?
HMAC-SHA256 is a cryptographic message authentication code. Both sender (Razorpay) and receiver (WebhookFlow) share a secret key. When Razorpay sends a webhook, they compute HMAC-SHA256(payload, secretKey) and put the result in a header. We compute the same hash using our copy of the secret key and compare. If they match, the payload is genuine — nobody tampered with it in transit. If they differ, we return 401. This prevents attackers from sending fake payment events to mark orders as paid. Implemented in HmacValidationService.java.
Q Why does SourceResponse not include the secretKey field?
This is the Entity vs DTO separation principle. EventSource (the JPA entity) has a secretKey field. SourceResponse (the DTO) deliberately omits it. If we returned the entity directly, anyone calling GET /api/sources could see all secret keys — which would allow them to forge webhook signatures. DTOs give us control over exactly what data leaves the system over HTTP.