diff --git a/Cargo.lock b/Cargo.lock
index 300df514b..af7f65834 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3239,6 +3239,7 @@ dependencies = [
"flate2",
"futures",
"handlebars 5.1.2",
+ "hmac",
"hyper",
"indexmap 2.5.0",
"infer",
diff --git a/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json b/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json
new file mode 100644
index 000000000..fdd295fed
--- /dev/null
+++ b/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json
@@ -0,0 +1,30 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE webhook_deliveries\n SET status = $2, error = $3, request_headers = $4, request_body = $5, response_http_code = null, response_headers = null, response_body = null\n WHERE id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ {
+ "Custom": {
+ "name": "webhook_delivery_status",
+ "kind": {
+ "Enum": [
+ "pending",
+ "success",
+ "failure",
+ "retrying"
+ ]
+ }
+ }
+ },
+ "Text",
+ "Jsonb",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd"
+}
diff --git a/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json b/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json
new file mode 100644
index 000000000..23906f801
--- /dev/null
+++ b/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json
@@ -0,0 +1,118 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2) ORDER BY created_at DESC",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "scope: ScopeName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "package: PackageName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "description",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "events: _",
+ "type_info": {
+ "Custom": {
+ "name": "_webhook_event_kind",
+ "kind": {
+ "Array": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 7,
+ "name": "payload_format: _",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 8,
+ "name": "is_active",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 9,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f"
+}
diff --git a/api/.sqlx/query-2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9.json b/api/.sqlx/query-2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9.json
new file mode 100644
index 000000000..7235ec9ab
--- /dev/null
+++ b/api/.sqlx/query-2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9.json
@@ -0,0 +1,42 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO webhook_events (scope, package, event, payload) VALUES ($1, $2, $3, $4) RETURNING id",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ },
+ "Jsonb"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9"
+}
diff --git a/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json b/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json
new file mode 100644
index 000000000..abb55ae57
--- /dev/null
+++ b/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE webhook_deliveries\n SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7, error = null\n WHERE id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ {
+ "Custom": {
+ "name": "webhook_delivery_status",
+ "kind": {
+ "Enum": [
+ "pending",
+ "success",
+ "failure",
+ "retrying"
+ ]
+ }
+ }
+ },
+ "Jsonb",
+ "Jsonb",
+ "Int4",
+ "Jsonb",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058"
+}
diff --git a/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json b/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json
new file mode 100644
index 000000000..282e43361
--- /dev/null
+++ b/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json
@@ -0,0 +1,155 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND endpoint_id = $3 ORDER BY webhook_deliveries.created_at DESC",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "webhook_delivery_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "webhook_delivery_endpoint_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "webhook_delivery_event_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 3,
+ "name": "webhook_delivery_status: WebhookDeliveryStatus",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_delivery_status",
+ "kind": {
+ "Enum": [
+ "pending",
+ "success",
+ "failure",
+ "retrying"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 4,
+ "name": "webhook_delivery_request_headers",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 5,
+ "name": "webhook_delivery_request_body",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 6,
+ "name": "webhook_delivery_response_http_code",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 7,
+ "name": "webhook_delivery_response_headers",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 8,
+ "name": "webhook_delivery_response_body",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 9,
+ "name": "webhook_delivery_error",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 10,
+ "name": "webhook_delivery_updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 11,
+ "name": "webhook_delivery_created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 12,
+ "name": "webhook_event_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 13,
+ "name": "webhook_event_scope: ScopeName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 14,
+ "name": "webhook_event_package: PackageName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 15,
+ "name": "webhook_event_event: WebhookEventKind",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 16,
+ "name": "webhook_event_payload: WebhookPayload",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 17,
+ "name": "webhook_event_created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a"
+}
diff --git a/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json
new file mode 100644
index 000000000..6948175ee
--- /dev/null
+++ b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json
@@ -0,0 +1,119 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "scope: ScopeName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "package: PackageName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "description",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "events: _",
+ "type_info": {
+ "Custom": {
+ "name": "_webhook_event_kind",
+ "kind": {
+ "Array": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 7,
+ "name": "payload_format: _",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 8,
+ "name": "is_active",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 9,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166"
+}
diff --git a/api/.sqlx/query-8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff.json b/api/.sqlx/query-8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff.json
new file mode 100644
index 000000000..48a88872c
--- /dev/null
+++ b/api/.sqlx/query-8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff.json
@@ -0,0 +1,42 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO webhook_deliveries (endpoint_id, event_id)\n SELECT webhook_endpoints.id, $1 FROM webhook_endpoints\n WHERE webhook_endpoints.scope = $2 AND (webhook_endpoints.package IS NULL OR webhook_endpoints.package = $3) AND $4 = ANY(webhook_endpoints.events) AND webhook_endpoints.is_active = TRUE\n RETURNING id",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff"
+}
diff --git a/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json b/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json
new file mode 100644
index 000000000..516392d58
--- /dev/null
+++ b/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json
@@ -0,0 +1,160 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE webhook_endpoints SET url = COALESCE($4, url), description = COALESCE($5, description), secret = COALESCE($6, secret), events = COALESCE($7, events), payload_format = COALESCE($8, payload_format), is_active = COALESCE($9, is_active) WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "scope: ScopeName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "package: PackageName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "description",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "events: _",
+ "type_info": {
+ "Custom": {
+ "name": "_webhook_event_kind",
+ "kind": {
+ "Array": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 7,
+ "name": "payload_format: _",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 8,
+ "name": "is_active",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 9,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Uuid",
+ "Text",
+ "Text",
+ "Varchar",
+ {
+ "Custom": {
+ "name": "_webhook_event_kind",
+ "kind": {
+ "Array": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ },
+ "Bool"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a"
+}
diff --git a/api/.sqlx/query-a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135.json b/api/.sqlx/query-a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135.json
new file mode 100644
index 000000000..e060d279e
--- /dev/null
+++ b/api/.sqlx/query-a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135.json
@@ -0,0 +1,80 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n webhook_endpoints.url as \"url\", webhook_events.event as \"event: _\", webhook_events.id as \"event_id\", webhook_endpoints.secret as \"secret\", webhook_endpoints.payload_format AS \"payload_format: _\", webhook_events.payload as \"payload: WebhookPayload\"\n FROM webhook_endpoints\n LEFT JOIN webhook_deliveries ON webhook_endpoints.id = webhook_deliveries.endpoint_id\n LEFT JOIN webhook_events ON webhook_events.id = webhook_deliveries.event_id\n WHERE webhook_deliveries.id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "event: _",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "event_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 3,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "payload_format: _",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 5,
+ "name": "payload: WebhookPayload",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ false,
+ false
+ ]
+ },
+ "hash": "a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135"
+}
diff --git a/api/.sqlx/query-bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec.json b/api/.sqlx/query-bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec.json
new file mode 100644
index 000000000..53059c972
--- /dev/null
+++ b/api/.sqlx/query-bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM webhook_endpoints WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Uuid"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec"
+}
diff --git a/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json b/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json
new file mode 100644
index 000000000..26ccb23fa
--- /dev/null
+++ b/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json
@@ -0,0 +1,156 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND webhook_deliveries.endpoint_id = $3 AND webhook_deliveries.id = $4",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "webhook_delivery_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "webhook_delivery_endpoint_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 2,
+ "name": "webhook_delivery_event_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 3,
+ "name": "webhook_delivery_status: WebhookDeliveryStatus",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_delivery_status",
+ "kind": {
+ "Enum": [
+ "pending",
+ "success",
+ "failure",
+ "retrying"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 4,
+ "name": "webhook_delivery_request_headers",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 5,
+ "name": "webhook_delivery_request_body",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 6,
+ "name": "webhook_delivery_response_http_code",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 7,
+ "name": "webhook_delivery_response_headers",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 8,
+ "name": "webhook_delivery_response_body",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 9,
+ "name": "webhook_delivery_error",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 10,
+ "name": "webhook_delivery_updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 11,
+ "name": "webhook_delivery_created_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 12,
+ "name": "webhook_event_id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 13,
+ "name": "webhook_event_scope: ScopeName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 14,
+ "name": "webhook_event_package: PackageName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 15,
+ "name": "webhook_event_event: WebhookEventKind",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 16,
+ "name": "webhook_event_payload: WebhookPayload",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 17,
+ "name": "webhook_event_created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Uuid",
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9"
+}
diff --git a/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json b/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json
new file mode 100644
index 000000000..649ef2852
--- /dev/null
+++ b/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json
@@ -0,0 +1,159 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format, is_active)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "scope: ScopeName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "package: PackageName",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "description",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "events: _",
+ "type_info": {
+ "Custom": {
+ "name": "_webhook_event_kind",
+ "kind": {
+ "Array": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 7,
+ "name": "payload_format: _",
+ "type_info": {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 8,
+ "name": "is_active",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 9,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "created_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Text",
+ "Text",
+ "Varchar",
+ {
+ "Custom": {
+ "name": "_webhook_event_kind",
+ "kind": {
+ "Array": {
+ "Custom": {
+ "name": "webhook_event_kind",
+ "kind": {
+ "Enum": [
+ "package_version_npm_tarball_ready",
+ "package_version_published",
+ "package_version_yanked",
+ "package_version_deleted",
+ "scope_package_created",
+ "scope_package_deleted",
+ "scope_package_archived",
+ "scope_member_added",
+ "scope_member_removed"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "Custom": {
+ "name": "webhook_payload_format",
+ "kind": {
+ "Enum": [
+ "json",
+ "discord",
+ "slack"
+ ]
+ }
+ }
+ },
+ "Bool"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf"
+}
diff --git a/api/Cargo.toml b/api/Cargo.toml
index c1e666bc7..5de66a500 100644
--- a/api/Cargo.toml
+++ b/api/Cargo.toml
@@ -34,6 +34,7 @@ once_cell = "1"
percent-encoding = "2"
rand = "0.8"
sha2 = "0.10.7"
+hmac = "0.12.1"
crc32fast = "1.3.2"
routerify = "3"
routerify-query = "3"
diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql
new file mode 100644
index 000000000..92e9d0e15
--- /dev/null
+++ b/api/migrations/20251221141049_webhook.sql
@@ -0,0 +1,70 @@
+CREATE TYPE webhook_event_kind AS ENUM (
+ 'package_version_npm_tarball_ready',
+ 'package_version_published',
+ 'package_version_yanked',
+ 'package_version_deleted',
+ 'scope_package_created',
+ 'scope_package_deleted',
+ 'scope_package_archived',
+ 'scope_member_added',
+ 'scope_member_removed'
+);
+
+CREATE TYPE webhook_payload_format AS ENUM (
+ 'json',
+ 'discord',
+ 'slack'
+);
+
+CREATE TABLE webhook_endpoints (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ scope TEXT NOT NULL references scopes (scope) ON DELETE CASCADE,
+ package TEXT,
+ url TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ secret VARCHAR(255),
+ events webhook_event_kind[] NOT NULL,
+ payload_format webhook_payload_format NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE
+);
+
+SELECT manage_updated_at('webhook_endpoints');
+
+CREATE TABLE webhook_events (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ scope TEXT NOT NULL references scopes (scope) ON DELETE CASCADE,
+ package TEXT,
+ event webhook_event_kind NOT NULL,
+ payload JSONB NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE
+);
+
+CREATE TYPE webhook_delivery_status AS ENUM (
+ 'pending', 'success', 'failure', 'retrying'
+);
+
+CREATE TABLE webhook_deliveries (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ endpoint_id UUID NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE,
+ event_id UUID NOT NULL REFERENCES webhook_events(id) ON DELETE CASCADE,
+ status webhook_delivery_status NOT NULL DEFAULT 'pending',
+
+ request_headers JSONB,
+ request_body JSONB,
+
+ response_http_code INT,
+ response_headers JSONB,
+ response_body TEXT,
+
+ error TEXT,
+
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+SELECT manage_updated_at('webhook_deliveries');
diff --git a/api/src/api/admin.rs b/api/src/api/admin.rs
index 1323eb71d..285aa203e 100644
--- a/api/src/api/admin.rs
+++ b/api/src/api/admin.rs
@@ -17,6 +17,7 @@ use crate::db::*;
use crate::iam::ReqIamExt;
use crate::ids::ScopeDescription;
use crate::publish::publish_task;
+use crate::tasks::WebhookDispatchQueue;
use crate::util;
use crate::util::ApiResult;
use crate::util::LicenseStore;
@@ -297,6 +298,8 @@ pub async fn requeue_publishing_tasks(req: Request
) -> ApiResult<()> {
}
let publish_queue = req.data::().unwrap().0.clone();
+ let webhook_dispatch_queue =
+ req.data::().unwrap().clone();
let orama_client = req.data::