diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java
new file mode 100644
index 0000000..73149b7
--- /dev/null
+++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/WebhookE2ETest.java
@@ -0,0 +1,239 @@
+package cloud.nstart.terravault.ordinis.app.e2e;
+
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
+import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery;
+import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDeliveryRepository;
+import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
+import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscriptionRepository;
+import cloud.nstart.terravault.ordinis.outbox.webhook.HmacSigner;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * End-to-end webhook delivery test:
+ *
+ * - Стартуем embedded {@link HttpServer} на random localhost port —
+ * imitate consumer's webhook receiver.
+ * - Создаём WebhookSubscription указывающий на этот endpoint.
+ * - POST a record — попадает в outbox_events.
+ * - WebhookDispatcher должен matchTick → INSERT delivery →
+ * sendTick → POST на наш HttpServer → mark delivered.
+ * - Проверяем: receiver получил body + valid HMAC signature +
+ * Ordinis headers; delivery row mark'нут как delivered.
+ *
+ *
+ * SSRF guard разрешает localhost через {@code allowed-hosts} config.
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {
+ "ordinis.outbox.enabled=true",
+ "ordinis.outbox.poll-interval-ms=200",
+ "ordinis.outbox.batch-size=20",
+ "ordinis.webhook.enabled=true",
+ "ordinis.webhook.match-interval-ms=200",
+ "ordinis.webhook.send-interval-ms=200",
+ "ordinis.webhook.allowed-hosts=localhost,127.0.0.1",
+ })
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class WebhookE2ETest {
+
+ @DynamicPropertySource
+ static void props(DynamicPropertyRegistry registry) {
+ E2ESupport.registerProperties(registry);
+ }
+
+ @Autowired MockMvc mvc;
+ @Autowired ObjectMapper om;
+ @Autowired OutboxEventRepository outboxRepo;
+ @Autowired WebhookSubscriptionRepository subRepo;
+ @Autowired WebhookDeliveryRepository deliveryRepo;
+
+ private HttpServer receiver;
+ private final ConcurrentLinkedQueue received = new ConcurrentLinkedQueue<>();
+
+ record ReceivedRequest(String path, String body, String signatureHeader, String eventTypeHeader, String scopeHeader) {}
+
+ @BeforeEach
+ void startReceiver() throws IOException {
+ receiver = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ receiver.createContext("/hook", exchange -> {
+ byte[] body = exchange.getRequestBody().readAllBytes();
+ received.add(new ReceivedRequest(
+ exchange.getRequestURI().getPath(),
+ new String(body, StandardCharsets.UTF_8),
+ exchange.getRequestHeaders().getFirst(HmacSigner.HEADER_NAME),
+ exchange.getRequestHeaders().getFirst("X-Ordinis-Event-Type"),
+ exchange.getRequestHeaders().getFirst("X-Ordinis-Scope")));
+ exchange.sendResponseHeaders(200, -1);
+ exchange.close();
+ });
+ receiver.start();
+ }
+
+ @AfterEach
+ void stopReceiver() {
+ if (receiver != null) receiver.stop(0);
+ }
+
+ @Test
+ void recordCreate_dispatchedToWebhookReceiver_withValidHmac() throws Exception {
+ int port = receiver.getAddress().getPort();
+ String hookUrl = "http://127.0.0.1:" + port + "/hook";
+
+ // 1. Создаём dict
+ String dictName = "wh_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+ JsonNode schema = om.readTree("""
+ {"$schema":"http://json-schema.org/draft-07/schema#","type":"object",
+ "additionalProperties":false,"x-id-source":"code","required":["code"],
+ "properties":{"code":{"type":"string","x-unique":true}}}""");
+ var dictBody = om.createObjectNode()
+ .put("name", dictName)
+ .put("scope", "PUBLIC")
+ .put("schemaVersion", "1.0.0")
+ .put("bundle", "test");
+ dictBody.set("schemaJson", schema);
+ mvc.perform(post("/api/v1/dictionaries")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(dictBody)))
+ .andExpect(status().is2xxSuccessful());
+
+ // 2. Создаём webhook subscription напрямую через repository (CRUD endpoint
+ // требует RESTRICTED scope от JWT, в тесте без auth — проще через repo)
+ String secret = "test-secret-" + UUID.randomUUID();
+ WebhookSubscription sub = new WebhookSubscription(
+ "e2e-test-receiver", hookUrl, secret, "test-user");
+ sub = subRepo.save(sub);
+ UUID subId = sub.getId();
+
+ long deliveriesBefore = deliveryRepo.count();
+
+ // 3. POST record → outbox_events row created
+ String needle = "EVT_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
+ var recBody = om.createObjectNode().put("businessKey", needle);
+ recBody.set("data", om.createObjectNode().put("code", needle));
+ mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(recBody)))
+ .andExpect(status().is2xxSuccessful());
+
+ // 4. Wait for delivery: receiver получил POST request
+ await().atMost(20, TimeUnit.SECONDS)
+ .pollInterval(Duration.ofMillis(300))
+ .until(() -> received.stream().anyMatch(r -> r.body().contains(needle)));
+
+ // 5. Verify request properties
+ ReceivedRequest req = received.stream()
+ .filter(r -> r.body().contains(needle))
+ .findFirst()
+ .orElseThrow();
+
+ assertThat(req.path()).isEqualTo("/hook");
+ assertThat(req.scopeHeader()).isEqualTo("PUBLIC");
+ assertThat(req.eventTypeHeader()).isNotBlank();
+
+ // HMAC signature валиден: receiver мог бы verify через свой secret
+ String expectedSig = HmacSigner.sign(secret, req.body().getBytes(StandardCharsets.UTF_8));
+ assertThat(req.signatureHeader()).isEqualTo(expectedSig);
+
+ // 6. Verify DB delivery row marked delivered
+ await().atMost(10, TimeUnit.SECONDS)
+ .pollInterval(Duration.ofMillis(300))
+ .until(() -> deliveryRepo.count() > deliveriesBefore);
+
+ List all = deliveryRepo.findBySubscriptionIdOrderByCreatedAtDesc(
+ subId, org.springframework.data.domain.PageRequest.of(0, 10)).getContent();
+ assertThat(all).isNotEmpty();
+ WebhookDelivery latest = all.get(0);
+ await().atMost(10, TimeUnit.SECONDS)
+ .pollInterval(Duration.ofMillis(300))
+ .until(() -> {
+ WebhookDelivery d = deliveryRepo.findById(latest.getId()).orElseThrow();
+ return d.getStatus() == WebhookDelivery.Status.delivered;
+ });
+
+ WebhookDelivery delivered = deliveryRepo.findById(latest.getId()).orElseThrow();
+ assertThat(delivered.getStatus()).isEqualTo(WebhookDelivery.Status.delivered);
+ assertThat(delivered.getLastStatusCode()).isEqualTo(200);
+ assertThat(delivered.getDeliveredAt()).isNotNull();
+ assertThat(delivered.getLastError()).isNull();
+ }
+
+ @Test
+ void receiverReturning500_deliveryRetried_notDelivered() throws Exception {
+ // Стартуем ALT receiver который всегда 500
+ HttpServer failing = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ failing.createContext("/fail", exchange -> {
+ byte[] body = exchange.getRequestBody().readAllBytes(); // drain
+ assertThat(body.length).isGreaterThan(0);
+ exchange.sendResponseHeaders(500, -1);
+ exchange.close();
+ });
+ failing.start();
+ try {
+ int port = failing.getAddress().getPort();
+ String hookUrl = "http://127.0.0.1:" + port + "/fail";
+
+ // dict
+ String dictName = "whf_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+ JsonNode schema = om.readTree("""
+ {"$schema":"http://json-schema.org/draft-07/schema#","type":"object",
+ "additionalProperties":false,"x-id-source":"code","required":["code"],
+ "properties":{"code":{"type":"string","x-unique":true}}}""");
+ var dictBody = om.createObjectNode()
+ .put("name", dictName).put("scope", "PUBLIC")
+ .put("schemaVersion", "1.0.0").put("bundle", "test");
+ dictBody.set("schemaJson", schema);
+ mvc.perform(post("/api/v1/dictionaries").contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(dictBody))).andExpect(status().is2xxSuccessful());
+
+ WebhookSubscription sub = subRepo.save(new WebhookSubscription(
+ "e2e-failing", hookUrl, "secret-fail", "test-user"));
+
+ String needle = "EVT_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
+ var recBody = om.createObjectNode().put("businessKey", needle);
+ recBody.set("data", om.createObjectNode().put("code", needle));
+ mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(recBody))).andExpect(status().is2xxSuccessful());
+
+ // Ждём что delivery с status=retrying появится (HTTP 500 → markFailed)
+ await().atMost(20, TimeUnit.SECONDS)
+ .pollInterval(Duration.ofMillis(300))
+ .until(() -> deliveryRepo.findBySubscriptionIdOrderByCreatedAtDesc(
+ sub.getId(), org.springframework.data.domain.PageRequest.of(0, 10))
+ .getContent().stream()
+ .anyMatch(d -> d.getStatus() == WebhookDelivery.Status.retrying
+ && d.getLastStatusCode() != null && d.getLastStatusCode() == 500));
+ } finally {
+ failing.stop(0);
+ }
+ }
+}