Webhooks
Receive real-time notifications when events happen in your workspace.
Plan requirement: Webhooks are available on Pro plans and above.
Overview
Webhooks allow you to receive HTTP POST notifications when specific events occur in your workspace, such as when a link is clicked or created. This enables real-time integrations with your own systems.
Webhook Events
Subscribe to any combination of the following events:
| Event | Description |
|---|---|
link.clicked | Triggered when a short link is clicked |
link.created | Triggered when a new link is created |
link.updated | Triggered when a link is updated |
link.deleted | Triggered when a link is deleted |
link.expired | Triggered when a link expires |
qr.scanned | Triggered when a QR code is scanned |
Creating a Webhook
Register a webhook endpoint to start receiving events:
Note about examples: All code examples on this page use placeholder values. Replace snip_your_api_key_here with your actual API key, and{workspaceId}, {webhookId} with real IDs from your workspace. Never use example values in production.
curl -X POST https://api.sniplink.co/v1/workspaces/{workspaceId}/webhooks \
-H "X-API-Key: snip_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/sniplink",
"events": ["link.clicked", "link.created"],
"description": "Production webhook endpoint"
}'Response:
{
"webhook": {
"id": "wh_xxxxxxxxxxxxx",
"url": "https://your-server.com/webhooks/sniplink",
"description": "Production webhook endpoint",
"events": ["link.clicked", "link.created"],
"isActive": true,
"createdAt": "2025-01-15T10:30:00.000Z"
},
"secret": "abc123def456..."
}Important: The webhook secret is only shown once at creation time. Store it securely - you will need it to verify webhook signatures.
Webhook Payload
All webhook payloads follow this structure:
{
"id": "evt_xxxxxxxxxxxxx",
"type": "link.clicked",
"timestamp": "2025-01-15T10:30:00.000Z",
"workspaceId": "ws_xxxxxxxxxxxxx",
"data": {
"linkId": "lnk_xxxxxxxxxxxxx",
"slug": "my-link",
"destination": "https://example.com",
"domain": "snip.link",
"clickedAt": "2025-01-15T10:30:00.000Z",
"country": "US",
"city": "New York",
"device": "mobile",
"browser": "Chrome",
"os": "iOS",
"referrer": "https://twitter.com",
"isBot": false
}
}link.expired Event Payload
When a link with an expiration date passes its expiry time, the link.expired event is emitted:
{
"id": "evt_xxxxxxxxxxxxx",
"type": "link.expired",
"timestamp": "2025-01-15T10:30:00.000Z",
"workspaceId": "ws_xxxxxxxxxxxxx",
"data": {
"link": {
"id": "lnk_xxxxxxxxxxxxx",
"slug": "my-promo",
"url": "https://example.com/promo",
"title": "Limited Time Offer",
"domain": "snip.link",
"expiresAt": "2025-01-15T10:00:00.000Z"
},
"expiredAt": "2025-01-15T10:05:00.000Z"
}
}Note: The link.expired event is checked every 5 minutes. The expiredAt field indicates when the event was emitted (when the system detected the expiration), which may be up to 5 minutes after the actual expiresAt time. This event is only emitted once per link, and the link is automatically disabled after the event is sent. Use this event to trigger cleanup actions, notify users, or update your records when time-limited links expire.
Webhook Headers
Each webhook request includes these headers:
| Header | Description |
|---|---|
X-Sniplink-Webhook-Signature | HMAC-SHA256 signature for payload verification |
X-Sniplink-Webhook-Timestamp | Unix timestamp when the webhook was sent |
X-Sniplink-Event-Type | The event type (e.g., link.clicked) |
X-Sniplink-Webhook-Id | Unique delivery ID for deduplication |
Content-Type | Always application/json |
Verifying Webhook Signatures
Always verify webhook signatures to ensure requests come from SnipLink. The signature is computed as: v1=HMAC-SHA256(timestamp.payload, secret)
Node.js Example
import crypto from 'crypto';
function verifyWebhookSignature(payload, signature, timestamp, secret) {
// Check timestamp is recent (within 30 seconds)
// SECURITY: Tight window prevents replay attacks
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > 30) {
return false; // Replay attack protection
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = 'v1=' + crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js example
app.post('/webhooks/sniplink', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-sniplink-webhook-signature'];
const timestamp = parseInt(req.headers['x-sniplink-webhook-timestamp']);
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, timestamp, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
console.log('Received event:', event.type, event.data);
// Process the event...
res.status(200).send('OK');
});Python Example
import hmac
import hashlib
import time
def verify_webhook_signature(payload: str, signature: str, timestamp: int, secret: str) -> bool:
# Check timestamp is recent (within 30 seconds)
# SECURITY: Tight window prevents replay attacks
current_time = int(time.time())
if abs(current_time - timestamp) > 30:
return False # Replay attack protection
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_signature = 'v1=' + hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(signature, expected_signature)
# Flask example
@app.route('/webhooks/sniplink', methods=['POST'])
def webhook():
signature = request.headers.get('X-Sniplink-Webhook-Signature')
timestamp = int(request.headers.get('X-Sniplink-Webhook-Timestamp'))
payload = request.get_data(as_text=True)
if not verify_webhook_signature(payload, signature, timestamp, WEBHOOK_SECRET):
return 'Invalid signature', 401
event = request.json
print(f"Received event: {event['type']}")
# Process the event...
return 'OK', 200Timestamp Requirements
Proper timestamp validation is critical for webhook security. SnipLink uses a strict 30-second validation window to prevent replay attacks.
Critical: 30-Second Validation Window
Webhooks must be verified within 30 seconds of the timestamp in theX-Sniplink-Webhook-Timestampheader. This tight window is essential for replay attack protection.
Validation Rules
| Rule | Value | Action |
|---|---|---|
| Max Age | 30 seconds | Reject webhooks older than 30 seconds |
| Clock Skew Tolerance | 5 seconds | Reject webhooks more than 5s in the future |
| Deduplication | X-Sniplink-Webhook-Id | Use delivery ID to detect duplicates |
Why 30 Seconds?
The 30-second window balances security and reliability:
- Short enough to prevent replay attacks where an attacker captures and re-sends a valid webhook
- Long enough to handle normal network latency and processing delays
- Combined with nonce tracking - SnipLink also tracks webhook delivery IDs to prevent replay even within the valid window
Retry Behavior
If your endpoint returns a non-2xx status code or times out, SnipLink will automatically retry the delivery with exponential backoff. The retry schedule uses a 4x exponential factor:
| Attempt | Delay After Previous | Total Time Elapsed |
|---|---|---|
| 1 | Immediate | 0 seconds |
| 2 | 2 seconds | 2 seconds |
| 3 | 8 seconds | 10 seconds |
| 4 | 32 seconds | 42 seconds |
| 5 (final) | 128 seconds (~2 min) | ~2 min 50 sec |
Formula: Each retry delay is calculated as 2 seconds * 4^(attempt-2). This fast exponential backoff ensures quick recovery from transient failures while avoiding overwhelming your endpoint.
After 5 failed attempts, the delivery is marked as "exhausted". After 10 consecutive failed deliveries across all events, the webhook endpoint is automatically disabled to protect your integration.
Managing Webhooks
List Webhooks
GET /v1/workspaces/{workspaceId}/webhooksUpdate a Webhook
PATCH /v1/workspaces/{workspaceId}/webhooks/{webhookId}
{
"url": "https://new-url.com/webhooks",
"events": ["link.clicked"],
"isActive": true
}Rotate Secret
If your webhook secret is compromised, rotate it immediately:
POST /v1/workspaces/{workspaceId}/webhooks/{webhookId}/rotate-secretTest a Webhook
Send a test event to verify your endpoint is configured correctly:
POST /v1/workspaces/{workspaceId}/webhooks/{webhookId}/testView Delivery History
GET /v1/workspaces/{workspaceId}/webhooks/{webhookId}/deliveries?limit=50&status=failedRetry a Failed Delivery
POST /v1/workspaces/{workspaceId}/webhooks/{webhookId}/deliveries/{deliveryId}/retryRe-enable a Disabled Webhook
POST /v1/workspaces/{workspaceId}/webhooks/{webhookId}/enableData Processing & Privacy
Understanding how webhook data is processed and what privacy considerations apply is essential for GDPR/CCPA compliance.
Data Included in Webhooks
Webhook payloads contain link and click event data. Here's what's included for each event type:
link.clicked / qr.scanned Events
| Field | Description | Privacy Note |
|---|---|---|
country | ISO country code | Derived from IP, anonymized |
city | City name (approximate) | Derived from IP, anonymized |
device | Device type (mobile/desktop/tablet) | Derived from User-Agent |
browser | Browser name | Derived from User-Agent |
os | Operating system | Derived from User-Agent |
referrer | Referring URL domain only | Full path stripped for privacy |
isBot | Bot detection flag | No personal data |
Privacy by design: SnipLink never sends raw IP addresses in webhook payloads. All geographic data is derived from IP addresses using anonymization techniques before transmission.
IP Address Handling
For analytics purposes, SnipLink processes IP addresses as follows:
- IP addresses are immediately processed at the edge to extract geographic location
- The original IP is hashed using a rotating daily salt before any storage
- Only anonymized identifiers are used for unique visitor counting
- Raw IP addresses are never included in webhook payloads or stored long-term
Data Retention
Webhook delivery records are retained for troubleshooting purposes:
- Delivery logs: 30 days (includes status, timing, error messages)
- Payload data: 7 days (for retry functionality)
- Click events: Varies by plan (Free: 30 days, Pro: 1 year, Business: 2 years)
Processing Recommendations
When processing webhook data, consider these privacy-focused practices:
- Don't store more than needed - Only persist the fields required for your use case
- Aggregate data - For analytics, aggregate data rather than storing individual events
- Set retention policies - Implement automated data cleanup in your systems
- Document data flows - For GDPR compliance, document how webhook data flows through your systems
// Example: Privacy-focused webhook processing
app.post('/webhooks/sniplink', async (req, res) => {
const event = req.body;
if (event.type === 'link.clicked') {
// Good: Aggregate rather than store individual events
await analytics.increment('clicks', {
linkId: event.data.linkId,
country: event.data.country, // Already anonymized
device: event.data.device,
date: new Date().toISOString().split('T')[0]
});
// Avoid: Storing full event payloads long-term
// await db.clickEvents.create({ data: event });
}
res.status(200).send('OK');
});Security Requirements
- HTTPS required: All webhook endpoints must use HTTPS for secure transmission.
- No internal IPs: Webhook URLs cannot point to private/internal IP addresses (SSRF protection).
- 30 second timeout: Your endpoint must respond within 30 seconds.
- Respond with 2xx: Return a 2xx status code to acknowledge receipt.
- Idempotency: Use the
X-Sniplink-Webhook-Idheader for deduplication in case of retries.
Best Practices
- Always verify signatures - Never trust webhook data without verifying the HMAC signature.
- Respond quickly - Return 200 immediately and process asynchronously to avoid timeouts.
- Handle duplicates - Use event IDs for idempotency as webhooks may be retried.
- Log delivery IDs - Store the webhook ID for debugging and support requests.
- Monitor failures - Set up alerts for webhook delivery failures in your dashboard.