Webhooks
Receive real-time notifications when events occur in your LinkForty account via HTTP callbacks to your server.
Webhooks are created and managed in the dashboard under Settings → Webhooks. Your application's role is to receive deliveries at the URL you register and verify their signatures, as shown below. (API keys are scoped to links, analytics, templates, and projects — see Authentication.)
Overview
Webhooks allow you to build integrations that subscribe to events in LinkForty. When an event occurs (like a link click or app install), LinkForty sends an HTTP POST request to your configured webhook URL with event data.
Benefits:
- Real-time - Get notified immediately when events happen
- Efficient - No need to poll APIs for updates
- Secure - HMAC signature verification ensures authenticity
- Reliable - Automatic retries with exponential backoff
- Flexible - Subscribe to specific events you care about
Event Types
LinkForty supports four event types:
| Event | Description | Triggered When |
|---|---|---|
click_event | User clicked a link | Someone clicks your short link |
install_event | App installed | User installs your app (attributed to a link) |
conversion_event | Conversion tracked | User completes a conversion event in your app (attributed installs only) |
sdk_event | SDK event tracked | An in-app event is tracked via the SDK (attributed installs only) |
Creating a Webhook
Create and manage webhooks in the dashboard under Settings → Webhooks:
- Click "Create Webhook"
- Configure:
- Name: Descriptive name (e.g., "Slack Notifications")
- URL: Your endpoint URL (must be HTTPS)
- Events: Select which events to receive —
click_event,install_event,conversion_event,sdk_event - Custom Headers: Optional headers to include on each delivery
- Retries and timeout: How aggressively to retry failed deliveries
- Click "Create"
- Save the signing secret (
whsec_…) — you'll need it to verify signatures. It's also available later in the webhook's details view.
A webhook belongs to the workspace, not to the person who created it: it receives events for every link in the workspace, whoever created the link and whether it was made in the dashboard, through the API, or by an SDK. If two webhooks in the same workspace point at the same URL, that URL receives each event twice.
Webhook Payload
Every webhook delivery includes:
Headers
| Header | Description | Example |
|---|---|---|
Content-Type | Always application/json | application/json |
X-LinkForty-Signature | HMAC SHA-256 signature | sha256=a1b2c3... |
X-LinkForty-Event | Event type | click_event |
X-LinkForty-Event-ID | Unique event identifier | evt_abc123 |
User-Agent | LinkForty webhook agent | LinkForty-Webhook/1.0 |
Body
Every delivery has the same envelope; data depends on the event.
{
"event": "click_event",
"event_id": "0b9d7c1e-5f3a-4c2b-9a8e-1d2c3b4a5f60",
"timestamp": "2026-03-15T14:23:12.000Z",
"data": { }
}
Event Payloads
Click Event
{
"event": "click_event",
"event_id": "0b9d7c1e-5f3a-4c2b-9a8e-1d2c3b4a5f60",
"timestamp": "2026-03-15T14:23:12.000Z",
"data": {
"id": "0b9d7c1e-5f3a-4c2b-9a8e-1d2c3b4a5f60",
"linkId": "6a1f0c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"clickedAt": "2026-03-15T14:23:12.000Z",
"ipAddress": "203.0.113.24",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) ...",
"deviceType": "ios",
"platform": "iOS",
"countryCode": "US",
"countryName": "United States",
"region": "California",
"city": "San Francisco",
"latitude": 37.7749,
"longitude": -122.4194,
"timezone": "America/Los_Angeles",
"utmSource": "instagram",
"utmMedium": "social",
"utmCampaign": "spring-sale",
"referrer": "https://www.instagram.com/"
}
}
deviceType is ios, android or web.
Install Event
Sent when a new install is attributed to a link. deepLinkData is the same object the SDK receives on first launch, including any query parameters the click carried in customParameters.
{
"event": "install_event",
"event_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"timestamp": "2026-03-15T15:45:30.000Z",
"data": {
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"linkId": "6a1f0c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"fingerprintHash": "3f2a…",
"confidenceScore": 90,
"installedAt": "2026-03-15T15:45:30.000Z",
"deepLinkData": {
"shortCode": "spring24",
"deepLinkPath": "/products/42",
"customParameters": { "promo": "spring", "fbclid": "IwAR..." },
"isDeferred": true
},
"ipAddress": "203.0.113.24",
"userAgent": "MyApp/1.0 iOS/18.0",
"platform": "iOS"
}
}
confidenceScore runs from 0 to 100.
Conversion Event and SDK Event
Both are sent for in-app events tracked through the SDK on an install that was attributed to a link, with the same data:
{
"event": "conversion_event",
"event_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"timestamp": "2026-03-15T16:20:00.000Z",
"data": {
"eventId": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"installId": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"linkId": "6a1f0c2e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"eventName": "purchase",
"eventData": { "productId": "prod_123", "revenue": 29.99, "currency": "USD" },
"timestamp": "2026-03-15T16:20:00.000Z"
}
}
Events from installs that weren't attributed to a link aren't sent to webhooks.
Verifying Webhook Signatures
Always verify webhook signatures to ensure requests are from LinkForty.
How It Works
LinkForty signs every webhook with HMAC SHA-256:
- Create HMAC using your webhook secret
- Hash the raw request body
- Compare with
X-LinkForty-Signatureheader
Node.js/TypeScript Example
import crypto from 'crypto';
import express from 'express';
const WEBHOOK_SECRET = 'whsec_your_secret_here';
app.post('/webhooks/linkforty', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-linkforty-signature'] as string;
const body = req.body.toString('utf8');
// Verify signature
if (!verifyWebhookSignature(body, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Parse and handle event
const event = JSON.parse(body);
handleWebhookEvent(event);
res.status(200).send('OK');
});
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
// Remove 'sha256=' prefix
const receivedSignature = signature.replace('sha256=', '');
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
function handleWebhookEvent(event: any) {
console.log(`Received ${event.event}:`, event.data);
switch (event.event) {
case 'click_event':
handleClickEvent(event.data);
break;
case 'install_event':
handleInstallEvent(event.data);
break;
case 'conversion_event':
handleConversionEvent(event.data);
break;
case 'sdk_event':
handleSdkEvent(event.data);
break;
default:
console.log('Unknown event type:', event.event);
}
}
Python Example
import hmac
import hashlib
import json
from flask import Flask, request, Response
WEBHOOK_SECRET = 'whsec_your_secret_here'
app = Flask(__name__)
@app.route('/webhooks/linkforty', methods=['POST'])
def webhook():
signature = request.headers.get('X-LinkForty-Signature', '')
body = request.get_data()
# Verify signature
if not verify_webhook_signature(body, signature, WEBHOOK_SECRET):
return Response('Invalid signature', status=401)
# Parse and handle event
event = json.loads(body)
handle_webhook_event(event)
return Response('OK', status=200)
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
# Remove 'sha256=' prefix
received_signature = signature.replace('sha256=', '')
# Compute expected signature
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(received_signature, expected_signature)
def handle_webhook_event(event):
print(f"Received {event['event']}: {event['data']}")
if event['event'] == 'click_event':
handle_click_event(event['data'])
elif event['event'] == 'install_event':
handle_install_event(event['data'])
elif event['event'] == 'conversion_event':
handle_conversion_event(event['data'])
elif event['event'] == 'sdk_event':
handle_sdk_event(event['data'])
Go Example
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
)
const WebhookSecret = "whsec_your_secret_here"
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-LinkForty-Signature")
body, _ := io.ReadAll(r.Body)
// Verify signature
if !verifyWebhookSignature(body, signature, WebhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse and handle event
var event WebhookEvent
json.Unmarshal(body, &event)
handleWebhookEvent(event)
w.WriteHeader(http.StatusOK)
}
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
// Remove 'sha256=' prefix
receivedSignature := strings.TrimPrefix(signature, "sha256=")
// Compute expected signature
h := hmac.New(sha256.New, []byte(secret))
h.Write(payload)
expectedSignature := hex.EncodeToString(h.Sum(nil))
// Constant-time comparison
return hmac.Equal([]byte(receivedSignature), []byte(expectedSignature))
}
type WebhookEvent struct {
Event string `json:"event"`
EventID string `json:"event_id"`
Timestamp string `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
func handleWebhookEvent(event WebhookEvent) {
switch event.Event {
case "click_event":
handleClickEvent(event.Data)
case "install_event":
handleInstallEvent(event.Data)
case "conversion_event":
handleConversionEvent(event.Data)
case "sdk_event":
handleSdkEvent(event.Data)
}
}
Testing Webhooks
Test from Dashboard
- Go to Settings → Webhooks
- Select your webhook
- Click "Send Test Event"
- Check your server logs, or the webhook's delivery history
Test Payload:
{
"event": "click_event",
"event_id": "test_evt_123",
"timestamp": "2024-03-15T14:00:00Z",
"data": {
"id": "test_click_123",
"linkId": "test-link-id",
"clickedAt": "2024-03-15T14:00:00Z",
"deviceType": "test",
"platform": "test",
"test": true
}
}
Test Locally with ngrok
-
Install ngrok:
npm install -g ngrok -
Start your local server:
node webhook-server.js
# Server running on http://localhost:3000 -
Create ngrok tunnel:
ngrok http 3000 -
Copy the HTTPS URL:
Forwarding https://abc123.ngrok.io -> http://localhost:3000 -
Create a webhook with the ngrok URL: in Settings → Webhooks, create a webhook pointing at your HTTPS ngrok URL (e.g.
https://abc123.ngrok.io/webhooks/linkforty) and subscribe it to the events you want to test. -
Trigger events by clicking your links
-
See events in your terminal in real-time
Testing Script
// test-webhook.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = 3000;
const SECRET = process.env.WEBHOOK_SECRET || 'your-secret-here';
app.post('/webhooks/linkforty', express.raw({ type: 'application/json' }), (req, res) => {
console.log('\n=== WEBHOOK RECEIVED ===');
console.log('Headers:', req.headers);
const signature = req.headers['x-linkforty-signature'];
const body = req.body.toString('utf8');
// Verify signature
const expectedSig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const receivedSig = signature.replace('sha256=', '');
console.log('\n=== SIGNATURE VERIFICATION ===');
console.log('Expected: ', expectedSig);
console.log('Received: ', receivedSig);
console.log('Valid: ', expectedSig === receivedSig);
if (expectedSig !== receivedSig) {
console.log('\nINVALID SIGNATURE');
return res.status(401).send('Invalid signature');
}
// Parse event
const event = JSON.parse(body);
console.log('\n=== EVENT DATA ===');
console.log('Event: ', event.event);
console.log('Event ID: ', event.event_id);
console.log('Timestamp: ', event.timestamp);
console.log('Data: ', JSON.stringify(event.data, null, 2));
console.log('\nWEBHOOK PROCESSED\n');
res.status(200).send('OK');
});
app.listen(PORT, () => {
console.log(`Webhook server listening on http://localhost:${PORT}`);
console.log(`Secret: ${SECRET}\n`);
});
Run it:
WEBHOOK_SECRET=your_secret_here node test-webhook.js
Retry Behavior
LinkForty automatically retries failed webhook deliveries with exponential backoff.
Retry Schedule
| Attempt | Delay | Total Time Elapsed |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 1 second | 1s |
| 3 | 2 seconds | 3s |
| 4 | 4 seconds (if retryCount > 3) | 7s |
| 5 | 8 seconds (if retryCount > 4) | 15s |
Default: 3 retries (configurable 1-10)
When Retries Occur
Webhooks are retried if:
- HTTP status code ≠ 200
- Connection timeout (default: 10 seconds)
- Network error
- DNS resolution failure
When Retries Stop
Retries stop when:
- Webhook returns HTTP 200
- Maximum retry count reached
- Timeout exceeded on all attempts
Configuring Retries
Set the retry count and timeout on each webhook in Settings → Webhooks when you create or edit it.
Delivery History
Every delivery attempt is recorded, including test events and retries. Open Settings → Webhooks and choose View deliveries on a webhook to see each attempt: the event, the HTTP status your endpoint returned (or the error, if it could not be reached), and the attempt number. The history is kept for 30 days.
Best Practices
1. Respond Quickly
Return 200 OK immediately, process asynchronously:
Good:
app.post('/webhooks/linkforty', async (req, res) => {
// Verify signature
if (!verifySignature(req.body, req.headers['x-linkforty-signature'])) {
return res.status(401).send('Invalid signature');
}
// Respond immediately
res.status(200).send('OK');
// Process async (don't await)
processWebhookAsync(req.body).catch(console.error);
});
Bad:
app.post('/webhooks/linkforty', async (req, res) => {
// Don't do slow operations before responding
await database.processEvent(req.body); // Slow
await sendEmail(req.body); // Slow
res.status(200).send('OK');
});
2. Idempotency
Handle duplicate events (use event_id):
async function handleWebhookEvent(event: WebhookEvent) {
// Check if already processed
const exists = await db.checkEventProcessed(event.event_id);
if (exists) {
console.log('Event already processed:', event.event_id);
return;
}
// Process event
await processEvent(event);
// Mark as processed
await db.markEventProcessed(event.event_id);
}
3. Use Queues for Processing
import { Queue } from 'bull';
const webhookQueue = new Queue('webhooks');
app.post('/webhooks/linkforty', (req, res) => {
// Verify signature
if (!verifySignature(req.body, req.headers['x-linkforty-signature'])) {
return res.status(401).send('Invalid signature');
}
// Add to queue
webhookQueue.add(req.body);
res.status(200).send('OK');
});
// Process queue
webhookQueue.process(async (job) => {
const event = job.data;
await handleWebhookEvent(event);
});
4. Log Everything
app.post('/webhooks/linkforty', (req, res) => {
const startTime = Date.now();
// Log incoming webhook
console.log({
type: 'webhook_received',
event_id: req.headers['x-linkforty-event-id'],
event_type: req.headers['x-linkforty-event'],
timestamp: new Date().toISOString(),
});
// Verify and process...
// Log completion
console.log({
type: 'webhook_processed',
event_id: req.headers['x-linkforty-event-id'],
duration_ms: Date.now() - startTime,
});
res.status(200).send('OK');
});
5. Monitor Failures
Check each webhook's delivery log in Settings → Webhooks regularly to catch endpoints that are timing out or returning errors, and use the dashboard's "Send test event" to confirm a fix.
Use Cases
1. Slack Notifications
async function handleClickEvent(data: ClickEventData) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🔗 New click on ${data.shortCode} from ${data.city}, ${data.countryCode}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Link Clicked*\n• Short Code: \`${data.shortCode}\`\n• Location: ${data.city}, ${data.countryCode}\n• Device: ${data.deviceType} (${data.platform})\n• Campaign: ${data.utmCampaign || 'None'}`
}
}
]
})
});
}
2. CRM Integration
async function handleInstallEvent(data: InstallEventData) {
// Add to CRM
await crmClient.createLead({
source: 'linkforty',
campaign: data.utmCampaign,
platform: data.platform,
attributedTo: data.shortCode,
installedAt: data.installedAt,
});
}
3. Analytics Pipeline
async function handleConversionEvent(data: ConversionEventData) {
// Send to analytics
await analyticsClient.track({
userId: data.userId,
event: data.eventName,
properties: {
value: data.eventValue,
currency: data.currency,
linkId: data.linkId,
...data.properties,
},
});
}
Troubleshooting
Webhooks Not Receiving Events
Check:
- Delivery history - View deliveries on the webhook shows whether LinkForty sent the event and what your endpoint answered
- Webhook is active - Verify
isActive: true - Events subscribed - Ensure you've selected the right events
- URL is reachable - Must be public HTTPS
- Server is responding - Return 200 within timeout
- Firewall rules - Allow LinkForty IPs (if applicable)
Signature Verification Failing
Common Issues:
- Using wrong secret
- Modifying request body before verification
- Not using raw body (express requires
express.raw()) - Incorrect HMAC algorithm (must be SHA-256)
Debug:
console.log('Received Signature:', req.headers['x-linkforty-signature']);
console.log('Raw Body:', req.body.toString('utf8'));
console.log('Expected Signature:', crypto.createHmac('sha256', secret).update(req.body).digest('hex'));
Timeouts
Increase the timeout for the webhook in Settings → Webhooks, or respond faster from your endpoint:
- Return
200immediately - Process the payload asynchronously
- Use a queue
Security
- Always verify signatures - Never trust unverified requests
- Use HTTPS - Webhooks must use HTTPS URLs
- Keep secrets safe - Store in environment variables
- Rotate secrets - If compromised, regenerate
- Whitelist IPs - Optionally restrict to LinkForty IPs
- Rate limit - Protect your endpoint from abuse
Next Steps
- Set up your first webhook
- Test with ngrok locally
- Integrate with your systems
- Monitor delivery logs