Add generic webhook notification channel

Introduces a new 'Webhook (Custom)' notification channel allowing users to send JSON payloads to any HTTP endpoint (e.g., n8n, Zapier, custom APIs). Updates the UI to support webhook configuration, adds backend validation, and implements the WebhookChannel for sending notifications. Documentation is updated with usage instructions and payload examples.
This commit is contained in:
Hosteroid
2025-10-17 11:13:25 +03:00
parent 6e8fef9b79
commit 2b783b7470
6 changed files with 219 additions and 82 deletions

View File

@@ -217,6 +217,34 @@ All application and email settings are now managed through the **Settings** page
4. Copy the webhook URL
5. Add it in the notification group settings
#### 🌐 Webhook (Custom)
Send JSON payloads to any HTTP endpoint (e.g., n8n, Zapier, Make, your own API):
1. Go to Notification Groups → Edit → Add Channel
2. Choose "Webhook (Custom)"
3. Paste your endpoint URL (HTTPS recommended)
4. Click "Test Channel" to verify
Payload example sent on domain alerts:
```json
{
"event": "domain_expiration_alert",
"message": "⚠️ WARNING: Domain 'example.com' expires in 7 days (January 30, 2026)!\n\nRegistrar: Example Registrar\nPlease renew soon.",
"data": {
"domain": "example.com",
"domain_id": 123,
"days_left": 7,
"expiration_date": "2026-01-30",
"registrar": "Example Registrar"
},
"sent_at": "2025-10-17T12:34:56Z"
}
```
Use this with n8n's "Webhook" trigger to start flows.
## 📅 Setting Up Cron Jobs
The application requires a cron job to check domains periodically.

View File

@@ -214,6 +214,7 @@ class NotificationGroupController extends Controller
break;
case 'discord':
case 'slack':
case 'webhook':
$missingField = 'webhook URL';
break;
}
@@ -427,6 +428,17 @@ class NotificationGroupController extends Controller
}
return ['webhook_url' => $webhookUrl];
case 'webhook':
$webhookUrl = trim($data['webhook_url'] ?? '');
if (empty($webhookUrl) || !filter_var($webhookUrl, FILTER_VALIDATE_URL)) {
return null;
}
// Optional: Allow any HTTPS URL; prefer HTTPS for security
if (!str_starts_with($webhookUrl, 'https://') && !str_starts_with($webhookUrl, 'http://')) {
return null;
}
return ['webhook_url' => $webhookUrl];
default:
return null;
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Services\Channels;
use GuzzleHttp\Client;
class WebhookChannel implements NotificationChannelInterface
{
private Client $httpClient;
public function __construct()
{
$this->httpClient = new Client(['timeout' => 10]);
}
public function send(array $config, string $message, array $data = []): bool
{
$url = trim($config['webhook_url'] ?? '');
if (empty($url)) {
return false;
}
// Build a sane, generic JSON payload for automation tools (n8n, Zapier, etc.)
$payload = [
'event' => 'domain_expiration_alert',
'message' => $message,
'data' => $data,
'sent_at' => date('c')
];
try {
$response = $this->httpClient->post($url, [
'headers' => [
'Content-Type' => 'application/json'
],
'json' => $payload
]);
$status = $response->getStatusCode();
return $status >= 200 && $status < 300;
} catch (\Exception $e) {
error_log('Webhook send failed: ' . $e->getMessage());
return false;
}
}
}

View File

@@ -6,6 +6,7 @@ use App\Services\Channels\EmailChannel;
use App\Services\Channels\TelegramChannel;
use App\Services\Channels\DiscordChannel;
use App\Services\Channels\SlackChannel;
use App\Services\Channels\WebhookChannel;
class NotificationService
{
@@ -18,6 +19,7 @@ class NotificationService
'telegram' => new TelegramChannel(),
'discord' => new DiscordChannel(),
'slack' => new SlackChannel(),
'webhook' => new WebhookChannel(),
];
}

View File

