diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookDelivery.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookDelivery.java
new file mode 100644
index 0000000..5eae9fb
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookDelivery.java
@@ -0,0 +1,153 @@
+package cloud.nstart.terravault.ordinis.domain.webhook;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Webhook delivery — одна попытка доставки event'а подписчику. Один (subscription_id,
+ * outbox_event_id) row на match (uniq index). Жизненный цикл:
+ *
+ *
+ * pending → retrying → delivered (2xx response)
+ * ↓
+ * dlq (после attempt_count >= 1000)
+ *
+ *
+ * Backoff schedule: 1m, 5m, 15m, 1h, 6h, 24h (exponential), capped 24h.
+ * Computed в {@link #scheduleNextAttempt()}.
+ */
+@Entity
+@Table(name = "webhook_deliveries")
+public class WebhookDelivery {
+
+ public enum Status {
+ pending,
+ retrying,
+ delivered,
+ dlq
+ }
+
+ /** Backoff schedule in seconds. attemptCount-1 индексирует. После — last value. */
+ private static final long[] BACKOFF_SECONDS = {
+ 60L, // 1 min
+ 5 * 60L, // 5 min
+ 15 * 60L, // 15 min
+ 60 * 60L, // 1 h
+ 6 * 60 * 60L, // 6 h
+ 24 * 60 * 60L // 24 h
+ };
+
+ /** После этого attempt_count → DLQ flag. Совпадает с outbox cap. */
+ public static final int MAX_ATTEMPTS = 1000;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "subscription_id", nullable = false)
+ private UUID subscriptionId;
+
+ @Column(name = "outbox_event_id", nullable = false)
+ private Long outboxEventId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 16)
+ private Status status = Status.pending;
+
+ @Column(name = "attempt_count", nullable = false)
+ private int attemptCount = 0;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @Column(name = "next_attempt_at", nullable = false)
+ private OffsetDateTime nextAttemptAt;
+
+ @Column(name = "last_attempt_at")
+ private OffsetDateTime lastAttemptAt;
+
+ @Column(name = "delivered_at")
+ private OffsetDateTime deliveredAt;
+
+ @Column(name = "last_error", columnDefinition = "TEXT")
+ private String lastError;
+
+ @Column(name = "last_status_code")
+ private Integer lastStatusCode;
+
+ @Column(name = "dlq_at")
+ private OffsetDateTime dlqAt;
+
+ protected WebhookDelivery() {}
+
+ public WebhookDelivery(UUID subscriptionId, Long outboxEventId) {
+ this.subscriptionId = subscriptionId;
+ this.outboxEventId = outboxEventId;
+ OffsetDateTime now = OffsetDateTime.now();
+ this.createdAt = now;
+ this.nextAttemptAt = now;
+ }
+
+ public void markDelivered(int statusCode) {
+ this.status = Status.delivered;
+ this.deliveredAt = OffsetDateTime.now();
+ this.lastAttemptAt = this.deliveredAt;
+ this.lastStatusCode = statusCode;
+ this.lastError = null;
+ }
+
+ /** Отметить попытку как failed и запланировать retry, либо DLQ если cap. */
+ public void markFailed(String error, Integer statusCode) {
+ this.attemptCount++;
+ this.lastAttemptAt = OffsetDateTime.now();
+ this.lastError = error;
+ this.lastStatusCode = statusCode;
+
+ if (this.attemptCount >= MAX_ATTEMPTS) {
+ this.status = Status.dlq;
+ this.dlqAt = this.lastAttemptAt;
+ return;
+ }
+ this.status = Status.retrying;
+ scheduleNextAttempt();
+ }
+
+ /** Reset DLQ flag — поллер снова возьмётся (e.g. оператор fixed receiver URL). */
+ public void resetDlq() {
+ this.status = Status.retrying;
+ this.dlqAt = null;
+ this.attemptCount = 0;
+ this.nextAttemptAt = OffsetDateTime.now();
+ }
+
+ private void scheduleNextAttempt() {
+ int idx = Math.min(this.attemptCount - 1, BACKOFF_SECONDS.length - 1);
+ long seconds = BACKOFF_SECONDS[idx];
+ this.nextAttemptAt = OffsetDateTime.now().plusSeconds(seconds);
+ }
+
+ public Long getId() { return id; }
+ public UUID getSubscriptionId() { return subscriptionId; }
+ public Long getOutboxEventId() { return outboxEventId; }
+ public Status getStatus() { return status; }
+ public int getAttemptCount() { return attemptCount; }
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public OffsetDateTime getNextAttemptAt() { return nextAttemptAt; }
+ public OffsetDateTime getLastAttemptAt() { return lastAttemptAt; }
+ public OffsetDateTime getDeliveredAt() { return deliveredAt; }
+ public String getLastError() { return lastError; }
+ public Integer getLastStatusCode() { return lastStatusCode; }
+ public OffsetDateTime getDlqAt() { return dlqAt; }
+
+ public boolean isDlq() { return status == Status.dlq; }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookDeliveryRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookDeliveryRepository.java
new file mode 100644
index 0000000..4db3d02
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookDeliveryRepository.java
@@ -0,0 +1,57 @@
+package cloud.nstart.terravault.ordinis.domain.webhook;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+public interface WebhookDeliveryRepository extends JpaRepository {
+
+ /**
+ * Due for retry: status IN (pending, retrying) AND next_attempt_at <= now().
+ * Использует partial index {@code idx_webhook_deliveries_due}. Limit через
+ * Pageable чтобы dispatcher не выгребал >1000 за тик.
+ */
+ @Query("""
+ SELECT d FROM WebhookDelivery d
+ WHERE d.status IN ('pending','retrying')
+ AND d.nextAttemptAt <= :now
+ ORDER BY d.nextAttemptAt ASC
+ """)
+ List findDue(@Param("now") OffsetDateTime now, Pageable pageable);
+
+ /** DLQ листинг для admin UI. Partial index {@code idx_webhook_deliveries_dlq}. */
+ Page findByStatus(WebhookDelivery.Status status, Pageable pageable);
+
+ /** Метрика {@code nsi_webhook_dlq_count}. */
+ long countByStatus(WebhookDelivery.Status status);
+
+ /**
+ * Idempotent insert (sub × event = uniq). На race между dispatcher tick'ами
+ * вторая вставка попадёт в conflict, ON CONFLICT DO NOTHING. Native query
+ * чтобы не тащить ON CONFLICT через JPA.
+ */
+ @Modifying
+ @Query(value = """
+ INSERT INTO webhook_deliveries (subscription_id, outbox_event_id, status,
+ attempt_count, created_at, next_attempt_at)
+ VALUES (:subscriptionId, :outboxEventId, 'pending', 0, now(), now())
+ ON CONFLICT (subscription_id, outbox_event_id) DO NOTHING
+ """, nativeQuery = true)
+ int insertIfAbsent(
+ @Param("subscriptionId") UUID subscriptionId,
+ @Param("outboxEventId") Long outboxEventId);
+
+ /** История доставок одного event'а — для drilldown в UI. */
+ List findByOutboxEventIdOrderByCreatedAtDesc(Long outboxEventId);
+
+ /** История доставок одной подписки. */
+ Page findBySubscriptionIdOrderByCreatedAtDesc(
+ UUID subscriptionId, Pageable pageable);
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookSubscription.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookSubscription.java
new file mode 100644
index 0000000..c9bef6d
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookSubscription.java
@@ -0,0 +1,116 @@
+package cloud.nstart.terravault.ordinis.domain.webhook;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Webhook subscription — HTTP callback delivery параллельно с Kafka publishing.
+ * Для consumer'ов которые не могут Kafka subscribe (геопортал PHP, legacy admin-ui'и
+ * заказчиков, BI-системы).
+ *
+ * Filter arrays: NULL/empty = match all events. Match logic в
+ * {@code WebhookDispatcher} — event matches subscription iff
+ * (scope_filter is null OR scope IN scope_filter) AND
+ * (dictionary_filter is null OR dictionary_name IN dictionary_filter) AND
+ * (event_type_filter is null OR event_type IN event_type_filter).
+ *
+ *
HMAC secret hex-encoded 64 chars (32 bytes random). Generated server-side
+ * на create, returned ONCE в response, потом hidden — admin-ui показывает
+ * только маскированный preview {@code sk_****abc123}.
+ */
+@Entity
+@Table(name = "webhook_subscriptions")
+public class WebhookSubscription {
+
+ @Id
+ @Column(name = "id")
+ private UUID id;
+
+ @Column(name = "name", nullable = false, length = 128)
+ private String name;
+
+ @Column(name = "url", nullable = false, length = 2048)
+ private String url;
+
+ /** TEXT[] postgres → List via SqlTypes.ARRAY. */
+ @JdbcTypeCode(SqlTypes.ARRAY)
+ @Column(name = "scope_filter", columnDefinition = "TEXT[]")
+ private List scopeFilter;
+
+ @JdbcTypeCode(SqlTypes.ARRAY)
+ @Column(name = "dictionary_filter", columnDefinition = "TEXT[]")
+ private List dictionaryFilter;
+
+ @JdbcTypeCode(SqlTypes.ARRAY)
+ @Column(name = "event_type_filter", columnDefinition = "TEXT[]")
+ private List eventTypeFilter;
+
+ @Column(name = "hmac_secret", nullable = false, length = 128)
+ private String hmacSecret;
+
+ @Column(name = "active", nullable = false)
+ private boolean active = true;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @Column(name = "created_by", nullable = false, length = 128)
+ private String createdBy;
+
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ protected WebhookSubscription() {}
+
+ public WebhookSubscription(
+ String name,
+ String url,
+ String hmacSecret,
+ String createdBy) {
+ this.id = UUID.randomUUID();
+ this.name = name;
+ this.url = url;
+ this.hmacSecret = hmacSecret;
+ this.createdBy = createdBy;
+ OffsetDateTime now = OffsetDateTime.now();
+ this.createdAt = now;
+ this.updatedAt = now;
+ }
+
+ public void touch() {
+ this.updatedAt = OffsetDateTime.now();
+ }
+
+ public UUID getId() { return id; }
+ public String getName() { return name; }
+ public String getUrl() { return url; }
+ public List getScopeFilter() { return scopeFilter; }
+ public List getDictionaryFilter() { return dictionaryFilter; }
+ public List getEventTypeFilter() { return eventTypeFilter; }
+ public String getHmacSecret() { return hmacSecret; }
+ public boolean isActive() { return active; }
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public String getCreatedBy() { return createdBy; }
+ public OffsetDateTime getUpdatedAt() { return updatedAt; }
+ public String getDescription() { return description; }
+
+ public void setName(String v) { this.name = v; touch(); }
+ public void setUrl(String v) { this.url = v; touch(); }
+ public void setScopeFilter(List v) { this.scopeFilter = v; touch(); }
+ public void setDictionaryFilter(List v) { this.dictionaryFilter = v; touch(); }
+ public void setEventTypeFilter(List v) { this.eventTypeFilter = v; touch(); }
+ public void setActive(boolean v) { this.active = v; touch(); }
+ public void setDescription(String v) { this.description = v; touch(); }
+}
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookSubscriptionRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookSubscriptionRepository.java
new file mode 100644
index 0000000..23e736f
--- /dev/null
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/webhook/WebhookSubscriptionRepository.java
@@ -0,0 +1,20 @@
+package cloud.nstart.terravault.ordinis.domain.webhook;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+import java.util.UUID;
+
+public interface WebhookSubscriptionRepository extends JpaRepository {
+
+ /**
+ * Активные подписки. Использует partial index {@code idx_webhook_subscriptions_active}
+ * (where active = true). Hot path в WebhookDispatcher.
+ */
+ @Query("SELECT w FROM WebhookSubscription w WHERE w.active = true")
+ List findActive();
+
+ /** Метрика {@code nsi_webhook_subscriptions_active}. */
+ long countByActiveTrue();
+}
diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0013-webhook-subscriptions.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0013-webhook-subscriptions.xml
new file mode 100644
index 0000000..a4ffaf1
--- /dev/null
+++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0013-webhook-subscriptions.xml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ URL должен начинаться с https:// (production) или http:// (dev).
+ Scope values constrained тот же enum что DataScope в Java.
+
+
+ ALTER TABLE webhook_subscriptions
+ ADD CONSTRAINT chk_webhook_url_scheme
+ CHECK (url ~ '^https?://[^\s]+$');
+
+ ALTER TABLE webhook_subscriptions
+ ADD CONSTRAINT chk_webhook_scope_filter
+ CHECK (scope_filter IS NULL OR scope_filter <@ ARRAY['PUBLIC','INTERNAL','RESTRICTED']::TEXT[]);
+
+
+
+ ALTER TABLE webhook_subscriptions DROP CONSTRAINT IF EXISTS chk_webhook_url_scheme;
+ ALTER TABLE webhook_subscriptions DROP CONSTRAINT IF EXISTS chk_webhook_scope_filter;
+
+
+
+
+
+
+ Partial index — dispatcher poller сканит только активные subscriptions
+ (горячий path). Inactive sit без overhead.
+
+
+ CREATE INDEX idx_webhook_subscriptions_active
+ ON webhook_subscriptions (created_at)
+ WHERE active = true;
+
+
+ DROP INDEX IF EXISTS idx_webhook_subscriptions_active;
+
+
+
+
diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0014-webhook-deliveries.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0014-webhook-deliveries.xml
new file mode 100644
index 0000000..976c88e
--- /dev/null
+++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0014-webhook-deliveries.xml
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALTER TABLE webhook_deliveries
+ ADD CONSTRAINT chk_webhook_delivery_status
+ CHECK (status IN ('pending','retrying','delivered','dlq'));
+
+
+
+ ALTER TABLE webhook_deliveries DROP CONSTRAINT IF EXISTS chk_webhook_delivery_status;
+
+
+
+
+
+
+ Hot poller path: WHERE status IN ('pending','retrying') AND
+ next_attempt_at <= now(). Partial index покрывает обе колонки.
+
+
+ CREATE INDEX idx_webhook_deliveries_due
+ ON webhook_deliveries (next_attempt_at)
+ WHERE status IN ('pending', 'retrying');
+
+
+ DROP INDEX IF EXISTS idx_webhook_deliveries_due;
+
+
+
+
+ Для UI listing DLQ + reset операций.
+
+ CREATE INDEX idx_webhook_deliveries_dlq
+ ON webhook_deliveries (dlq_at DESC)
+ WHERE status = 'dlq';
+
+
+ DROP INDEX IF EXISTS idx_webhook_deliveries_dlq;
+
+
+
+
+
+ Idempotency: один subscription × event = один row. Если dispatcher
+ ловит retry где event/subscription уже создавал delivery — INSERT
+ ON CONFLICT DO NOTHING.
+
+
+ CREATE UNIQUE INDEX uniq_webhook_deliveries_subscription_event
+ ON webhook_deliveries (subscription_id, outbox_event_id);
+
+
+ DROP INDEX IF EXISTS uniq_webhook_deliveries_subscription_event;
+
+
+
+
diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml
index 95289a3..9bc6291 100644
--- a/ordinis-migrations/src/main/resources/db/changelog/master.xml
+++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml
@@ -22,5 +22,7 @@
+
+