> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endorlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Set up integrations using webhooks

> Learn how to create webhooks and enable custom integrations with Endor Labs application

export const YamlTable = ({children, data: propData, content}) => {
  const KV_RE = /^([A-Za-z][A-Za-z0-9_()/#\s-]+?):\s*(.+)$/;
  const INLINE_MD_RE = /(\[([^\]]+)\]\(([^)]+)\))|(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)/g;
  const YES_RE = /^-yes-$/i;
  const NO_RE = /^-no-$/i;
  const LIMITED_RE = /^-(limited|partial)-$/i;
  const NA_RE = /^-(na|none)-$/i;
  const NA2_RE = /^-na2-$/i;
  const SIMPLE_TAG_RE = /(<br\s*\/?>)|(<p\s*\/?>)|(-note-)|(-warning-)/gi;
  const renderSuccessIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true">
      <path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" fill="currentColor" />
    </svg>;
  const renderFailureIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true">
      <path fillRule="evenodd" clipRule="evenodd" d="M11.9902 1.98633C12.5208 1.9864 13.0426 2.12743 13.501 2.39453C13.9581 2.66107 14.3372 3.04388 14.5986 3.50391L22.5967 17.5L22.6895 17.6738C22.8921 18.0851 22.9979 18.5387 22.9981 18.999C22.9981 19.5253 22.8605 20.0431 22.5977 20.499C22.3348 20.9549 21.9555 21.3332 21.5 21.5967C21.0445 21.8601 20.5272 21.9994 20.001 22H4.00001C3.47453 22.0031 2.95699 21.868 2.50001 21.6084C2.04032 21.3471 1.65712 20.9684 1.39063 20.5117C1.12431 20.055 0.983643 19.5355 0.982429 19.0068C0.981281 18.4793 1.11967 17.9611 1.38282 17.5039L9.38184 3.50391C9.64344 3.04359 10.023 2.66111 10.4805 2.39453C10.9388 2.12755 11.4598 1.98636 11.9902 1.98633ZM12 16.9004C11.3925 16.9004 10.9004 17.3925 10.9004 18C10.9004 18.6075 11.3925 19.0996 12 19.0996H12.0098C12.6173 19.0996 13.1104 18.6075 13.1104 18C13.1104 17.3925 12.6173 16.9004 12.0098 16.9004H12ZM12 5.90039C11.3925 5.9004 10.9004 6.39249 10.9004 7V13C10.9004 13.6075 11.3925 14.0996 12 14.0996C12.6075 14.0996 13.0996 13.6075 13.0996 13V7C13.0996 6.39249 12.6075 5.90039 12 5.90039Z" fill="currentColor" />
    </svg>;
  const renderPartialSuccessIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true">
      <path d="M12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1ZM12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3ZM12 5C13.8565 5 15.6374 5.73705 16.9502 7.0498C18.263 8.36256 19 10.1435 19 12C19 13.8565 18.263 15.6374 16.9502 16.9502C15.6374 18.263 13.8565 19 12 19C11.4477 19 11 18.5523 11 18V6C11 5.73478 11.1054 5.4805 11.293 5.29297C11.4805 5.10543 11.7348 5 12 5Z" fill="currentColor" />
    </svg>;
  const renderPendingIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
      <path d="M10.1 2.182a10 10 0 0 1 3.8 0" />
      <path d="M13.9 21.818a10 10 0 0 1-3.8 0" />
      <path d="M17.609 3.721a10 10 0 0 1 2.69 2.7" />
      <path d="M2.182 13.9a10 10 0 0 1 0-3.8" />
      <path d="M20.279 17.609a10 10 0 0 1-2.7 2.69" />
      <path d="M21.818 10.1a10 10 0 0 1 0 3.8" />
      <path d="M3.721 6.391a10 10 0 0 1 2.7-2.69" />
      <path d="M6.391 20.279a10 10 0 0 1-2.69-2.7" />
    </svg>;
  const renderRunningIcon = () => <svg className="yt-status-running-svg" viewBox="22 22 44 44" width="100%" height="100%" aria-hidden="true">
      <circle className="yt-status-running-circle" cx="44" cy="44" r="20.2" fill="none" stroke="currentColor" strokeWidth="3.6" />
    </svg>;
  const renderSkippedIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="10" />
      <path d="M8 12h8" />
    </svg>;
  const STATUS_ICON_DEFS = [{
    re: /^-status-success-$/i,
    colorClass: 'yt-status-color-success',
    label: 'Success',
    render: renderSuccessIcon
  }, {
    re: /^-status-failure-$/i,
    colorClass: 'yt-status-color-failure',
    label: 'Failure',
    render: renderFailureIcon
  }, {
    re: /^-status-partial-success-$/i,
    colorClass: 'yt-status-color-partial',
    label: 'Partial success',
    render: renderPartialSuccessIcon
  }, {
    re: /^-status-pending-$/i,
    colorClass: 'yt-status-color-neutral',
    label: 'Pending',
    render: renderPendingIcon
  }, {
    re: /^-status-running-$/i,
    colorClass: 'yt-status-color-neutral',
    label: 'Running',
    render: renderRunningIcon
  }, {
    re: /^-status-skipped-$/i,
    colorClass: 'yt-status-color-neutral',
    label: 'Skipped',
    render: renderSkippedIcon
  }];
  const renderStatusBadge = def => <span className={['yt-status-icon', def.colorClass].join(' ')} role="img" aria-label={def.label} title={def.label}>
      {def.render()}
    </span>;
  const tryParseKV = trimmed => {
    const m = KV_RE.exec(trimmed);
    return m ? {
      key: m[1],
      value: m[2].trim()
    } : null;
  };
  const registerKey = (key, seenKeys, orderedKeys) => {
    if (!seenKeys.has(key)) {
      orderedKeys.push(key);
      seenKeys.add(key);
    }
  };
  const flushEntry = (currentEntry, entries) => {
    if (Object.keys(currentEntry).length > 0) entries.push(currentEntry);
  };
  const parseDashPrefixed = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed.startsWith('- ')) {
        if (inEntry) entries.push(currentEntry);
        currentEntry = {};
        inEntry = true;
        const kv = tryParseKV(trimmed.substring(2).trim());
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      } else if (inEntry && trimmed !== '') {
        const kv = tryParseKV(trimmed);
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      }
    }
    flushEntry(currentEntry, entries);
  };
  const parseBlankSeparated = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed === '') {
        if (inEntry) {
          flushEntry(currentEntry, entries);
          currentEntry = {};
          inEntry = false;
        }
        continue;
      }
      const kv = tryParseKV(trimmed);
      if (!kv) continue;
      const isNewEntry = !line.startsWith(' ') && !line.startsWith('\t');
      if (isNewEntry && inEntry && Object.keys(currentEntry).length > 0) {
        entries.push(currentEntry);
        currentEntry = {};
      }
      registerKey(kv.key, seenKeys, orderedKeys);
      currentEntry[kv.key] = kv.value;
      inEntry = true;
    }
    flushEntry(currentEntry, entries);
  };
  const normalizeEntries = (entries, orderedKeys) => entries.map(entry => {
    const filled = {};
    for (const key of orderedKeys) filled[key] = entry[key] || '';
    return filled;
  });
  const parseYamlTableContent = contentStr => {
    if (!contentStr) return [];
    const entries = [];
    const orderedKeys = [];
    const seenKeys = new Set();
    const lines = contentStr.split('\n');
    if (lines.some(line => line.trim().startsWith('- '))) {
      parseDashPrefixed(lines, entries, orderedKeys, seenKeys);
    } else {
      parseBlankSeparated(lines, entries, orderedKeys, seenKeys);
    }
    return normalizeEntries(entries, orderedKeys);
  };
  const processText = text => {
    if (!text) return text;
    const parts = [];
    let keyIndex = 0;
    let lastIndex = 0;
    let match;
    while ((match = INLINE_MD_RE.exec(text)) !== null) {
      if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
      if (match[1]) {
        parts.push(<a key={keyIndex++} href={match[3]}>{match[2]}</a>);
      } else if (match[4]) {
        parts.push(<code key={keyIndex++}>{match[5]}</code>);
      } else if (match[6]) {
        parts.push(<strong key={keyIndex++}>{match[7]}</strong>);
      } else if (match[8]) {
        parts.push(<em key={keyIndex++}>{match[9]}</em>);
      }
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) parts.push(text.slice(lastIndex));
    if (parts.length === 0) return text;
    const keyRef = {
      current: keyIndex
    };
    return expandHtmlTags(parts, keyRef);
  };
  const processBadges = text => {
    if (!text || typeof text !== 'string') return text;
    if (YES_RE.test(text)) return <span className="yt-badge-yes" role="img" aria-label="Supported" title="Supported">✓</span>;
    if (NO_RE.test(text)) return <span className="yt-badge-no" role="img" aria-label="Not supported" title="Not supported">✗</span>;
    if (LIMITED_RE.test(text)) return <span className="yt-badge-limited" role="img" aria-label="Partially supported" title="Partially supported">◐</span>;
    if (NA_RE.test(text) || NA2_RE.test(text)) return <span className="yt-sr-only" title="Not applicable">Not applicable</span>;
    const statusBadge = STATUS_ICON_DEFS.find(def => def.re.test(text));
    if (statusBadge) return renderStatusBadge(statusBadge);
    return processText(text);
  };
  const cellClassName = text => {
    if (!text || typeof text !== 'string') return undefined;
    if (NA_RE.test(text)) return 'yt-cell-na';
    if (NA2_RE.test(text)) return 'yt-cell-na2';
    return undefined;
  };
  const expandSimpleTags = (str, keyRef) => {
    const result = [];
    let last = 0;
    SIMPLE_TAG_RE.lastIndex = 0;
    let m;
    while ((m = SIMPLE_TAG_RE.exec(str)) !== null) {
      if (m.index > last) result.push(str.slice(last, m.index));
      if (m[1]) {
        result.push(<br key={keyRef.current++} />);
      } else if (m[2]) {
        result.push(<br key={keyRef.current++} />, <br key={keyRef.current++} />);
      } else if (m[3]) {
        result.push(<span key={keyRef.current++} className="yt-badge-note" style={{
          fontWeight: 600
        }}>Note: </span>);
      } else if (m[4]) {
        result.push(<span key={keyRef.current++} className="yt-badge-warning" style={{
          fontWeight: 600
        }}>Warning: </span>);
      }
      last = m.index + m[0].length;
    }
    if (last < str.length) result.push(str.slice(last));
    return result;
  };
  const expandHtmlTags = (chunks, keyRef) => {
    const out = [];
    for (const chunk of chunks) {
      if (typeof chunk === 'string') {
        out.push(...expandSimpleTags(chunk, keyRef));
      } else {
        out.push(chunk);
      }
    }
    return out;
  };
  const extractText = node => {
    if (node === null || node === undefined) return '';
    if (typeof node === 'string') return node;
    if (typeof node === 'number') return String(node);
    if (typeof node === 'boolean') return '';
    if (Array.isArray(node)) return node.map(extractText).join('');
    if (node && typeof node === 'object' && node.type) {
      const props = node.props || ({});
      if (typeof props.children === 'string') return props.children;
      if (props.children) return extractText(props.children);
      return '';
    }
    return String(node || '');
  };
  const [mounted, setMounted] = useState(false);
  const scrollWrapRef = useRef(null);
  const [isScrollable, setIsScrollable] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const data = useMemo(() => {
    if (propData) return propData;
    if (content && typeof content === 'string') return parseYamlTableContent(content);
    if (!children) return [];
    if (typeof children === 'string') return parseYamlTableContent(children);
    const childrenArray = Array.isArray(children) ? children : [children];
    return parseYamlTableContent(childrenArray.map(extractText).join('').trim());
  }, [children, propData, content]);
  const columns = useMemo(() => {
    if (!data || data.length === 0) return [];
    const firstRow = data[0];
    if (!firstRow || typeof firstRow !== 'object') return [];
    return Object.keys(firstRow);
  }, [data]);
  useEffect(() => {
    const wrap = scrollWrapRef.current;
    if (!wrap) return undefined;
    const updateScrollable = () => {
      setIsScrollable(wrap.scrollWidth > wrap.clientWidth);
    };
    updateScrollable();
    const observer = new ResizeObserver(updateScrollable);
    observer.observe(wrap);
    return () => observer.disconnect();
  }, [mounted, data]);
  if (!mounted) return null;
  if (!data || data.length === 0) return null;
  const rowKey = row => columns.map(c => row[c] || '').join('|');
  return <div ref={scrollWrapRef} style={{
    overflowX: 'auto',
    margin: '1.75rem 0'
  }} role={isScrollable ? 'region' : undefined} aria-label={isScrollable ? 'Scrollable table' : undefined} tabIndex={isScrollable ? 0 : undefined}>
      <table style={{
    display: 'table',
    width: '100%',
    minWidth: '100%',
    margin: 0
  }}>
        <thead>
          <tr>
            {columns.map(col => <th key={col}>{col.replaceAll('_', ' ')}</th>)}
          </tr>
        </thead>
        <tbody>
          {data.map(row => <tr key={rowKey(row)}>
              {columns.map(col => <td key={col} className={cellClassName(row[col])}>{processBadges(row[col])}</td>)}
            </tr>)}
        </tbody>
      </table>
    </div>;
};

