# Slack Loop Updates Integration

**Last Updated:** 2026-04-10

Sends Ordio Loop partner and lead activity to the #loop-updates Slack channel so the team can track new users and conversions. Supports Incoming Webhooks (notifications) and Slash Commands (on-demand queries).

## Overview

- **Channel:** #loop-updates (Ordio workspace)
- **Notifications:** Incoming Webhooks (one-way)
- **Commands:** Slash Commands (`/loop`) for on-demand queries
- **Helper:** `v2/helpers/affiliate-slack.php`
- **Lead dedupe:** `v2/helpers/affiliate-slack-lead-dedupe.php` (JSON store, 90-day TTL; one `lead_attributed` per partner + email or partner + HubSpot contact id)
- **Non-blocking:** User flow is never affected if Slack fails

### Internal deep links (admin)

- **`deal_closed_won` / `deal_status_change`:** When the sync passes `hubspot_deal_id`, the Deal line in Slack uses a mrkdwn link to the HubSpot CRM record (same URL shape as `hubspotCrmRecordUrl()` in `v2/helpers/hubspot-affiliate-api.php`).
- **`weekly_summary`:** Includes a link to **Programm-Analyse** (`/v2/pages/partner-program-analytics.php`) for pipeline and closed-deal tables (admin-only UI).

## Setup

### 1. Enable Incoming Webhooks

