2 Commits

Author SHA1 Message Date
Bartłomiej Janczak
bdef0b6687 feat: add api_enabled on/off toggle in settings + update README for v1.5 2026-06-02 11:10:10 +02:00
Bartłomiej Janczak
e0b7e3f28c feat: add social retargeting pixels, developer REST API, and webhooks system 2026-06-02 09:44:53 +02:00
16 changed files with 973 additions and 1 deletions

254
README.md
View File

@@ -50,6 +50,9 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
- 🎯 **Smart Link Targeting** — Route visitors to different destination URLs based on their device type, country, or via weighted A/B split rotation.
- 🛡️ **Rate Limiting / Bot Protection** — Configurable per-IP rate limits on redirects with automatic `429 Too Many Requests` responses.
- 📊 **Daily Stats Aggregation & Pruning** — Automatic daily summarization of raw visit logs into compact daily stats tables. Configurable retention window prevents unbounded database growth at scale.
- 🎯 **Social Retargeting Pixels** *(new in v1.5)* — Inject Meta Pixel, Google Tag, and LinkedIn Insight tracking scripts client-side via a premium glassmorphic interstitial page. Build remarketing audiences even when redirecting to external domains.
- 🔌 **Developer REST API** *(new in v1.5)* — Full REST API (`GET`, `POST`, `DELETE`) for external integrations. Secured with API Key authentication managed via the Settings panel.
- 📡 **Webhooks** *(new in v1.5)* — Real-time HTTP POST event notifications on every click, link creation, or expiration. Configure per-link or globally, dispatched asynchronously via the queue.
---
@@ -362,6 +365,246 @@ When Redis is not available and counter buffering is enabled, the `IncrementVisi
---
## Social Retargeting Pixels (new in v1.5.0)
The **Marketing & API** tab in the short URL form lets you attach client-side tracking pixels to any link. When a visitor clicks a link that has pixels configured, instead of an instant 302 redirect the plugin serves a lightweight HTML interstitial page (styled with glassmorphism) for ~250 ms. During that time the browser executes the pixel scripts — capturing cookies and building ad audiences — then the visitor is forwarded to the destination URL.
This unlocks remarketing to people who clicked your links **even when redirecting to external domains** (e.g. booking.com, amazon.com) where you cannot install your own tracking code.
### Supported Pixel Providers
| Field | Provider | Script loaded |
|---|---|---|
| **Meta Pixel ID** | Meta / Facebook Ads | `fbevents.js` via `fbq('init', ...)` |
| **Google Tag / GA4 ID** | Google Ads, GA4 | `gtag.js` via Google Tag Manager |
| **LinkedIn Partner ID** | LinkedIn Insight Tag | `insight.min.js` via LinkedIn |
> **Note:** These pixels fire **client-side** in the visitor's browser — completely separate from the server-side GA4 Measurement Protocol integration. Both systems work in parallel and do not interfere with each other.
### How to use
1. Open any short URL for editing.
2. Navigate to the **Marketing & API** tab.
3. Enter your pixel IDs in the **Retargeting Pixels** section.
4. Save. Done — every click will now trigger the configured tracking scripts.
```php
// Programmatically via model attributes
$shortUrl->update([
'pixel_meta_id' => '1234567890',
'pixel_google_id' => 'G-XXXXXXXXXX',
'pixel_linkedin_id' => '1234567',
]);
```
> **Privacy/GDPR Note:** You are responsible for ensuring that firing these pixels complies with applicable privacy regulations and your cookie consent mechanism.
---
## Developer REST API (new in v1.5.0)
The plugin exposes a REST API that allows external systems (CRMs, Zapier, Make, custom integrations) to manage short URLs programmatically.
### Enabling the API
The API is **disabled by default**. Enable it in **Settings → API & Webhooks → REST API Access → Enable Developer REST API**.
### Authentication
All API endpoints are protected. Include your API key in every request using one of these headers:
```
X-Api-Key: sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
or
```
Authorization: Bearer sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
**Managing API Keys:** Go to **Settings → API & Webhooks → Developer API Keys** and add named keys. Each key can be individually activated or deactivated without deleting it.
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
### Endpoints
#### `GET /api/short-url/links`
List all short URLs (paginated, 30 per page).
```bash
curl https://yourdomain.com/api/short-url/links \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:**
```json
{
"data": [
{
"id": 1,
"destination_url": "https://example.com",
"url_key": "abc123",
"short_url": "https://yourdomain.com/s/abc123",
"is_enabled": true,
"redirect_status_code": 302,
"total_visits": 47,
"unique_visits": 31,
"max_visits": null,
"activated_at": null,
"expires_at": null,
"pixel_meta_id": null,
"pixel_google_id": null,
"pixel_linkedin_id": null,
"webhook_url": null,
"notes": null,
"created_at": "2026-06-01T12:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 3,
"per_page": 30,
"total": 72
}
}
```
#### `POST /api/short-url/links`
Create a new short URL.
```bash
curl -X POST https://yourdomain.com/api/short-url/links \
-H "X-Api-Key: sh_key_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://example.com/product",
"url_key": "promo26",
"notes": "Summer campaign",
"single_use": false,
"max_visits": 1000,
"pixel_meta_id": "1234567890",
"webhook_url": "https://api.mycrm.com/clicks"
}'
```
**Accepted fields:**
| Field | Type | Required | Description |
|---|---|---|---|
| `destination_url` | string (URL) | ✅ | Target URL |
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
| `notes` | string | ❌ | Internal notes |
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect code |
| `single_use` | boolean | ❌ | Expire after first click |
| `forward_query_params` | boolean | ❌ | Forward query string to destination |
| `max_visits` | integer | ❌ | Click limit before expiry |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
| `activated_at` | datetime | ❌ | Activation timestamp |
| `expires_at` | datetime | ❌ | Expiration timestamp |
| `pixel_meta_id` | string | ❌ | Meta Pixel ID |
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
**Response:** `201 Created` with the created link object.
#### `DELETE /api/short-url/links/{id}`
Permanently delete a short URL by its ID.
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:** `200 OK`
```json
{ "message": "Short URL deleted successfully." }
```
### Error Responses
| HTTP Code | Reason |
|---|---|
| `401 Unauthorized` | Missing or invalid API key |
| `422 Unprocessable Entity` | Validation error (see `errors` field in response) |
| `503 Service Unavailable` | REST API is disabled in Settings |
---
## Webhooks (new in v1.5.0)
Webhooks allow external systems to receive real-time HTTP POST notifications when events occur on your short URLs. Payloads are dispatched **asynchronously** via the Laravel Queue — redirects are never blocked.
### Configuration
Webhooks can be configured at two levels:
**1. Per-link webhook** — Set a `Dedicated Webhook URL` in the **Marketing & API** tab of a specific short URL. Fires for every click on that link.
**2. Global webhook** — Set a **Global Webhook URL** in **Settings → API & Webhooks → Global Webhook Configuration**. Fires for all links that don't have their own webhook URL, for the event types you select.
### Monitored Events
| Event key | When fired |
|---|---|
| `visited` | A visitor clicks the short URL |
| `created` | A new short URL is created via the REST API |
| `expired` | A link reaches its expiration date |
| `limit_reached` | A link reaches its `max_visits` click limit |
Select which events to monitor in **Settings → API & Webhooks → Monitored Webhook Events**.
### Payload Format
All webhook requests are HTTP POST with `Content-Type: application/json` and the following payload structure:
```json
{
"event": "visited",
"timestamp": "2026-06-02T10:00:00+00:00",
"short_url": {
"id": 1,
"destination_url": "https://example.com",
"url_key": "abc123",
"short_url": "https://yourdomain.com/s/abc123",
"total_visits": 48,
"unique_visits": 32
},
"visit": {
"id": 101,
"visited_at": "2026-06-02T10:00:00+00:00",
"device_type": "desktop",
"browser": "Chrome",
"browser_version": "124.0",
"operating_system": "Windows",
"operating_system_version": "10",
"country": "Poland",
"country_code": "PL",
"city": "Warsaw",
"referer_url": "https://linkedin.com",
"referer_host": "linkedin.com",
"utm_source": "linkedin",
"utm_medium": "social",
"utm_campaign": "spring_sale",
"utm_term": null,
"utm_content": null
}
}
```
### Retry Policy
If the webhook endpoint returns a non-2xx response or is unreachable, the `SendWebhookJob` will automatically retry up to **3 times** with a **10-second backoff** between attempts. Failed jobs land in your queue's failed jobs table after exhausting retries.
### Webhook Priority (per-link vs global)
The resolution order is:
1. If the short URL has its own `webhook_url` → use it (always fires, regardless of global event selection).
2. If no per-link URL is set, and a **Global Webhook URL** is configured, and the event type is in the selected **Monitored Events** list → use the global URL.
3. Otherwise no webhook is fired.
---
## Configuration Reference (.env)
You can also pre-configure all parameters via your `.env` file:
@@ -520,7 +763,16 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## Changelog
### v1.4.0 (Latest)
### v1.5.1
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled. Toggle takes effect immediately without route cache clearing.
### v1.5.0
- **Social Retargeting Pixels** — Attach Meta Pixel, Google Tag (GA4/Ads), and LinkedIn Insight Tag to any short URL. A premium glassmorphic interstitial page executes pixel scripts in the visitor's browser before forwarding them to the destination. Enables building remarketing audiences even on external domains.
- **Developer REST API** — Full `GET /api/short-url/links`, `POST /api/short-url/links`, and `DELETE /api/short-url/links/{id}` endpoints with API Key authentication (managed via Settings UI). Supports creating links with all available attributes including pixels and webhooks.
- **Webhook System** — Real-time HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events. Configurable per-link or globally. Dispatched asynchronously via `SendWebhookJob` with 3-attempt retry policy and 10-second backoff.
- **Settings: API & Webhooks Tab** — New settings tab to manage global webhook URL, monitored event types, and developer API key management with name labels and per-key activation toggles.
### v1.4.0
- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active.
- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits).
- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page.

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('pixel_meta_id', 100)->nullable();
$table->string('pixel_google_id', 100)->nullable();
$table->string('pixel_linkedin_id', 100)->nullable();
$table->string('webhook_url', 2048)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
]);
});
}
};