export const CodeFile = ({src, lang, expandable = true, maxLines = 15}) => {
  const [content, setContent] = useState(null);
  const [wrapperLang, setWrapperLang] = useState('text');
  const [error, setError] = useState('');
  const [copied, setCopied] = useState(false);
  const [isDark, setIsDark] = useState(false);
  const [expanded, setExpanded] = useState(false);
  useEffect(() => {
    const root = document.documentElement;
    const checkTheme = () => setIsDark(root.dataset.theme === 'dark' || root.classList.contains('dark'));
    checkTheme();
    const observer = new MutationObserver(checkTheme);
    observer.observe(root, {
      attributes: true,
      attributeFilter: ['data-theme', 'class']
    });
    return () => {
      observer.disconnect();
    };
  }, []);
  useEffect(() => {
    if (!src) {
      setError('No src provided.');
      return undefined;
    }
    const ac = new AbortController();
    fetch(src, {
      signal: ac.signal
    }).then(r => {
      if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
      return r.json();
    }).then(data => {
      setContent(data.content || '');
      if (data.lang) setWrapperLang(data.lang);
    }).catch(err => {
      if (err.name !== 'AbortError') {
        setError('Unable to load ' + src + ': ' + err.message);
      }
    });
    return () => {
      ac.abort();
    };
  }, [src]);
  const language = lang || wrapperLang || 'text';
  const handleCopy = () => {
    if (!content) return;
    navigator.clipboard.writeText(content).then(() => {
      setCopied(true);
      setTimeout(() => {
        setCopied(false);
      }, 2000);
    }).catch(() => {});
  };
  const toggleExpand = () => {
    setExpanded(v => !v);
  };
  const statusBoxStyle = variant => {
    const base = {
      padding: '12px 16px',
      borderRadius: '6px',
      fontSize: '14px'
    };
    if (variant === 'error') {
      return {
        ...base,
        background: isDark ? '#451a1a' : '#fef2f2',
        color: isDark ? '#fca5a5' : '#dc2626',
        border: '1px solid ' + (isDark ? '#7f1d1d' : '#fecaca')
      };
    }
    return {
      ...base,
      background: isDark ? '#1f2937' : '#f5f5f5',
      color: isDark ? '#9ca3af' : '#6b7280'
    };
  };
  const shellStyle = {
    position: 'relative',
    borderRadius: '8px',
    border: '1px solid ' + (isDark ? 'rgba(255,255,255,0.1)' : '#d1d5db'),
    overflow: 'hidden'
  };
  const copyBtnIdleBg = isDark ? 'rgba(255,255,255,0.15)' : '#f6f8fa';
  const copyBtnHoverBg = isDark ? 'rgba(255,255,255,0.25)' : '#e5e7eb';
  const copyBtnStyle = {
    position: 'absolute',
    top: '8px',
    right: '8px',
    padding: '4px 8px',
    fontSize: '12px',
    lineHeight: '1',
    border: '1px solid ' + (isDark ? 'rgba(255,255,255,0.3)' : '#d1d5db'),
    borderRadius: '4px',
    background: copyBtnIdleBg,
    color: isDark ? '#fff' : '#1f2937',
    cursor: 'pointer',
    zIndex: 2,
    transition: 'background 0.15s, color 0.15s'
  };
  const renderErrorBox = () => <div style={statusBoxStyle('error')}>{error}</div>;
  const renderLoadingBox = () => <div style={statusBoxStyle('loading')}>Loading…</div>;
  const countLogicalLines = text => {
    const normalized = text.replace(/\n+$/, '');
    if (normalized === '') {
      return 0;
    }
    return normalized.split('\n').length;
  };
  const lineCount = content ? countLogicalLines(content) : 0;
  const showTruncate = expandable && lineCount >= maxLines && !expanded;
  const showExpandToggle = expandable && lineCount >= maxLines;
  const LINE_HEIGHT_PX = 25;
  const previewMaxHeightPx = maxLines * LINE_HEIGHT_PX;
  const preStyle = {
    margin: 0,
    padding: '16px',
    overflow: 'auto',
    maxHeight: showTruncate ? previewMaxHeightPx + 'px' : 'none',
    overflowY: showTruncate ? 'hidden' : 'auto'
  };
  const toggleLinkStyle = {
    display: 'block',
    width: '100%',
    boxSizing: 'border-box',
    background: 'none',
    border: 'none',
    padding: '8px 16px 12px 16px',
    cursor: 'pointer',
    color: isDark ? '#26D07C' : '#047857',
    fontSize: '13px',
    fontFamily: 'inherit',
    textAlign: 'left'
  };
  const renderLoaded = () => <div style={shellStyle}>
      <button type="button" onClick={handleCopy} aria-label="Copy code" style={copyBtnStyle} onMouseEnter={e => {
    e.currentTarget.style.background = copyBtnHoverBg;
  }} onMouseLeave={e => {
    e.currentTarget.style.background = copyBtnIdleBg;
  }}>
        {copied ? 'Copied!' : 'Copy'}
      </button>
      <pre style={preStyle} tabIndex={0} role="region" aria-label={'Code file: ' + (typeof src === 'string' && src.split('/').filter(Boolean).pop() || 'code sample')}>
        <code className={'language-' + language} style={{
    fontSize: '14px'
  }}>
          {content}
        </code>
      </pre>
      {showExpandToggle ? <button type="button" style={toggleLinkStyle} onClick={toggleExpand} aria-expanded={expanded ? 'true' : 'false'} aria-label={expanded ? 'Show less code' : 'See all ' + lineCount + ' lines of code'}>
          {expanded ? '... Show less' : '... See all ' + lineCount + ' lines'}
        </button> : null}
    </div>;
  if (error) {
    return renderErrorBox();
  }
  if (content === null) {
    return renderLoadingBox();
  }
  return renderLoaded();
};

