diff --git a/docs/providers/documentation/snmp-provider.mdx b/docs/providers/documentation/snmp-provider.mdx
new file mode 100644
index 0000000000..5d56749aca
--- /dev/null
+++ b/docs/providers/documentation/snmp-provider.mdx
@@ -0,0 +1,147 @@
+---
+title: "SNMP"
+sidebarTitle: "SNMP Provider"
+description: "Ingest SNMP traps into Keep as alerts via a lightweight `snmptrapd` bridge."
+---
+import AutoGeneratedSnippet from '/snippets/providers/snmp-snippet-autogenerated.mdx';
+
+
+
+## How it works
+
+The SNMP provider is passive. It does not bind to UDP port 162 itself (which
+would require root and conflict across tenants). Instead, traps are received
+by the standard `snmptrapd` daemon on a host you already operate, and a small
+exec hook forwards each parsed trap to Keep's webhook endpoint as JSON.
+
+```
+network device ──(SNMP trap UDP:162)──> snmptrapd ──(HTTPS JSON)──> Keep webhook
+```
+
+## Connecting with the Provider
+
+1. Install `net-snmp` (ships `snmptrapd`) on a host that can receive traps
+ from your network devices. Linux: `sudo apt install snmp snmptrapd` or
+ `sudo yum install net-snmp`. macOS: `brew install net-snmp`.
+2. In Keep, add the SNMP provider. Copy the generated webhook URL and API key
+ shown after install.
+3. Configure `snmptrapd` to forward each trap to Keep (see below).
+
+## `snmptrapd` bridge
+
+Add this to `/etc/snmp/snmptrapd.conf` (create it if missing):
+
+```conf
+# Accept v2c traps with community "public" — adjust for your environment.
+authCommunity log,execute,net public
+
+# Forward every trap to the Keep webhook.
+traphandle default /usr/local/bin/keep-snmp-bridge
+```
+
+Create `/usr/local/bin/keep-snmp-bridge` and make it executable
+(`chmod +x /usr/local/bin/keep-snmp-bridge`):
+
+```bash
+#!/usr/bin/env bash
+# Minimal snmptrapd → Keep webhook bridge.
+# snmptrapd invokes this script with the raw trap on stdin.
+
+KEEP_WEBHOOK_URL="${KEEP_WEBHOOK_URL:?KEEP_WEBHOOK_URL is required}"
+KEEP_API_KEY="${KEEP_API_KEY:?KEEP_API_KEY is required}"
+
+read -r source_address
+read -r _source_hostname
+trap_oid=""
+declare -A varbinds
+while read -r line; do
+ # Each varbind line is: OIDVALUE
+ oid="${line%% *}"
+ val="${line#* }"
+ if [ -z "$trap_oid" ] && [ "$oid" = "1.3.6.1.6.3.1.1.4.1.0" ]; then
+ trap_oid="$val"
+ else
+ varbinds["$oid"]="$val"
+ fi
+done
+
+# Build JSON payload.
+vars_json="{"
+sep=""
+for k in "${!varbinds[@]}"; do
+ vars_json+="$sep\"$k\":\"${varbinds[$k]//\"/\\\"}\""
+ sep=","
+done
+vars_json+="}"
+
+payload=$(cat </dev/null
+```
+
+Export `KEEP_WEBHOOK_URL` and `KEEP_API_KEY` in the service environment for
+`snmptrapd`, then restart it:
+
+```bash
+sudo systemctl restart snmptrapd # or: sudo /usr/sbin/snmptrapd -Lsd
+```
+
+## Trap payload shape
+
+The bridge posts a JSON object with these fields; any can be omitted:
+
+| Field | Type | Description |
+|---|---|---|
+| `trap_oid` | string | The trap OID (e.g. `1.3.6.1.6.3.1.1.5.3` for `linkDown`). |
+| `trap_name` | string | Optional human name used as the alert name. |
+| `source_address` | string | IP/host of the device that emitted the trap. |
+| `variables` | object | Varbinds as `{ "oid": "value" }`. Exposed on the alert as `labels["var:"]`. |
+| `severity` | string | Optional override: `critical`, `high`, `warning`, `info`, `low`. |
+| `description` | string | Optional human-readable description. |
+| `community` | string | SNMP community string (v1/v2c). |
+| `version` | string | SNMP version: `1`, `2c` or `3`. |
+
+If `severity` is not provided, the provider uses the following built-in
+mapping for standard trap OIDs (RFC 1907 / RFC 3418):
+
+| OID | Name | Severity |
+|---|---|---|
+| `1.3.6.1.6.3.1.1.5.1` | `coldStart` | info |
+| `1.3.6.1.6.3.1.1.5.2` | `warmStart` | info |
+| `1.3.6.1.6.3.1.1.5.3` | `linkDown` | high |
+| `1.3.6.1.6.3.1.1.5.4` | `linkUp` | info |
+| `1.3.6.1.6.3.1.1.5.5` | `authenticationFailure` | warning |
+| `1.3.6.1.6.3.1.1.5.6` | `egpNeighborLoss` | warning |
+
+Anything else falls back to the `default_severity` you configured on the
+provider.
+
+## Testing
+
+Send a synthetic trap with `snmptrap` from the CLI:
+
+```bash
+snmptrap -v 2c -c public localhost '' 1.3.6.1.6.3.1.1.5.3 \
+ 1.3.6.1.2.1.2.2.1.1 i 2 \
+ 1.3.6.1.2.1.2.2.1.7 i 2
+```
+
+You should see a new alert in Keep within a second or two.
+
+## Useful links
+
+- [net-snmp documentation](http://www.net-snmp.org/docs/)
+- [`snmptrapd.conf` reference](http://net-snmp.sourceforge.net/docs/man/snmptrapd.conf.html)
+- [RFC 1907 — Standard SNMPv2 traps](https://www.rfc-editor.org/rfc/rfc1907)
diff --git a/keep-ui/public/icons/snmp-icon.png b/keep-ui/public/icons/snmp-icon.png
new file mode 100644
index 0000000000..d69ddbcfab
Binary files /dev/null and b/keep-ui/public/icons/snmp-icon.png differ
diff --git a/keep/providers/snmp_provider/__init__.py b/keep/providers/snmp_provider/__init__.py
new file mode 100644
index 0000000000..f55f0fc07e
--- /dev/null
+++ b/keep/providers/snmp_provider/__init__.py
@@ -0,0 +1,6 @@
+from keep.providers.snmp_provider.snmp_provider import (
+ SnmpProvider,
+ SnmpProviderAuthConfig,
+)
+
+__all__ = ["SnmpProvider", "SnmpProviderAuthConfig"]
diff --git a/keep/providers/snmp_provider/alerts_mock.py b/keep/providers/snmp_provider/alerts_mock.py
new file mode 100644
index 0000000000..a458e994c2
--- /dev/null
+++ b/keep/providers/snmp_provider/alerts_mock.py
@@ -0,0 +1,42 @@
+"""Sample SNMP trap payloads used by `SnmpProvider.simulate_alert`."""
+
+TRAPS = {
+ "linkDown": {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.3",
+ "trap_name": "linkDown",
+ "source_address": "192.0.2.10",
+ "community": "public",
+ "version": "2c",
+ "variables": {
+ "1.3.6.1.2.1.2.2.1.1": "2",
+ "1.3.6.1.2.1.2.2.1.7": "2",
+ },
+ "description": "Interface eth1 went down",
+ "severity": "high",
+ },
+ "linkUp": {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.4",
+ "trap_name": "linkUp",
+ "source_address": "192.0.2.10",
+ "community": "public",
+ "version": "2c",
+ "variables": {"1.3.6.1.2.1.2.2.1.1": "2"},
+ "description": "Interface eth1 came back up",
+ },
+ "coldStart": {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.1",
+ "trap_name": "coldStart",
+ "source_address": "192.0.2.20",
+ "community": "public",
+ "version": "2c",
+ "description": "Device rebooted and reinitialised",
+ },
+ "authenticationFailure": {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.5",
+ "trap_name": "authenticationFailure",
+ "source_address": "192.0.2.30",
+ "community": "public",
+ "version": "2c",
+ "description": "SNMP authentication failure from 10.0.0.5",
+ },
+}
diff --git a/keep/providers/snmp_provider/snmp_provider.py b/keep/providers/snmp_provider/snmp_provider.py
new file mode 100644
index 0000000000..f7dfb6885b
--- /dev/null
+++ b/keep/providers/snmp_provider/snmp_provider.py
@@ -0,0 +1,248 @@
+"""
+SnmpProvider ingests SNMP traps into Keep as alerts.
+
+SNMP traps arrive via a small `snmptrapd` exec hook that POSTs the parsed
+trap as JSON to Keep's generic webhook endpoint. See
+`docs/providers/documentation/snmp-provider.mdx` for the bridge setup.
+"""
+
+import dataclasses
+import datetime
+import hashlib
+
+import pydantic
+
+from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus
+from keep.contextmanager.contextmanager import ContextManager
+from keep.providers.base.base_provider import BaseProvider
+from keep.providers.models.provider_config import ProviderConfig
+
+
+@pydantic.dataclasses.dataclass
+class SnmpProviderAuthConfig:
+ """SNMP provider configuration.
+
+ The provider itself is passive (receives JSON-over-HTTPS posted by the
+ trap bridge), so no credentials are required. These fields tune how
+ incoming traps are interpreted.
+ """
+
+ default_severity: str = dataclasses.field(
+ default="info",
+ metadata={
+ "required": False,
+ "description": (
+ "Severity assigned to a trap when the payload does not specify one "
+ "and the trap OID is not in the built-in mapping."
+ ),
+ "type": "select",
+ "options": ["critical", "high", "warning", "info", "low"],
+ "sensitive": False,
+ },
+ )
+ community_string: str = dataclasses.field(
+ default="public",
+ metadata={
+ "required": False,
+ "description": (
+ "Expected SNMP community string. Documented for the trap "
+ "bridge; the provider itself does not enforce it."
+ ),
+ "sensitive": False,
+ },
+ )
+
+
+# Well-known SNMP trap OIDs from RFC 1907 / RFC 3418 with reasonable
+# default severities. Users can override any of these in the bridge.
+_STANDARD_TRAP_OIDS = {
+ "1.3.6.1.6.3.1.1.5.1": ("coldStart", AlertSeverity.INFO),
+ "1.3.6.1.6.3.1.1.5.2": ("warmStart", AlertSeverity.INFO),
+ "1.3.6.1.6.3.1.1.5.3": ("linkDown", AlertSeverity.HIGH),
+ "1.3.6.1.6.3.1.1.5.4": ("linkUp", AlertSeverity.INFO),
+ "1.3.6.1.6.3.1.1.5.5": ("authenticationFailure", AlertSeverity.WARNING),
+ "1.3.6.1.6.3.1.1.5.6": ("egpNeighborLoss", AlertSeverity.WARNING),
+}
+
+
+class SnmpProvider(BaseProvider):
+ """Ingest SNMP traps into Keep as alerts."""
+
+ PROVIDER_DISPLAY_NAME = "SNMP"
+ PROVIDER_CATEGORY = ["Monitoring"]
+ PROVIDER_TAGS = ["alert"]
+
+ FINGERPRINT_FIELDS = ["trap_oid", "source_address"]
+
+ webhook_description = (
+ "This provider ingests SNMP traps via a small `snmptrapd` exec hook "
+ "that POSTs each trap as JSON to the Keep webhook URL below. "
+ "See the SNMP provider docs for the bridge script and "
+ "`snmptrapd.conf` snippet."
+ )
+ webhook_template = (
+ "POST {keep_webhook_api_url}\n"
+ "Headers:\n"
+ " X-API-KEY: {api_key}\n"
+ " Content-Type: application/json\n"
+ "Body (example):\n"
+ "{{\n"
+ ' "trap_oid": "1.3.6.1.6.3.1.1.5.3",\n'
+ ' "trap_name": "linkDown",\n'
+ ' "source_address": "192.0.2.10",\n'
+ ' "variables": {{"1.3.6.1.2.1.2.2.1.1": "2"}},\n'
+ ' "community": "public",\n'
+ ' "version": "2c"\n'
+ "}}"
+ )
+
+ SEVERITIES_MAP = {
+ "critical": AlertSeverity.CRITICAL,
+ "high": AlertSeverity.HIGH,
+ "error": AlertSeverity.HIGH,
+ "warning": AlertSeverity.WARNING,
+ "medium": AlertSeverity.WARNING,
+ "info": AlertSeverity.INFO,
+ "low": AlertSeverity.LOW,
+ }
+
+ def __init__(
+ self,
+ context_manager: ContextManager,
+ provider_id: str,
+ config: ProviderConfig,
+ ):
+ super().__init__(context_manager, provider_id, config)
+
+ def validate_config(self):
+ """Validate the provider configuration."""
+ self.authentication_config = SnmpProviderAuthConfig(
+ **(self.config.authentication or {})
+ )
+
+ def dispose(self):
+ """Nothing to clean up; the provider holds no connections."""
+ return
+
+ @staticmethod
+ def _resolve_name(event: dict) -> str:
+ """Pick a human-friendly alert name for a trap event."""
+ if event.get("trap_name"):
+ return str(event["trap_name"])
+ trap_oid = event.get("trap_oid") or ""
+ if trap_oid in _STANDARD_TRAP_OIDS:
+ return _STANDARD_TRAP_OIDS[trap_oid][0]
+ if trap_oid:
+ # Use the last numeric segment as a last resort name.
+ return f"snmp_trap_{trap_oid.rsplit('.', 1)[-1]}"
+ return "snmp_trap"
+
+ @staticmethod
+ def _resolve_severity(event: dict, default_severity: str) -> AlertSeverity:
+ """Resolve severity: explicit field > well-known OID map > default."""
+ raw = event.get("severity")
+ if isinstance(raw, str) and raw.strip():
+ mapped = SnmpProvider.SEVERITIES_MAP.get(raw.strip().lower())
+ if mapped is not None:
+ return mapped
+ trap_oid = event.get("trap_oid") or ""
+ if trap_oid in _STANDARD_TRAP_OIDS:
+ return _STANDARD_TRAP_OIDS[trap_oid][1]
+ return SnmpProvider.SEVERITIES_MAP.get(
+ (default_severity or "info").lower(), AlertSeverity.INFO
+ )
+
+ @staticmethod
+ def _build_fingerprint(event: dict) -> str:
+ """Deterministic fingerprint from (trap_oid, source_address).
+
+ Falls back to a hash of the full event if neither is present.
+ """
+ trap_oid = event.get("trap_oid") or ""
+ source = event.get("source_address") or ""
+ if trap_oid or source:
+ return hashlib.sha256(f"{trap_oid}|{source}".encode()).hexdigest()
+ raw = repr(sorted(event.items()))
+ return hashlib.sha256(raw.encode()).hexdigest()
+
+ @staticmethod
+ def _format_alert(
+ event: dict,
+ provider_instance: "BaseProvider" = None,
+ ) -> AlertDto:
+ default_severity = "info"
+ if provider_instance is not None and getattr(
+ provider_instance, "authentication_config", None
+ ):
+ default_severity = (
+ provider_instance.authentication_config.default_severity or "info"
+ )
+
+ name = SnmpProvider._resolve_name(event)
+ severity = SnmpProvider._resolve_severity(event, default_severity)
+ description = event.get("description") or (
+ f"SNMP trap {event.get('trap_oid', '')} "
+ f"from {event.get('source_address', 'unknown host')}".strip()
+ )
+ last_received = (
+ event.get("last_received")
+ or datetime.datetime.now(tz=datetime.timezone.utc).isoformat()
+ )
+
+ labels = {
+ "trap_oid": event.get("trap_oid", ""),
+ "trap_name": event.get("trap_name", ""),
+ "source_address": event.get("source_address", ""),
+ "community": event.get("community", ""),
+ "version": event.get("version", ""),
+ }
+ # Expose varbinds as labels too, prefixed so they can't clobber
+ # existing keys.
+ variables = event.get("variables") or {}
+ if isinstance(variables, dict):
+ for oid, value in variables.items():
+ labels[f"var:{oid}"] = str(value)
+
+ return AlertDto(
+ id=event.get("id"),
+ name=name,
+ description=description,
+ severity=severity,
+ status=AlertStatus.FIRING,
+ lastReceived=last_received,
+ source=["snmp"],
+ host=event.get("source_address"),
+ fingerprint=event.get("fingerprint")
+ or SnmpProvider._build_fingerprint(event),
+ labels={k: v for k, v in labels.items() if v not in (None, "")},
+ pushed=True,
+ )
+
+ @classmethod
+ def simulate_alert(cls, **kwargs) -> dict:
+ """Return a representative SNMP trap payload for UI testing."""
+ import random
+
+ from keep.providers.snmp_provider.alerts_mock import TRAPS
+
+ trap_key = kwargs.get("alert_type") or random.choice(list(TRAPS.keys()))
+ payload = dict(TRAPS[trap_key])
+ payload.setdefault(
+ "last_received", datetime.datetime.now(tz=datetime.timezone.utc).isoformat()
+ )
+ return payload
+
+
+if __name__ == "__main__":
+ import logging
+
+ logging.basicConfig(level=logging.DEBUG, handlers=[logging.StreamHandler()])
+ context_manager = ContextManager(tenant_id="singletenant", workflow_id="test")
+ provider = SnmpProvider(
+ context_manager=context_manager,
+ provider_id="snmp-test",
+ config=ProviderConfig(authentication={"default_severity": "info"}),
+ )
+ sample = SnmpProvider.simulate_alert()
+ alert = SnmpProvider._format_alert(sample, provider_instance=provider)
+ print(alert.json(indent=2))
diff --git a/tests/test_snmp_provider.py b/tests/test_snmp_provider.py
new file mode 100644
index 0000000000..0e055eea28
--- /dev/null
+++ b/tests/test_snmp_provider.py
@@ -0,0 +1,109 @@
+"""Tests for the SNMP provider's trap → AlertDto translation."""
+
+import pytest
+
+from keep.api.models.alert import AlertSeverity, AlertStatus
+from keep.contextmanager.contextmanager import ContextManager
+from keep.providers.models.provider_config import ProviderConfig
+from keep.providers.snmp_provider.snmp_provider import SnmpProvider
+
+
+class TestSnmpProvider:
+ @pytest.fixture
+ def context_manager(self):
+ return ContextManager(tenant_id="test_tenant", workflow_id="test_workflow")
+
+ @pytest.fixture
+ def provider(self, context_manager):
+ return SnmpProvider(
+ context_manager=context_manager,
+ provider_id="snmp-test",
+ config=ProviderConfig(authentication={"default_severity": "info"}),
+ )
+
+ def test_validate_config_defaults_when_empty(self, context_manager):
+ provider = SnmpProvider(
+ context_manager=context_manager,
+ provider_id="snmp-no-auth",
+ config=ProviderConfig(authentication={}),
+ )
+ assert provider.authentication_config.default_severity == "info"
+ assert provider.authentication_config.community_string == "public"
+
+ def test_standard_trap_oid_maps_to_known_name_and_severity(self, provider):
+ event = {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.3",
+ "source_address": "192.0.2.10",
+ }
+ alert = SnmpProvider._format_alert(event, provider_instance=provider)
+ assert alert.name == "linkDown"
+ assert alert.severity == AlertSeverity.HIGH.value
+ assert alert.status == AlertStatus.FIRING.value
+ assert alert.source == ["snmp"]
+ assert alert.labels["trap_oid"] == "1.3.6.1.6.3.1.1.5.3"
+
+ def test_explicit_severity_overrides_trap_oid_default(self, provider):
+ event = {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.3",
+ "source_address": "192.0.2.10",
+ "severity": "critical",
+ }
+ alert = SnmpProvider._format_alert(event, provider_instance=provider)
+ assert alert.severity == AlertSeverity.CRITICAL.value
+
+ def test_unknown_trap_falls_back_to_default_severity(self, context_manager):
+ provider = SnmpProvider(
+ context_manager=context_manager,
+ provider_id="snmp-warn-default",
+ config=ProviderConfig(authentication={"default_severity": "warning"}),
+ )
+ event = {
+ "trap_oid": "1.3.6.1.4.1.99999.1",
+ "source_address": "192.0.2.50",
+ "trap_name": "vendorSpecificTrap",
+ }
+ alert = SnmpProvider._format_alert(event, provider_instance=provider)
+ assert alert.name == "vendorSpecificTrap"
+ assert alert.severity == AlertSeverity.WARNING.value
+
+ def test_fingerprint_is_stable_for_same_oid_and_source(self, provider):
+ event_a = {"trap_oid": "1.2.3", "source_address": "192.0.2.1"}
+ event_b = {
+ "trap_oid": "1.2.3",
+ "source_address": "192.0.2.1",
+ "variables": {"1.2.3.4": "different varbind"},
+ }
+ alert_a = SnmpProvider._format_alert(event_a, provider_instance=provider)
+ alert_b = SnmpProvider._format_alert(event_b, provider_instance=provider)
+ assert alert_a.fingerprint == alert_b.fingerprint
+
+ def test_fingerprint_differs_for_different_source(self, provider):
+ alert_a = SnmpProvider._format_alert(
+ {"trap_oid": "1.2.3", "source_address": "192.0.2.1"},
+ provider_instance=provider,
+ )
+ alert_b = SnmpProvider._format_alert(
+ {"trap_oid": "1.2.3", "source_address": "192.0.2.2"},
+ provider_instance=provider,
+ )
+ assert alert_a.fingerprint != alert_b.fingerprint
+
+ def test_variables_are_exposed_as_prefixed_labels(self, provider):
+ event = {
+ "trap_oid": "1.3.6.1.6.3.1.1.5.3",
+ "source_address": "192.0.2.10",
+ "variables": {"1.3.6.1.2.1.2.2.1.1": "2"},
+ }
+ alert = SnmpProvider._format_alert(event, provider_instance=provider)
+ assert alert.labels.get("var:1.3.6.1.2.1.2.2.1.1") == "2"
+
+ def test_simulate_alert_returns_well_formed_payload(self):
+ payload = SnmpProvider.simulate_alert(alert_type="linkDown")
+ assert payload["trap_oid"] == "1.3.6.1.6.3.1.1.5.3"
+ assert payload["trap_name"] == "linkDown"
+
+ def test_provider_type_is_snmp(self, provider):
+ assert provider.provider_type == "snmp"
+
+ def test_dispose_is_noop(self, provider):
+ assert provider.dispose() is None