View File

@@ -338,4 +338,36 @@ return [
'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
'warning_btn_continue' => 'Continue to Destination',
'warning_btn_back' => 'Go Back',
// Marketing & API Tab
'tab_marketing' => 'Marketing & API',
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Link Webhook Integration',
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
'webhook_url' => 'Dedicated Webhook URL',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',
'settings_section_rest_api' => 'REST API Access',
'settings_api_enabled' => 'Enable Developer REST API',
'settings_api_enabled_helper' => 'Allow external systems to create, list, and delete short URLs via the REST API. Disable to block all /api/short-url/* endpoints with a 503 response.',
'settings_section_global_webhook' => 'Global Webhook Configuration',
'settings_global_webhook_url' => 'Global Webhook URL',
'settings_global_webhook_url_helper' => 'Destination URL to dispatch event payloads asynchronously in the background for all links.',
'settings_webhook_events' => 'Monitored Webhook Events',
'settings_webhook_events_helper' => 'Select which events should trigger a POST notification.',
'webhook_event_visited' => 'Link Visited (Click)',
'webhook_event_created' => 'Link Created',
'webhook_event_expired' => 'Link Expired (Date)',
'webhook_event_limit_reached' => 'Link Click Limit Reached',
'settings_section_api_keys' => 'Developer API Keys',
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
'settings_api_keys' => 'Active API Keys',
'api_key_name' => 'Key Name (Description)',
'api_key' => 'API Key',
'active' => 'Active',
];