@@ -78,9 +78,9 @@ ob_start();
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<?php foreach ($group['channels'] as $channel):
$config = json_decode($channel['channel_config'], true);
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack'];
$iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab'];
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal'];
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack', 'webhook' => 'fa-link'];
$iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab', 'webhook' => 'fas'];
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal', 'webhook' => 'purple'];
$icon = $icons[$channel['channel_type']] ?? 'fa-bell';
$iconClass = $iconClasses[$channel['channel_type']] ?? 'fas';
$color = $colors[$channel['channel_type']] ?? 'gray';
@@ -152,6 +152,7 @@ ob_start();
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="webhook">Webhook (Custom)</option>
</select>
</div>
@@ -236,6 +237,24 @@ ob_start();
</div>
</div>
<!-- Generic Webhook Fields -->
<div id="webhook_fields" class="hidden space-y-4">
<div>
<label for="generic_webhook_url" class="block text-sm font-medium text-gray-700 mb-1.5">
Webhook URL
</label>
<input type="text"
id="generic_webhook_url"
name="webhook_url"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
placeholder="https://example.com/webhook-endpoint"
autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500">
Will receive JSON payload compatible with n8n/Zapier/Make.
</p>
</div>
</div>
<div class="flex gap-3">
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
@@ -314,6 +333,7 @@ function toggleChannelFields() {
const chatIdField = document.getElementById('chat_id');
const discordWebhook = document.getElementById('discord_webhook');
const slackWebhook = document.getElementById('slack_webhook');
const genericWebhook = document.getElementById('generic_webhook_url');
// Remove required from all
emailField.removeAttribute('required');
@@ -321,12 +341,14 @@ function toggleChannelFields() {
chatIdField.removeAttribute('required');
discordWebhook.removeAttribute('required');
slackWebhook.removeAttribute('required');
if (genericWebhook) genericWebhook.removeAttribute('required');
// Hide all fields
document.getElementById('email_fields').classList.add('hidden');
document.getElementById('telegram_fields').classList.add('hidden');
document.getElementById('discord_fields').classList.add('hidden');
document.getElementById('slack_fields').classList.add('hidden');
document.getElementById('webhook_fields').classList.add('hidden');
// Hide test button by default
testBtn.classList.add('hidden');
@@ -352,6 +374,12 @@ function toggleChannelFields() {
slackWebhook.setAttribute('required', 'required');
slackWebhook.focus();
break;
case 'webhook':
if (genericWebhook) {
genericWebhook.setAttribute('required', 'required');
genericWebhook.focus();
}
break;
}
// Show test button when channel type is selected
@@ -401,6 +429,17 @@ if (addChannelForm) {
}
}
// Validate Generic webhook
if (channelType === 'webhook') {
const webhookUrl = document.getElementById('generic_webhook_url').value.trim();
if (!webhookUrl) {
e.preventDefault();
alert('Please enter the Webhook URL');
document.getElementById('generic_webhook_url').focus();
return false;
}
}
return true;
});
}
@@ -473,6 +512,13 @@ function testChannel(channelType, existingConfig = null) {
errorMessage = 'Please enter a Slack webhook URL';
}
break;
case 'webhook':
const genericWebhook = document.getElementById('generic_webhook_url').value.trim();
if (!genericWebhook) {
isValid = false;
errorMessage = 'Please enter a Webhook URL';
}
break;
}
if (!isValid) {
@@ -531,6 +577,9 @@ function testChannel(channelType, existingConfig = null) {
case 'slack':
formData.append('slack_webhook_url', document.getElementById('slack_webhook').value);
break;
case 'webhook':
formData.append('webhook_url', document.getElementById('generic_webhook_url').value);
break;
}
}

View File

@@ -536,10 +536,8 @@ foreach ($notificationPresets as $key => $preset) {
</div>
</form>
</div>
</div>
<!-- Two-Factor Authentication Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-6">
<!-- Two-Factor Authentication Settings -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-6">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
<p class="text-sm text-gray-600 mt-1">Configure 2FA policy and security settings</p>
@@ -616,8 +614,8 @@ foreach ($notificationPresets as $key => $preset) {
</button>
</div>
</form>
</div>
</div>
<!-- Tab Content: System Information -->
<div id="content-system" class="tab-content hidden">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">