Webhooks enable real-time communication between different systems or applications over the internet. They allow one application to send data to another application as soon as a specific event or a trigger occurs.

Use webhooks to integrate Endor Labs with applications such as Slack, Microsoft Teams or more, and instantly get notified about projects when scans violate your configured policies.

When events occur, Endor Labs sends HTTPS POST requests to URLs of your configured events, with all the information you need.

## Configure a webhook integration

Set up a custom integration with Endor Labs webhooks.

1. Select **User menu** > **Integrations** from the left sidebar.

2. Under **Notifications**, click **Add** on the **Webhook** card. If you already have a webhook integration, click **Manage** instead.

3. Click **Add Notification Integration**.

   **Send Notifications to Webhook** appears.

4. Enter a **Name** and **Description** for this integration.

5. In **URL endpoint for webhook**, enter the URL of your webhook receiver.

   <Note>
     If your webhook receiver accepts traffic only from allowlisted IP addresses, see [Ingress rules for restricted environments](/best-practices/troubleshooting/firewall-rules#ingress-rules-for-restricted-environments) to allow notifications from Endor Labs.
   </Note>

6. Choose an **Auth method** of **None**, **Basic**, or **API Key**, then enter its credentials.

   Basic authentication uses **Username** and **Password**. API key authentication uses **API Key**. Make sure the credentials grant permission to post to your receiver.

7. If you chose **Basic** or **API Key**, HMAC signing is on by default. Enter an **HMAC Shared Key**, or select **Disable HMAC Integration Check** to turn it off. When HMAC is on, Endor Labs signs the request body and sends the signature in the `X-Endor-HMAC-Signature` header so your receiver can verify that the request is authentic. HMAC is not available for **None** auth.

8. Optional: Under **Custom Headers**, click **Add Header**, then enter a **Header name** and **Header value**.

   You can add up to 20 headers.

   <Note>
     Use custom headers to pass vendor-specific API keys required by your webhook receiver. Endor Labs includes custom headers in every webhook request, regardless of the authentication method used.
   </Note>

9. Optional: Select **Propagate this notification target to all child namespaces** to make this integration available in child namespaces.

10. Click **Add Notification Integration** to save the integration.

### Associate an action policy with the webhook

You can create action policies to trigger webhook notifications when a scan matches policy conditions. For example, send a webhook notification when there is a critical or high vulnerability.

To send a webhook when a scan produces matching findings, create an action policy with a **Send Notification** action that targets your webhook integration. For the full procedure, see [Create an action policy](/platform-administration/policies/action-policies).

The webhook-specific choices are:

* Under **Choose an Action**, select **Send Notification**.
* From **Select notification targets**, choose the webhook integration you created.

From **Select aggregation type**, choose how findings are grouped into notifications.

* **None (Notify for each Finding)** sends a separate notification for each finding.
* **Project** sends a single notification for all findings in a project.
* **Dependency** sends a notification for every dependency.
* **Dependency per Package Version** sends a notification for every unique combination of dependency and package version.

## Endor Labs webhook payload

Endor Labs provides the following webhook payload, that you can customize for your needs.

<YamlTable>
  {`
    - Name: \`data.message\`
    Description: Brief message about the number of findings discovered for a project
    - Name: \`data.projectURL\`
    Description: Link to the scanned project in the Endor Labs application
    - Name: \`data.policy.name\`
    Description: Name of the violated policy that triggered the notification
    - Name: \`data.policy.url\`
    Description: Link to the violated policy in the Endor Labs application
    - Name: \`data.findings\`
    Description: Complete list of findings
    - Name: \`data.findings[].uuid\`
    Description: Unique identifier of the finding
    - Name: \`data.findings[].description\`
    Description: Brief description of the finding
    - Name: \`data.findings[].severity\`
    Description: Severity of the finding
    - Name: \`data.findings[].dependency [CONDITIONAL]\`
    Description: Name of dependency that caused the policy violation. This field is only present for findings that have a dependency associated. For example, vulnerability findings.
    - Name: \`data.findings[].package [CONDITIONAL]\`
    Description: The version of the package in the project that imported the dependency causing the policy violation. This field is only present for findings that have a package version associated with them. For example, vulnerability findings.
    - Name: \`data.findings[].repositoryVersion [CONDITIONAL]\`
    Description: Repository version of the project that triggered the policy violation. This field is only present for findings that have a repository version associated with them. For example, secrets findings.
    - Name: \`data.findings[].findingURL\`
    Description: Link to the finding in the Endor Labs application
    `}
</YamlTable>

You can view all possible payload information in [GetFindings REST API endpoint](/api-reference/findingservice/listfindings). Expand the `spec` section in the API response to view all the information.
See the following sample notification payload.

<Note>
  If you use Endor Labs with an EU tenant, use `https://app.eu.endorlabs.com` instead of `https://app.endorlabs.com`.
</Note>

```json expandable theme={null}
{
 "data": {
  "message": "4 findings discovered for project endorlabs/monorepo",
  "projectURL": "https://app.endorlabs.com/t/<your-namespace>/projects/<project_uuid>",
  "policy": {
   "name": "Webhook vuln",
   "url": "https://app.endorlabs.com/t/<your-namespace>/policies/actions?filter.default=Webhook+vuln"
  },
  "findings": [
   {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "description": "GHSA-c2qf-rxjj-qqgw: semver vulnerable to Regular Expression Denial of Service",
    "severity": "FINDING_LEVEL_MEDIUM",
    "dependency": "semver@7.5.0",
    "package": "endorlabs-vscode-extension@1.5.0",
    "findingURL": "https://app.endorlabs.com/t/<your-namespace>/findings/550e8400-e29b-41d4-a716-446655440000"
   },
   {
    "uuid": "550e8400-e29b-41d4-a716-446655440001",
    "description": "GHSA-c2qf-rxjj-qqgw: semver vulnerable to Regular Expression Denial of Service",
    "severity": "FINDING_LEVEL_MEDIUM",
    "dependency": "semver@7.3.8",
    "package": "endorlabs-vscode-extension@1.5.0",
    "findingURL": "https://app.endorlabs.com/t/<your-namespace>/findings/550e8400-e29b-41d4-a716-446655440001"
   },
   {
    "uuid": "550e8400-e29b-41d4-a716-446655440002",
    "description": "GHSA-c2qf-rxjj-qqgw: semver vulnerable to Regular Expression Denial of Service",
    "severity": "FINDING_LEVEL_MEDIUM",
    "dependency": "semver@5.7.1",
    "package": "endorlabs-vscode-extension@1.5.0",
    "findingURL": "https://app.endorlabs.com/t/<your-namespace>/findings/550e8400-e29b-41d4-a716-446655440002"
   },
   {
    "uuid": "550e8400-e29b-41d4-a716-446655440003",
    "description": "GHSA-c2qf-rxjj-qqgw: semver vulnerable to Regular Expression Denial of Service",
    "severity": "FINDING_LEVEL_MEDIUM",
    "dependency": "semver@6.3.0",
    "package": "endorlabs-vscode-extension@1.5.0",
    "findingURL": "https://app.endorlabs.com/t/<your-namespace>/findings/550e8400-e29b-41d4-a716-446655440003"
   }
  ]
 }
}
```

In the payload, replace `<your-namespace>` and `<project_uuid>` with the values from your Endor Labs tenant. The identifier in each `findingURL` matches that finding's `uuid`.

### Payload variations by notification event

Endor Labs sends a webhook for three notification events. The `data.message` text and the presence of `data.findings` change with the event.

* **Findings discovered**: Endor Labs sends the payload shown above. The `data.message` field reads `<count> findings discovered for project <project>` and `data.findings` lists every finding.
* **New findings added**: A later scan finds additional findings. The `data.message` field reads `<count> new findings discovered for project <project>` and `data.findings` lists only the new findings.
* **Findings resolved**: A scan resolves all findings. The `data.message` field reads `All findings resolved for project <project>` and the payload omits the `data.findings` array.

The `data.policy` object is present only when an action policy triggers the notification.

The following example shows the payload when a later scan adds new findings. The `data.findings` array lists only the new findings.

```json expandable theme={null}
{
 "data": {
  "message": "2 new findings discovered for project endorlabs/monorepo",
  "projectURL": "https://app.endorlabs.com/t/<your-namespace>/projects/<project_uuid>",
  "policy": {
   "name": "Webhook vuln",
   "url": "https://app.endorlabs.com/t/<your-namespace>/policies/actions?filter.default=Webhook+vuln"
  },
  "findings": [
   {
    "uuid": "550e8400-e29b-41d4-a716-446655440004",
    "description": "GHSA-jf85-cpcp-j695: Prototype Pollution in lodash",
    "severity": "FINDING_LEVEL_CRITICAL",
    "dependency": "lodash@4.17.4",
    "package": "endorlabs-vscode-extension@1.5.0",
    "findingURL": "https://app.endorlabs.com/t/<your-namespace>/findings/550e8400-e29b-41d4-a716-446655440004"
   },
   {
    "uuid": "550e8400-e29b-41d4-a716-446655440005",
    "description": "GHSA-35jh-r3h4-6jhm: Command Injection in lodash",
    "severity": "FINDING_LEVEL_HIGH",
    "dependency": "lodash@4.17.4",
    "package": "endorlabs-vscode-extension@1.5.0",
    "findingURL": "https://app.endorlabs.com/t/<your-namespace>/findings/550e8400-e29b-41d4-a716-446655440005"
   }
  ]
 }
}
```

The following example shows the payload when a scan resolves all findings. The payload omits the `data.findings` array.

```json expandable theme={null}
{
 "data": {
  "message": "All findings resolved for project endorlabs/monorepo",
  "projectURL": "https://app.endorlabs.com/t/<your-namespace>/projects/<project_uuid>",
  "policy": {
   "name": "Webhook vuln",
   "url": "https://app.endorlabs.com/t/<your-namespace>/policies/actions?filter.default=Webhook+vuln"
  }
 }
}
```

## Use Endor Labs webhooks to integrate with Slack

If you use Slack as a collaborative tool, integrate Slack channels using webhooks in Endor Labs to publish notifications as messages in the respective channels.

* [Configure a webhook integration](#configure-a-webhook-integration)
* [Endor Labs webhook payload](#endor-labs-webhook-payload)
* [Use Endor Labs webhooks to integrate with Slack](#use-endor-labs-webhooks-to-integrate-with-slack)
  * [Create incoming webhooks in Slack](#create-incoming-webhooks-in-slack)
  * [Customize webhook notification templates](#customize-webhook-notification-templates)
  * [Data model](#data-model)
  * [Webhook handler example for Slack](#webhook-handler-example-for-slack)

### Create incoming webhooks in Slack

Create an incoming webhook to your Slack channel to enable Endor Labs to post notifications in the channel. The webhook provides a unique URL for integrating the channel in Endor Labs. To send messages into Slack using incoming webhooks, see [Slack Integration](https://api.slack.com/messaging/webhooks)
If you have already created an incoming webhook in the channel, copy the unique URL and integrate the channel in Endor Labs.

### Customize webhook notification templates

Endor Labs provides you with a default template with standard information for the webhook message. You can use the default template or you can choose to edit and customize this template to fit your organization's specific requirements. You can also create your own custom templates using [Go Templates](https://pkg.go.dev/text/template).

1. Select **User menu** > **Integrations** from the left sidebar.
2. Under **Notifications**, click **Manage** on the **Slack** card to view the configured notification integrations.
3. Choose one, click the ellipsis on the right side, and click **Edit Template**.
4. Make required changes to any of the following templates and click **Save Template**.
   * **Open** - This template applies when Endor Labs raises new notifications.
   * **Update** - This template applies when an existing notification updates, such as when findings change.
   * **Resolve** - This template applies when all findings reported by the notification resolve.
5. Click **Restore to Default** to revert the changes.
6. Use the download icon on the top right corner to download this template.
7. Use the copy icon to copy the information in the template.

### Data model

To create custom templates for Webhook notifications, you must understand the data supplied to the template.

See the protobuf specification `NotificationData` message used for the templates.

<CodeFile src="/templates/notifications/notification_data.json" lang="proto" />

To understand Project, Finding, PackageVersion and RepositoryVersion definitions used in this protobuf specification, see:

* [Project resource kind](/developers-api/rest-api/using-the-rest-api/data-model/resource-kinds#project)
* [Finding resource kind](/developers-api/rest-api/using-the-rest-api/data-model/resource-kinds#finding)
* [PackageVersion resource kind](/developers-api/rest-api/using-the-rest-api/data-model/resource-kinds#packageversion)
* [RepositoryVersion resource kind](/developers-api/rest-api/using-the-rest-api/data-model/resource-kinds#repositoryversion)

See the following specification to understand a few additional functions available to the template. You can access these functions by using their corresponding keys.

<CodeFile src="/templates/notifications/notification_func_map.json" lang="go" />

### Webhook handler example for Slack

Create a webhook handler or a cloud function to receive webhook requests generated by Endor Labs, authorize the request, and post messages to your Slack channel.

See the following code sample hosted as a cloud function or a webhook handler.

```go expandable theme={null}
// Package p contains an HTTP Cloud Function that receives Endor Labs webhook
// notifications, verifies the HMAC signature, and posts a message to Slack.
package p

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

// WebhookMessage is the default Endor Labs webhook payload.
type WebhookMessage struct {
	Data Payload `json:"data"`
}

type Payload struct {
	Message    string    `json:"message"`
	ProjectURL string    `json:"projectURL"`
	Policy     Policy    `json:"policy"`
	Findings   []Finding `json:"findings"`
}

type Finding struct {
	UUID              string `json:"uuid"`
	Description       string `json:"description"`
	Severity          string `json:"severity"`
	Dependency        string `json:"dependency,omitempty"`
	Package           string `json:"package,omitempty"`
	RepositoryVersion string `json:"repositoryVersion,omitempty"`
	FindingURL        string `json:"findingURL"`
}

type Policy struct {
	Name string `json:"name"`
	URL  string `json:"url"`
}

// hmacSharedKey is the HMAC Shared Key you configured on the webhook integration.
const hmacSharedKey = "<your-hmac-shared-key>"

// HandleWebhook verifies the request signature, then posts the notification to Slack.
func HandleWebhook(w http.ResponseWriter, r *http.Request) {
	// Read the raw body first. Endor Labs signs these exact bytes, so verify the
	// signature before decoding the JSON.
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unable to read request body", http.StatusBadRequest)
		return
	}

	// Endor Labs signs the raw body with HMAC-SHA256 and sends the
	// base64-encoded signature in the X-Endor-HMAC-Signature header.
	if !validSignature(body, r.Header.Get("X-Endor-HMAC-Signature"), hmacSharedKey) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var message WebhookMessage
	if err := json.Unmarshal(body, &message); err != nil {
		http.Error(w, "unable to decode payload", http.StatusBadRequest)
		return
	}

	text := fmt.Sprintf("%s which violates policy %s", message.Data.Message, message.Data.Policy.Name)
	if err := sendMessageToSlack(text); err != nil {
		log.Printf("unable to post to Slack: %v", err)
		http.Error(w, "unable to post to Slack", http.StatusInternalServerError)
		return
	}
}

// validSignature recomputes the HMAC over the raw body and compares it against
// the received signature in constant time.
func validSignature(body []byte, receivedSignature, sharedKey string) bool {
	mac := hmac.New(sha256.New, []byte(sharedKey))
	mac.Write(body)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(receivedSignature))
}

// sendMessageToSlack posts a message to a Slack incoming webhook.
func sendMessageToSlack(text string) error {
	// Replace this with the incoming webhook URL from your Slack app.
	slackWebhookURL := "https://hooks.slack.com/services/<your-slack-webhook>"

	body, err := json.Marshal(map[string]string{"text": text})
	if err != nil {
		return err
	}

	resp, err := http.Post(slackWebhookURL, "application/json", bytes.NewReader(body))
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}
```