View File

@@ -339,4 +339,36 @@ return [
'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
'warning_btn_continue' => 'Kontynuuj do celu',
'warning_btn_back' => 'Wróć',
// Marketing & API Tab
'tab_marketing' => 'Marketing i API',
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Integracja Webhook linku',
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
'webhook_url' => 'Dedykowany Webhook URL',
// Settings tab additions
'settings_tab_developer' => 'API i Webhooki',
'settings_section_rest_api' => 'Dostęp do REST API',
'settings_api_enabled' => 'Włącz Developer REST API',
'settings_api_enabled_helper' => 'Zezwól zewnętrznym systemom na tworzenie, listowanie i usuwanie krótkich linków przez REST API. Wyłącz, aby zablokować wszystkie endpointy /api/short-url/* odpowiedzią 503.',
'settings_section_global_webhook' => 'Konfiguracja globalnego Webhooka',
'settings_global_webhook_url' => 'Globalny Webhook URL',
'settings_global_webhook_url_helper' => 'Adres URL, pod który wysyłane będą w tle powiadomienia o zdarzeniach ze wszystkich linków.',
'settings_webhook_events' => 'Monitorowane zdarzenia Webhooka',
'settings_webhook_events_helper' => 'Wybierz zdarzenia, które powinny wyzwalać wysłanie powiadomienia POST.',
'webhook_event_visited' => 'Odwiedziny linku (Wizyta)',
'webhook_event_created' => 'Utworzenie linku',
'webhook_event_expired' => 'Wygaśnięcie linku (Data)',
'webhook_event_limit_reached' => 'Osiągnięcie limitu kliknięć',
'settings_section_api_keys' => 'Klucze API Dewelopera',
'settings_api_keys_description' => 'Zarządzaj kluczami API do integracji z zewnętrznymi systemami (np. CRM, Zapier, Make). Autoryzacja odbywa się za pomocą nagłówka X-Api-Key.',
'settings_api_keys' => 'Aktywne klucze API',
'api_key_name' => 'Nazwa klucza (Opis)',
'api_key' => 'Klucz API',
'active' => 'Aktywny',
];

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connecting...</title>
<!-- Premium Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
<!-- Meta / Facebook Pixel -->
@if(!empty($pixelMetaId))
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '{{ $pixelMetaId }}');
fbq('track', 'PageView');
</script>
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
@endif
<!-- Google Analytics / GTM -->
@if(!empty($pixelGoogleId))
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ $pixelGoogleId }}');
</script>
@endif
<!-- LinkedIn Insight -->
@if(!empty($pixelLinkedinId))
<script type="text/javascript">
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
(function(l) {
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
window.lintrk.q=[]}
var s = document.getElementsByTagName("script")[0];
var b = document.createElement("script");
b.type = "text/javascript";b.async = true;
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
@endif
<!-- Premium Styling -->
<style>
body {
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
color: #f1f5f9;
font-family: 'Outfit', -apple-system, sans-serif;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
.container {
text-align: center;
padding: 2.5rem;
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
animation: fadeIn 0.6s ease-out;
}
.spinner {
position: relative;
width: 72px;
height: 72px;
margin: 0 auto 2rem;
}
.spinner-ring {
position: absolute;
width: 100%;
height: 100%;
border: 4px solid transparent;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.spinner-ring:nth-child(2) {
animation-delay: -0.3s;
border-top-color: #a855f7;
}
.spinner-ring:nth-child(3) {
animation-delay: -0.6s;
border-top-color: #ec4899;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
background: linear-gradient(to right, #818cf8, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.025em;
}
p {
font-size: 0.95rem;
color: #94a3b8;
margin: 0;
font-weight: 300;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="container">
<div class="spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
<h1>Connecting Safely</h1>
<p>Securing connection & forwarding you now...</p>
</div>
<!-- Async non-blocking redirect -->
<script>
setTimeout(function() {
window.location.replace("{!! addslashes($destination) !!}");
}, 250);
</script>
</body>
</html>

View File

@@ -11,3 +11,11 @@ Route::match(
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
Route::prefix('api/short-url')
->middleware([\Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi::class])
->group(function () {
Route::get('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'index']);
Route::post('links', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [\Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController::class, 'destroy']);
});

View File

@@ -74,6 +74,10 @@ class ShortUrlSettingsPage extends Page implements HasForms
'qr_gradient_from' => $mgr->get('qr_gradient_from', '#4f46e5'),
'qr_gradient_to' => $mgr->get('qr_gradient_to', '#06b6d4'),
'qr_gradient_type' => $mgr->get('qr_gradient_type', 'linear'),
'global_webhook_url' => $mgr->get('global_webhook_url'),
'webhook_events' => $mgr->get('webhook_events', ['visited']),
'api_keys' => $mgr->get('api_keys', []),
'api_enabled' => $mgr->get('api_enabled', false),
]);
}
@@ -537,6 +541,66 @@ class ShortUrlSettingsPage extends Page implements HasForms
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
]),
]),
// ── Developer API & Webhooks ────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_developer'))
->icon('heroicon-o-cpu-chip')
->schema([
Section::make(__('filament-short-url::default.settings_section_rest_api'))
->schema([
Toggle::make('api_enabled')
->label(__('filament-short-url::default.settings_api_enabled'))
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
->default(false)
->inline(false)
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
->schema([
TextInput::make('global_webhook_url')
->label(__('filament-short-url::default.settings_global_webhook_url'))
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
->url()
->nullable()
->columnSpanFull(),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
->multiple()
->options([
'visited' => __('filament-short-url::default.webhook_event_visited'),
'created' => __('filament-short-url::default.webhook_event_created'),
'expired' => __('filament-short-url::default.webhook_event_expired'),
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
])
->default(['visited'])
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->schema([
\Filament\Forms\Components\Repeater::make('api_keys')
->label(__('filament-short-url::default.settings_api_keys'))
->schema([
TextInput::make('name')
->label(__('filament-short-url::default.api_key_name'))
->required(),
TextInput::make('key')
->label(__('filament-short-url::default.api_key'))
->disabled()
->dehydrated()
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
])
->columns(3)
->default([]),
]),
]),
]),
])
->statePath('data');

