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.
SourceResponse, we deliberately omit secretKey so it never leaves the server.| Layer | Annotation | Talks To | Never Does |
|---|---|---|---|
| Controller | @RestController | Service layer | Direct DB calls or business rules |
| Service | @Service | Repository + other services | HTTP concepts (request/response) |
| Repository | @Repository (implicit) | Database | Business logic |
| Model | @Entity | Hibernate (ORM) | Logic or HTTP |
Class-level Annotations
| Annotation | What it tells Spring |
|---|---|
| @SpringBootApplication | Main class. Auto-configure everything, scan all packages for beans. |
| @RestController | This class handles HTTP requests. All methods return JSON automatically. |
| @Service | This class contains business logic. Make it a Spring bean. |
| @Repository | This class accesses the database. (JpaRepository adds this automatically.) |
| @Entity | This class maps to a database table. |
| @Table(name="...") | Override the default table name and add indexes. |
| @RestControllerAdvice | This class handles exceptions thrown by any controller globally. |
Method-level Annotations (HTTP Mapping)
| Annotation | HTTP 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 |
| @PrePersist | This method runs automatically BEFORE inserting a row into DB |
Parameter Annotations (What to Extract from the Request)
| Annotation | Extracts From | Example |
|---|---|---|
| @PathVariable | URL path: /events/42 | Long id = 42 |
| @RequestParam | Query string: ?status=FAILED | String status = "FAILED" |
| @RequestBody | HTTP request body (JSON) | String rawPayload = "{...}" |
| @RequestHeader | HTTP header: X-Signature: abc | String sig = "abc" |
| @Valid | Triggers validation annotations (@NotBlank etc.) | Throws 400 if invalid |
JPA Field Annotations
| Annotation | What it does to the DB column |
|---|---|
| @Id | This 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 |
| @Lob | Store as TEXT/CLOB instead of VARCHAR(255) — for large content |
| @Enumerated(STRING) | Store enum as its name ("RECEIVED") not its number (0) |
| @OneToMany | One entity has a list of another entity (one event → many logs) |
| @ManyToOne | Many entities belong to one (many logs → one event) |
| @JoinColumn | Defines which column is the foreign key |
WebhookEvent — Annotated with Explanations
The 4 Entities and Their Relationships
| Entity | Table | Relationship | Purpose |
|---|---|---|---|
| WebhookEvent | webhook_events | Has many DeliveryLogs | Stores incoming webhook — the "letter" in the postal analogy |
| WebhookSubscription | subscriptions | Has many DeliveryLogs | A subscriber endpoint — who wants to receive what event types |
| DeliveryLog | delivery_logs | Belongs to Event + Subscription | One row per delivery attempt — full audit trail |
| EventSource | event_sources | Independent | Allowed senders — stores their secret key for HMAC validation |
Lazy vs Eager Loading — Performance Critical Concept
Spring Data reads your method names and generates the SQL. You write zero SQL for standard queries.
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
| Method | Generated 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 = ? |
Constructor Injection — Why We Do It This Way
The 5-Step Receive Flow
Optional — Handling "Might Not Exist" Results
How It Works — Step by Step
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.
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.
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.
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.
SourceResponse DTO deliberately omits the secretKey field — even GET /api/sources never returns it.Exponential Backoff — The Pattern
RestTemplate — Making Outgoing HTTP Calls
Why We Record Every Attempt in DeliveryLog
Event Status State Machine
| Scenario | Final Status |
|---|---|
| No active subscriptions match this event type | IGNORED |
| All subscribers received it successfully | DELIVERED |
| Some subscribers got it, some didn't | PARTIAL |
| No subscriber received it after all retries | FAILED |
| Manual retry triggered on FAILED event | Reset to RECEIVED → reprocessed |
HTTP Status Codes We Use and Why
| Code | Meaning | When We Use It |
|---|---|---|
| 200 OK | Success | GET, PATCH, POST receive (returns data) |
| 201 Created | New resource created | POST /subscriptions, POST /sources |
| 204 No Content | Success with no body | DELETE /subscriptions/{id} |
| 400 Bad Request | Client sent invalid data | Validation fails (@NotBlank, @Pattern) |
| 401 Unauthorized | Not authenticated | Invalid HMAC signature |
| 404 Not Found | Resource doesn't exist | Event/subscription/source ID not found |
| 500 Internal Server Error | Our bug | Unexpected exceptions (caught globally) |
"duplicate": true, we signal "we got it, don't retry" without processing it again.payment.captured webhook to POST /api/events/receive/razorpay.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()
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(...)
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.
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.
WebhookEventService — Step 3: Idempotency check
repository.findByExternalId(externalId) → if already exists → throws DuplicateEventException → GlobalExceptionHandler returns 200 with duplicate: true. Razorpay stops retrying.
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.
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 *).
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.
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.
Spring Boot & Architecture
WebhookApplication.java has this — Spring finds all our controllers, services, and repositories automatically.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.EventController just calls eventService.receive() — the controller doesn't care how delivery works.@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
EventStatus in WebhookEvent.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.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
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.EventDeliveryService, we implement this with a simple int[] RETRY_DELAYS_SECONDS = {0, 1, 5, 30} array.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
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.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.