1. Go to [Slack API Apps](https://api.slack.com/apps) → select **Ordio Loop**
2. In the left sidebar, click **Incoming Webhooks**
3. Toggle **Activate Incoming Webhooks** to **On**

### 2. Add Webhook for #loop-updates

1. Click **Add New Webhook to Workspace**
2. Select channel **#loop-updates**
3. Click **Allow**
4. Copy the generated webhook URL (format: `https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX`)

### 3. Store the Webhook URL

**Priority:**

1. **Environment variable** (if available): `SLACK_LOOP_UPDATES_WEBHOOK_URL`
2. **Local override:** `v2/config/slack-config.local.php` (gitignored)
3. **Fallback:** Hardcoded in `v2/config/slack-config.php` (used when env vars are not available)

When env vars cannot be set, the webhook URL is stored in `slack-config.php`. If the repo is or becomes public, rotate the webhook in Slack and update the config.

### 4. Optional: Sandbox channel for safe testing

To try Block Kit payloads without posting to **#loop-updates**, create a private channel (for example **#loop-updates-sandbox**), add a **second** Incoming Webhook in the same Ordio Loop app, and point it at that channel.

**Do not commit webhook URLs.** Store the sandbox URL only in:

- Environment variable `SLACK_LOOP_TEST_WEBHOOK_URL`, or
- `v2/config/slack-config.local.php` as `'test_webhook_url' => 'https://hooks.slack.com/services/...'`

Template (committed): `v2/config/slack-config.local.php.example` — copy to `slack-config.local.php` (gitignored) and paste your URLs.

`define('SLACK_LOOP_TEST_WEBHOOK_URL', ...)` is loaded from those sources in `v2/config/slack-config.php`. Production notification code uses only `SLACK_LOOP_UPDATES_WEBHOOK_URL`.

Run the test script against the sandbox:

```bash
php v2/scripts/affiliate/test-slack-webhook.php --verbose
```

If both production and test URLs are set, the script **prefers the sandbox URL** for POSTs. You can also force a URL once:

```bash
php v2/scripts/affiliate/test-slack-webhook.php --webhook-url='https://hooks.slack.com/services/...'
```

### 5. Optional: HubSpot workflow → instant Slack

For **immediate** `lead_attributed` when a contact is assigned in CRM (instead of waiting for the hourly sync), configure Operations Hub **Send webhook** to:

`POST https://www.ordio.com/v2/api/slack-loop-lead-attribution-webhook.php`

**Auth:** Header `X-Ordio-Loop-Attribution-Token` must match the secret from:

- Env: `SLACK_LOOP_ATTRIBUTION_WEBHOOK_SECRET`, or
- `v2/config/slack-config.local.php` → `loop_attribution_webhook_secret`

**JSON body (example):** `partner_id`, `email` (or `hubspot_contact_id` if no email), optional `firstname` / `lastname`, optional `hubspot_contact_id`.

The endpoint uses the same Block Kit builder and **dedupe** as the website and sync paths, so workflow + sync do not double-post.

If the secret is empty, the endpoint returns HTTP 503 (disabled).

### 6. Slash Commands (Optional)

To enable `/loop` commands:

1. In Slack API Apps → **Ordio Loop** → **Slash Commands** → **Create New Command**
2. **Command:** `/loop`
3. **Request URL:** `https://www.ordio.com/v2/api/slack-command-handler.php` (or your production URL)
4. **Short Description:** `Ordio Loop partner and lead stats`
5. **Usage Hint:** `partner <id> | top [n] | summary | help`
6. In **OAuth & Permissions** → Bot Token Scopes: add `commands`
7. In **Basic Information** → App Credentials: copy **Signing Secret**
8. Store Signing Secret: env `SLACK_SIGNING_SECRET` or `slack-config.local.php` → `signing_secret`
9. **Reinstall App** to workspace

## Activity Types (Notifications)

| Type | Trigger | Payload |
|------|---------|---------|
| `registration` | New partner via email form | partner_id, name, email |
| `registration_oauth` | New partner via Google OAuth | partner_id, name, email |
| `lead_attributed` | Lead with affiliate (forms), CRM sync (new contact on partner), or workflow webhook | partner_id; **email and/or hubspot_contact_id**; optional partner_name, lead_name, page, signuptype, channel_label, call_preference, lead_source, attribution_source |
| `partner_level_up` | Level changes during sync | partner_id, name, old_level, new_level |
| `deal_closed_won` | New closed-won deal (sync diff) | partner_id, partner_name, deal_name, deal_mrr, partner_share |
| `lead_qualified` | Lead moves to qualified (sync diff) | partner_id, partner_name, email |
| `deal_status_change` | Deal subscription_status changes | partner_id, partner_name, deal_name, old_status, new_status |
| `mrr_milestone` | Partner crosses €100/€500/€1000 MRR | partner_id, partner_name, milestone, current_mrr |
| `admin_status_change` | Partner activated/deactivated (behind flag) | partner_id, target_email, new_status, actor |
| `admin_level_change` | Manual level override (behind flag) | partner_id, new_level, actor |
| `admin_assign` / `admin_revoke` | Admin role assigned/removed (behind flag) | partner_id, target_email, actor |
| `weekly_summary` | Weekly digest (cron: e.g. Mondays 09:00 Europe/Berlin) — **7 calendar days before report day** (report day excluded) | new_partners, new_leads, new_deals, **open_pipeline_snapshot** (current cache: pipeline deals not won/lost), top_partners, total_mrr, report_date, period_start, period_end |

### Block Kit message layout (all notification types)

Payloads are built in `buildSlackLoopUpdatePayload()` in `v2/helpers/affiliate-slack.php`.

- **Structure:** `text` (short fallback for push / accessibility via `slackLoopFallbackNotificationText()` — **no** app-name prefix; Slack shows the bot name as sender) plus **`attachments[0].blocks`**: `header` (emoji + German title; lead notifications use `:alert:` for “Neuer Partner-Lead”) → `divider` → `section` (fields or text) → `context` (UTC timestamp). Copy stays internal-reporting style (no marketing tone).
- **Emoji headers:** Each activity type has a **distinct** leading emoji in the header (e.g. email vs OAuth registration use different icons). Helpers: `slackLoopHeaderTextForType()`, `slackLoopHeaderBlock()`.
- **Color bar:** `attachments[0].color` is a hex sidebar color per type (`slackLoopAttachmentColorHex()`) so partner vs lead vs revenue vs admin notifications are easier to scan.
- **Tooling:** To read blocks from a built payload (top-level `blocks` vs attachment-wrapped), use `slackLoopPayloadGetBlocks()`.

### `lead_attributed` message fields

- **Kanal** maps `signuptype` to short German labels where known (e.g. `tools_page` → Tools / Rechner); you can override with `channel_label`.
- **Herkunft** uses `page` (tool name, Lead Capture path, `CRM / Kontaktliste`, etc.).
- **`attribution_source`:** `crm_sync` → Quelle “CRM-Sync (stündlich)”; `hubspot_workflow` → “HubSpot-Workflow”; omit for normal website forms.
- **Dedupe:** After a successful Slack HTTP 200, `lead_attributed` is recorded so the same partner + email (or partner + HubSpot id without email) does not notify again within the TTL. Pass **`skip_dedupe_check` => true** only in tests/scripts (stripped before Block Kit build).

## Change Detection (Sync-Time Notifications)

Sync-time notifications (`partner_level_up`, `deal_closed_won`, `lead_qualified`, `deal_status_change`, `mrr_milestone`, **`lead_attributed` for new contacts**) **only fire when a previous cache state exists** (baseline guard):

- **Previous cache:** Loaded at sync start; used for diffing. No mass notifications on first sync for deal/lead/mrr (no previous state).
- **partner_level_up:** Level changed from registry value (`effectiveLevel !== levelBeforeSync`).
- **deal_closed_won:** Deal is closed-won and was NOT closed-won in previous cache.
- **lead_qualified:** Lead was in previous cache with status ≠ qualified; now qualified.
- **lead_attributed (sync):** Partner had **non-empty** lead list in previous cache; a HubSpot contact id appears in the current fetch that was **not** in the previous list (e.g. manual `affiliate_partner_id` in CRM). Uses `attribution_source` = `crm_sync`.
- **deal_status_change:** Deal `subscription_status` changed (old ≠ new).
- **mrr_milestone:** Partner had previous cache entry; MRR crossed €100/€500/€1000 threshold.

Real-time events (`registration`, `lead_attributed` from forms, admin actions) fire at the moment of the event.

## Integration Points

- `v2/api/partner-register.php` – after successful registration
- `v2/pages/partner-oauth-callback.php` – after new OAuth user creation
- `v2/api/collect-lead.php` – after HubSpot success when `affiliate` param present
- `v2/api/lead-capture.php` – after HubSpot success when `affiliate` param present
- `v2/helpers/affiliate-sync-runner.php` – sync-time notifications (level, deal, lead, new attributed contact, MRR)
- `v2/api/slack-loop-lead-attribution-webhook.php` – optional HubSpot workflow webhook (secret required)
- `v2/api/affiliate-admin-update-partner.php` – admin actions (when `SLACK_ADMIN_ACTIONS_ENABLED`)
- `v2/scripts/affiliate/weekly-loop-summary.php` – weekly digest (cron; `daily-loop-summary.php` is a deprecated wrapper)

### Weekly digest (cron)

- **Slack type:** `weekly_summary` (header: **Wochenbericht**).
- **Window:** The **seven completed calendar days before the report day** (`Europe/Berlin`): from `today 00:00 − 7 days` up to but **not including** `today 00:00` (the execution calendar day is never counted). Stated in Slack as `period_start` … `period_end` (first/last included date, usually “through yesterday”).
- **MRR / Top Partner:** Current **snapshot** from cache (not a 7-day sum).
- **Offene Pipeline-Deals:** `open_pipeline_snapshot` — count of partner-linked deals in cache that are neither Closed Won nor Closed Lost (same semantics as Programm-Analyse / `affiliateDealPipelineCountsForPartner`). Not windowed; reflects last sync.
- **Schedule:** e.g. `0 9 * * 1` with working directory set to the repo; use host `CRON_TZ=Europe/Berlin` or adjust the hour if cron runs in UTC.
- **CLI:** `php v2/scripts/affiliate/weekly-loop-summary.php --dry-run` prints counts without Slack.

## Slash Commands

| Command | Args | Response |
|---------|------|----------|
| `/loop partner <id\|email>` | Partner ID or email (fuzzy) | Name, level, leads, deals, MRR, last activity |
| `/loop top [n]` | Optional n (default 5) | Top n partners by MRR |
| `/loop summary` | — | Last 24h: new partners, leads, deals; total MRR; top 5 |
| `/loop help` | — | List commands and usage |

Responses are ephemeral (visible only to the user who invoked the command).

## Message Format

Messages use Slack Block Kit for structured layout. Full email addresses are shown in notifications. Incoming webhooks do not support custom block colors; differentiation is via headers and field labels (purposeful, scannable internal updates).

## Config Flags

- `SLACK_ADMIN_ACTIONS_ENABLED`: Set to `true` (env or `slack-config.local.php` → `admin_actions_enabled`) to enable admin action notifications. Default: `false`.
- `SLACK_LOOP_ATTRIBUTION_WEBHOOK_SECRET`: Enables the optional workflow webhook when non-empty.

## Troubleshooting

### Quick Diagnostics

**Run comprehensive diagnostic (payload only, no live POST):**
```bash
php v2/scripts/affiliate/diagnose-slack-integration.php --verbose --skip-live-post
```

**Print Block Kit JSON without posting to Slack:**
```bash
php v2/scripts/affiliate/test-slack-webhook.php --dry-run --all --verbose
```

**Test webhook connectivity (posts a real message — avoid on busy channels during development):**
```bash
php v2/scripts/affiliate/test-slack-webhook.php
```

Do **not** rely on repeated live webhook tests against production #loop-updates during development; prefer `--dry-run`, `--skip-live-post`, or a staging webhook URL in `slack-config.local.php`.

### No messages in #loop-updates

1. **Run diagnostic script:** `php v2/scripts/affiliate/diagnose-slack-integration.php --verbose`
2. **Check webhook URL:** Run `php v2/scripts/affiliate/test-slack-webhook.php`
3. **Check config:** Ensure `SLACK_LOOP_UPDATES_ENABLED` is true (webhook URL set and valid)
4. **Check logs:** 
   - Dedicated log: `tail -f v2/logs/affiliate-slack.log`
   - Error log: `grep "[Affiliate Slack]" /var/log/php-fpm/error.log`

**Common causes:**
- Webhook URL expired or invalid → Regenerate in Slack API dashboard
- Configuration disabled → Set `SLACK_LOOP_UPDATES_ENABLED=true`
- cURL errors → Check network connectivity, SSL certificates
- Invalid webhook URL format → Must start with `https://hooks.slack.com/services/`
- **Dedupe:** Second notification for the same partner + lead is skipped (see log line `lead_attributed dedupe`)

### Slash commands not working

1. **Check Signing Secret:** Ensure `SLACK_SIGNING_SECRET` is set (env or `slack-config.local.php`)
2. **Check Request URL:** Must be HTTPS and publicly accessible
3. **Check Slack App:** Reinstall app after adding Slash Commands
4. **Check logs:** Invalid signature returns 401

### Monitoring & Health Checks

**Check notification status (admin only):**
```bash
curl -b cookies.txt https://www.ordio.com/v2/api/slack-notification-status.php
```

**Monitor log file:**
```bash
tail -f v2/logs/affiliate-slack.log
```

**Health check:**
```php
<?php
require_once 'v2/helpers/affiliate-slack-monitor.php';
checkSlackNotificationHealth(5, 50.0, 24);
```

### Diagnostic Commands

**Comprehensive diagnostics:**
```bash
php v2/scripts/affiliate/diagnose-slack-integration.php --verbose
```

**Test webhook:**
```bash
php v2/scripts/affiliate/test-slack-webhook.php --verbose
```

**Check statistics:**
```php
<?php
require_once 'v2/helpers/affiliate-slack-monitor.php';
$stats = getSlackNotificationStats(24);
print_r($stats);
```

For detailed troubleshooting, see [SLACK_TROUBLESHOOTING.md](SLACK_TROUBLESHOOTING.md).

For monitoring setup, see [SLACK_MONITORING.md](SLACK_MONITORING.md).

## Security

- Webhook URL and Signing Secret are secrets; treat like passwords
- Store in environment variables or gitignored config
- Do not log full webhook URL or signing secret
- Rotate webhook if exposed (Slack API → Incoming Webhooks → regenerate)
- Signing Secret verifies that slash command requests are from Slack
- Attribution webhook secret must match the header token; do not commit secrets

## Related

- [SLACK_TROUBLESHOOTING.md](SLACK_TROUBLESHOOTING.md) – Comprehensive troubleshooting guide
- [SLACK_MONITORING.md](SLACK_MONITORING.md) – Monitoring and health checks
- [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) – Slack webhook, Signing Secret, digest cron
- [.cursor/rules/affiliate-slack.mdc](../../../.cursor/rules/affiliate-slack.mdc) – Cursor rule for Slack integration