View File

@@ -27,6 +27,7 @@ class ShortUrlForm
static::linkTab(),
static::targetingTab(),
static::trackingTab(),
static::marketingTab(),
static::qrDesignTab(),
])->columnSpanFull(),
]);
@@ -477,4 +478,43 @@ class ShortUrlForm
]),
]);
}
private static function marketingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_marketing'))
->icon('heroicon-o-megaphone')
->schema([
Section::make(__('filament-short-url::default.marketing_pixels_title'))
->description(__('filament-short-url::default.marketing_pixels_desc'))
->schema([
TextInput::make('pixel_meta_id')
->label(__('filament-short-url::default.pixel_meta'))
->placeholder('e.g., 1234567890')
->maxLength(100)
->nullable(),
TextInput::make('pixel_google_id')
->label(__('filament-short-url::default.pixel_google'))
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
->maxLength(100)
->nullable(),
TextInput::make('pixel_linkedin_id')
->label(__('filament-short-url::default.pixel_linkedin'))
->placeholder('e.g., 1234567')
->maxLength(100)
->nullable(),
])->columns(3),
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
->description(__('filament-short-url::default.marketing_webhooks_desc'))
->schema([
TextInput::make('webhook_url')
->label(__('filament-short-url::default.webhook_url'))
->placeholder('https://api.yourcrm.com/webhooks/clicks')
->url()
->maxLength(2048)
->nullable()
->columnSpanFull(),
]),
]);
}
}

View File

@@ -32,6 +32,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'2026_06_01_000004_add_targeting_and_security_to_short_urls_table',
'2026_06_01_000005_create_short_url_daily_stats_table',
'2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table',
'2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table',
])
->hasCommands([
SyncBufferedCountersCommand::class,

View File

@@ -0,0 +1,137 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ShortUrlApiController extends Controller
{
public function __construct(
private readonly ShortUrlService $service
) {}
/**
* Display a listing of short URLs.
*/
public function index(): JsonResponse
{
$links = ShortUrl::orderBy('id', 'desc')->paginate(30);
$transformed = $links->getCollection()->map(fn ($link) => $this->transformLink($link));
return response()->json([
'data' => $transformed,
'meta' => [
'current_page' => $links->currentPage(),
'last_page' => $links->lastPage(),
'per_page' => $links->perPage(),
'total' => $links->total(),
],
]);
}
/**
* Store a newly created short URL.
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'destination_url' => 'required|url|max:2048',
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
'notes' => 'nullable|string|max:1000',
'is_enabled' => 'nullable|boolean',
'redirect_status_code' => 'nullable|integer|in:301,302',
'single_use' => 'nullable|boolean',
'forward_query_params' => 'nullable|boolean',
'max_visits' => 'nullable|integer|min:1',
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'pixel_meta_id' => 'nullable|string|max:100',
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
'webhook_url' => 'nullable|url|max:2048',
]);
$shortUrl = $this->service->create($validated);
// Fire 'created' webhook if active
$this->dispatchCreatedWebhook($shortUrl);
return response()->json([
'message' => 'Short URL created successfully.',
'data' => $this->transformLink($shortUrl),
], 201);
}
/**
* Remove the specified short URL.
*/
public function destroy(int $id): JsonResponse
{
$shortUrl = ShortUrl::findOrFail($id);
$shortUrl->delete();
return response()->json([
'message' => 'Short URL deleted successfully.',
]);
}
/**
* Transform a ShortUrl model to API response array.
*/
private function transformLink(ShortUrl $link): array
{
return [
'id' => $link->id,
'destination_url' => $link->destination_url,
'url_key' => $link->url_key,
'short_url' => $link->getShortUrl(),
'is_enabled' => (bool) $link->is_enabled,
'redirect_status_code' => (int) $link->redirect_status_code,
'total_visits' => (int) $link->total_visits,
'unique_visits' => (int) $link->unique_visits,
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
'pixel_meta_id' => $link->pixel_meta_id,
'pixel_google_id' => $link->pixel_google_id,
'pixel_linkedin_id' => $link->pixel_linkedin_id,
'webhook_url' => $link->webhook_url,
'notes' => $link->notes,
'created_at' => $link->created_at->toIso8601String(),
];
}
/**
* Dispatch webhook if global or per-link webhook is active for 'created' event.
*/
private function dispatchCreatedWebhook(ShortUrl $shortUrl): void
{
$targetUrl = $shortUrl->webhook_url;
$globalUrl = config('filament-short-url.global_webhook_url');
$events = config('filament-short-url.webhook_events', []);
if (empty($targetUrl) && !empty($globalUrl) && in_array('created', $events)) {
$targetUrl = $globalUrl;
}
if (!empty($targetUrl)) {
$connection = config('filament-short-url.queue_connection', 'sync');
dispatch(new SendWebhookJob(
url: $targetUrl,
event: 'created',
payload: [
'event' => 'created',
'timestamp' => now()->toIso8601String(),
'short_url' => $this->transformLink($shortUrl),
]
)->onConnection($connection ?: 'sync'));
}
}
}

View File

@@ -141,6 +141,15 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
if (! empty($shortUrl->pixel_meta_id) || ! empty($shortUrl->pixel_google_id) || ! empty($shortUrl->pixel_linkedin_id)) {
return response(view('filament-short-url::pixel-loading', [
'destination' => $destination,
'pixelMetaId' => $shortUrl->pixel_meta_id,
'pixelGoogleId' => $shortUrl->pixel_google_id,
'pixelLinkedinId' => $shortUrl->pixel_linkedin_id,
]))->header('Content-Type', 'text/html');
}
return redirect()->away($destination, $shortUrl->redirect_status_code);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Middleware;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateShortUrlApi
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (! config('filament-short-url.api_enabled', false)) {
return response()->json([
'error' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
], 503);
}
$apiKey = $request->header('X-Api-Key');
if (!$apiKey && $auth = $request->header('Authorization')) {
if (str_starts_with(strtolower($auth), 'bearer ')) {
$apiKey = substr($auth, 7);
}
}
if (empty($apiKey)) {
return response()->json([
'error' => 'Unauthorized. API Key is missing.',
], 401);
}
$mgr = app(ShortUrlSettingsManager::class);
$keys = $mgr->get('api_keys', []);
$valid = false;
foreach ($keys as $keyObj) {
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
$valid = true;
break;
}
}
if (!$valid) {
return response()->json([
'error' => 'Unauthorized. Invalid or inactive API Key.',
], 401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Bjanczak\FilamentShortUrl\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SendWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int Max retry attempts if the webhook fails */
public int $tries = 3;
/** @var int Delay between retries (seconds) */
public int $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
public readonly string $url,
public readonly string $event,
public readonly array $payload
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
])
->post($this->url, $this->payload);
if ($response->failed()) {
Log::warning("[FilamentShortUrl] Webhook delivery returned client/server error", [
'url' => $this->url,
'event' => $this->event,
'status' => $response->status(),
]);
// Throw exception to trigger queue retry
throw new \RuntimeException("Webhook failed with status " . $response->status());
}
} catch (\Throwable $e) {
Log::warning("[FilamentShortUrl] Webhook delivery failed", [
'url' => $this->url,
'event' => $this->event,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -90,6 +90,53 @@ class TrackShortUrlVisitJob implements ShouldQueue
// Fire event for user listeners
ShortUrlVisited::dispatch($shortUrl, $visit);
// Trigger Webhook if active
$targetUrl = $shortUrl->webhook_url;
$globalUrl = config('filament-short-url.global_webhook_url');
$events = config('filament-short-url.webhook_events', []);
if (empty($targetUrl) && ! empty($globalUrl) && in_array('visited', $events)) {
$targetUrl = $globalUrl;
}
if (! empty($targetUrl)) {
dispatch(new \Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob(
url: $targetUrl,
event: 'visited',
payload: [
'event' => 'visited',
'timestamp' => now()->toIso8601String(),
'short_url' => [
'id' => $shortUrl->id,
'destination_url' => $shortUrl->destination_url,
'url_key' => $shortUrl->url_key,
'short_url' => $shortUrl->getShortUrl(),
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
'unique_visits' => (int) $shortUrl->unique_visits,
],
'visit' => [
'id' => $visit->id,
'visited_at' => $visit->visited_at->toIso8601String(),
'device_type' => $visit->device_type,
'browser' => $visit->browser,
'browser_version' => $visit->browser_version,
'operating_system' => $visit->operating_system,
'operating_system_version' => $visit->operating_system_version,
'country' => $visit->country,
'country_code' => $visit->country_code,
'city' => $visit->city,
'referer_url' => $visit->referer_url,
'referer_host' => $visit->referer_host,
'utm_source' => $visit->utm_source,
'utm_medium' => $visit->utm_medium,
'utm_campaign' => $visit->utm_campaign,
'utm_term' => $visit->utm_term,
'utm_content' => $visit->utm_content,
],
]
)->onConnection($this->connection ?: 'sync'));
}
// Optional GA4 Measurement Protocol integration
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
$this->sendGa4Hit($shortUrl, $visit);

View File

@@ -80,6 +80,10 @@ class ShortUrl extends Model
'unique_visits',
'max_visits',
'expiration_redirect_url',
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
];
/** @var array<string, string> */
@@ -186,6 +190,19 @@ class ShortUrl extends Model
if ($m->activated_at === null && $m->expires_at === null) {
$m->expiration_redirect_url = null;
}
if (empty($m->pixel_meta_id)) {
$m->pixel_meta_id = null;
}
if (empty($m->pixel_google_id)) {
$m->pixel_google_id = null;
}
if (empty($m->pixel_linkedin_id)) {
$m->pixel_linkedin_id = null;
}
if (empty($m->webhook_url)) {
$m->webhook_url = null;
}
});
static::saved(function (self $m) {

View File

@@ -76,6 +76,10 @@ class ShortUrlSettingsManager
'qr_gradient_from' => config('filament-short-url.qr_defaults.gradient_from', '#4f46e5'),
'qr_gradient_to' => config('filament-short-url.qr_defaults.gradient_to', '#06b6d4'),
'qr_gradient_type' => config('filament-short-url.qr_defaults.gradient_type', 'linear'),
'global_webhook_url' => null,
'webhook_events' => ['visited'],
'api_keys' => [],
'api_enabled' => false,
], $stored);
return $this->cache;
@@ -142,6 +146,10 @@ class ShortUrlSettingsManager
'qr_gradient_from',
'qr_gradient_to',
'qr_gradient_type',
'global_webhook_url',
'webhook_events',
'api_keys',
'api_enabled',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -292,6 +300,9 @@ class ShortUrlSettingsManager
'filament-short-url.qr_defaults.gradient_from' => $settings['qr_gradient_from'],
'filament-short-url.qr_defaults.gradient_to' => $settings['qr_gradient_to'],
'filament-short-url.qr_defaults.gradient_type' => $settings['qr_gradient_type'],
'filament-short-url.global_webhook_url' => $settings['global_webhook_url'] ?? null,
'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'],
'filament-short-url.api_enabled' => (bool) ($settings['api_enabled'] ?? false),
]);
}
}