Webhooks
Set up webhooks to receive real-time notifications from pxlpeak.
Overview
Webhooks enable real-time notifications when events occur in pxlpeak. Instead of polling the API, webhooks push data to your server instantly.
┌─────────────┐ Event ┌─────────────┐ POST ┌─────────────┐
│ pxlpeak │ ────────────▶ │ Webhook │ ────────────▶ │ Your Server │
│ Platform │ │ System │ │ Endpoint │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
Retry on failure
(up to 5 attempts)Available Webhook Events
Conversion Events
| Event | Description |
|-------|-------------|
| conversion.completed | Conversion goal achieved |
| conversion.attributed | Attribution assigned to conversion |
| conversion.value_updated | Conversion value modified |
Alert Events
| Event | Description |
|-------|-------------|
| alert.triggered | Custom alert condition met |
| alert.resolved | Alert condition no longer met |
| anomaly.detected | Automatic anomaly detection |
Data Events
| Event | Description |
|-------|-------------|
| export.completed | Data export ready for download |
| report.generated | Scheduled report created |
| segment.computed | Segment membership updated |
Integration Events
| Event | Description |
|-------|-------------|
| integration.connected | New integration authorized |
| integration.disconnected | Integration removed |
| integration.error | Integration sync failed |
Site Events
| Event | Description |
|-------|-------------|
| site.created | New site added |
| site.deleted | Site removed |
| tracking.error | Tracking issue detected |
Creating Webhooks
Via API
POST /v1/webhooksRequest Body:
{
"url": "https://your-server.com/webhooks/pxlpeak",
"events": [
"conversion.completed",
"alert.triggered",
"anomaly.detected"
],
"site_ids": ["site_aBcDeFgHiJkL"],
"secret": "whsec_your_secret_key_here",
"metadata": {
"environment": "production",
"team": "marketing"
}
}Response:
{
"data": {
"id": "wh_xxxxxxxxxxxx",
"url": "https://your-server.com/webhooks/pxlpeak",
"events": ["conversion.completed", "alert.triggered", "anomaly.detected"],
"site_ids": ["site_aBcDeFgHiJkL"],
"status": "active",
"secret": "whsec_your_secret_key_here",
"created_at": "2026-01-12T10:00:00Z"
}
}Via Dashboard
- Navigate to Settings → Webhooks
- Click Add Webhook
- Enter your endpoint URL
- Select events to subscribe to
- Copy the signing secret
Webhook Payload Structure
All webhooks follow a consistent payload structure:
{
"id": "evt_xxxxxxxxxxxx",
"type": "conversion.completed",
"created_at": "2026-01-12T14:30:00Z",
"site_id": "site_aBcDeFgHiJkL",
"data": {
// Event-specific data
},
"metadata": {
"webhook_id": "wh_xxxxxxxxxxxx",
"delivery_attempt": 1
}
}Conversion Completed
{
"id": "evt_conv_12345",
"type": "conversion.completed",
"created_at": "2026-01-12T14:30:00Z",
"site_id": "site_aBcDeFgHiJkL",
"data": {
"conversion": {
"id": "conv_xxxxxxxxxxxx",
"name": "Purchase",
"event_id": "evt_xxxxxxxxxxxx"
},
"value": 99.99,
"currency": "USD",
"user": {
"user_id": "user_12345",
"client_id": "cid_xxxxxxxxxxxx",
"email_hash": "sha256_xxxx"
},
"attribution": {
"model": "last_click",
"source": "google",
"medium": "cpc",
"campaign": "summer_sale",
"touchpoints": [
{
"source": "google",
"medium": "cpc",
"timestamp": "2026-01-10T09:00:00Z",
"credit": 1.0
}
]
},
"properties": {
"transaction_id": "TXN-12345",
"items": [
{
"item_id": "SKU-001",
"item_name": "Premium Plan",
"price": 99.99
}
]
}
}
}Alert Triggered
{
"id": "evt_alert_67890",
"type": "alert.triggered",
"created_at": "2026-01-12T14:30:00Z",
"site_id": "site_aBcDeFgHiJkL",
"data": {
"alert": {
"id": "alert_xxxxxxxxxxxx",
"name": "Conversion Drop Alert",
"description": "Conversions dropped >20% vs last week"
},
"condition": {
"metric": "conversions",
"operator": "decreased_by_percent",
"threshold": 20,
"comparison_period": "previous_week"
},
"current_value": 85,
"previous_value": 120,
"change_percent": -29.2,
"triggered_at": "2026-01-12T14:30:00Z"
}
}Anomaly Detected
{
"id": "evt_anomaly_11111",
"type": "anomaly.detected",
"created_at": "2026-01-12T14:30:00Z",
"site_id": "site_aBcDeFgHiJkL",
"data": {
"anomaly": {
"type": "traffic_spike",
"severity": "high",
"confidence": 0.95
},
"metric": "sessions",
"current_value": 15000,
"expected_value": 5000,
"expected_range": { "min": 4000, "max": 6500 },
"deviation_percent": 200,
"detected_at": "2026-01-12T14:30:00Z",
"potential_causes": [
"Viral social media post",
"News mention",
"Bot traffic"
]
}
}Export Completed
{
"id": "evt_export_22222",
"type": "export.completed",
"created_at": "2026-01-12T15:00:00Z",
"site_id": "site_aBcDeFgHiJkL",
"data": {
"export": {
"id": "exp_xxxxxxxxxxxx",
"type": "events",
"format": "csv"
},
"rows_exported": 124532,
"file_size_bytes": 15234567,
"download_url": "https://exports.pxlpeak.com/exp_xxxxxxxxxxxx.csv",
"expires_at": "2026-01-19T15:00:00Z"
}
}Verifying Webhooks
All webhook requests include a signature in the X-Pxlpeak-Signature header. Always verify this signature before processing.
Signature Format
X-Pxlpeak-Signature: t=1704985800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt: Unix timestamp when signature was generatedv1: HMAC-SHA256 signature
Verification Implementation
import { createHmac, timingSafeEqual } from 'crypto';
interface WebhookVerificationResult {
valid: boolean;
error?: string;
}
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
toleranceSeconds = 300
): WebhookVerificationResult {
// Parse signature header
const parts = signature.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key] = value;
return acc;
}, {} as Record<string, string>);
const timestamp = parseInt(parts.t, 10);
const receivedSignature = parts.v1;
if (!timestamp || !receivedSignature) {
return { valid: false, error: 'Invalid signature format' };
}
// Check timestamp tolerance (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) {
return { valid: false, error: 'Timestamp outside tolerance window' };
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
const receivedBuffer = Buffer.from(receivedSignature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (receivedBuffer.length !== expectedBuffer.length) {
return { valid: false, error: 'Signature length mismatch' };
}
const valid = timingSafeEqual(receivedBuffer, expectedBuffer);
return { valid, error: valid ? undefined : 'Signature mismatch' };
}Express.js Middleware
import express from 'express';
const app = express();
// Use raw body for signature verification
app.use('/webhooks/pxlpeak', express.raw({ type: 'application/json' }));
app.post('/webhooks/pxlpeak', (req, res) => {
const signature = req.headers['x-pxlpeak-signature'] as string;
const payload = req.body.toString();
const result = verifyWebhookSignature(
payload,
signature,
process.env.PXLPEAK_WEBHOOK_SECRET!
);
if (!result.valid) {
console.error('Webhook verification failed:', result.error);
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// Process webhook asynchronously
processWebhook(event).catch(console.error);
// Respond quickly to avoid timeout
res.status(200).json({ received: true });
});
async function processWebhook(event: WebhookEvent) {
switch (event.type) {
case 'conversion.completed':
await handleConversion(event.data);
break;
case 'alert.triggered':
await handleAlert(event.data);
break;
case 'anomaly.detected':
await handleAnomaly(event.data);
break;
default:
console.log('Unhandled event type:', event.type);
}
}Next.js API Route
// app/api/webhooks/pxlpeak/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-pxlpeak-signature');
const payload = await request.text();
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 401 }
);
}
const result = verifyWebhookSignature(
payload,
signature,
process.env.PXLPEAK_WEBHOOK_SECRET!
);
if (!result.valid) {
return NextResponse.json(
{ error: result.error },
{ status: 401 }
);
}
const event = JSON.parse(payload);
// Queue for async processing
await queueWebhookProcessing(event);
return NextResponse.json({ received: true });
}Retry Policy
Failed webhook deliveries are automatically retried with exponential backoff:
| Attempt | Delay | Total Time | |---------|-------|------------| | 1 | Immediate | 0 | | 2 | 1 minute | 1 min | | 3 | 5 minutes | 6 min | | 4 | 30 minutes | 36 min | | 5 | 2 hours | 2h 36min |
A delivery is considered failed if:
- Connection timeout (10 seconds)
- Response status code is not 2xx
- Response timeout (30 seconds)
After 5 failed attempts, the webhook is marked as failed and an email notification is sent.
Checking Webhook Status
GET /v1/webhooks/{webhook_id}/deliveriesResponse:
{
"data": [
{
"id": "del_xxxxxxxxxxxx",
"event_id": "evt_xxxxxxxxxxxx",
"event_type": "conversion.completed",
"status": "delivered",
"attempts": 1,
"response_status": 200,
"response_time_ms": 145,
"delivered_at": "2026-01-12T14:30:01Z"
},
{
"id": "del_yyyyyyyyyyyy",
"event_id": "evt_yyyyyyyyyyyy",
"event_type": "alert.triggered",
"status": "failed",
"attempts": 5,
"last_error": "Connection timeout",
"next_retry_at": null,
"failed_at": "2026-01-12T17:06:00Z"
}
]
}Manually Retry Failed Deliveries
POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retryBest Practices
1. Respond Quickly
Return a 200 response immediately and process asynchronously:
// Good: Quick response, async processing
app.post('/webhooks/pxlpeak', async (req, res) => {
// Verify signature synchronously
if (!verifySignature(req)) {
return res.status(401).send();
}
// Queue for processing
await messageQueue.add('process-webhook', req.body);
// Respond immediately
res.status(200).json({ received: true });
});
// Bad: Slow synchronous processing
app.post('/webhooks/pxlpeak', async (req, res) => {
await syncToDataWarehouse(req.body); // 5+ seconds
await notifySlack(req.body); // 2+ seconds
res.status(200).send(); // May timeout!
});2. Implement Idempotency
Webhooks may be delivered multiple times. Use the event ID to ensure idempotent processing:
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function processWebhookIdempotently(event: WebhookEvent) {
const lockKey = `webhook:processed:${event.id}`;
// Check if already processed
const alreadyProcessed = await redis.get(lockKey);
if (alreadyProcessed) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Set lock with TTL (24 hours)
await redis.set(lockKey, '1', 'EX', 86400);
// Process the webhook
await handleEvent(event);
}3. Handle Event Ordering
Events may arrive out of order. Use timestamps for ordering:
async function handleConversionUpdate(event: ConversionEvent) {
const existing = await db.conversion.findUnique({
where: { id: event.data.conversion.id }
});
// Only update if this event is newer
if (existing && new Date(existing.updated_at) > new Date(event.created_at)) {
console.log('Skipping older event');
return;
}
await db.conversion.upsert({
where: { id: event.data.conversion.id },
update: { ...event.data, updated_at: event.created_at },
create: { ...event.data, created_at: event.created_at }
});
}4. Monitor Webhook Health
Track webhook success rates and latency:
import { metrics } from './monitoring';
async function processWebhook(event: WebhookEvent) {
const start = Date.now();
try {
await handleEvent(event);
metrics.increment('webhooks.processed', {
event_type: event.type,
status: 'success'
});
} catch (error) {
metrics.increment('webhooks.processed', {
event_type: event.type,
status: 'error'
});
throw error;
} finally {
metrics.timing('webhooks.processing_time', Date.now() - start, {
event_type: event.type
});
}
}5. Use Webhook Filters
Reduce noise by filtering events at the source:
PATCH /v1/webhooks/{webhook_id}{
"filters": {
"conversion.completed": {
"min_value": 100,
"conversion_ids": ["conv_xxx", "conv_yyy"]
},
"alert.triggered": {
"severity": ["high", "critical"]
}
}
}Testing Webhooks
Send Test Event
POST /v1/webhooks/{webhook_id}/testRequest Body:
{
"event_type": "conversion.completed"
}This sends a test payload to your endpoint with "test": true in the metadata.
Local Development
Use ngrok or similar tools to test webhooks locally:
# Terminal 1: Start your local server
npm run dev
# Terminal 2: Create tunnel
ngrok http 3000
# Use the ngrok URL for your webhook endpoint
# https://abc123.ngrok.io/api/webhooks/pxlpeakWebhook Simulator
// scripts/simulate-webhook.ts
import { createHmac } from 'crypto';
const secret = process.env.PXLPEAK_WEBHOOK_SECRET!;
const url = 'http://localhost:3000/api/webhooks/pxlpeak';
const payload = {
id: 'evt_test_12345',
type: 'conversion.completed',
created_at: new Date().toISOString(),
site_id: 'site_test',
data: {
conversion: {
id: 'conv_test',
name: 'Test Conversion'
},
value: 99.99,
currency: 'USD'
},
metadata: {
test: true
}
};
const timestamp = Math.floor(Date.now() / 1000);
const payloadString = JSON.stringify(payload);
const signedPayload = `${timestamp}.${payloadString}`;
const signature = createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Pxlpeak-Signature': `t=${timestamp},v1=${signature}`
},
body: payloadString
}).then(res => {
console.log('Status:', res.status);
return res.json();
}).then(console.log);Common Use Cases
Sync Conversions to CRM
async function handleConversion(data: ConversionData) {
const { user, value, attribution } = data;
// Update CRM contact
await hubspot.contacts.update(user.email_hash, {
properties: {
last_conversion_date: new Date().toISOString(),
total_conversion_value: value,
acquisition_source: attribution.source,
acquisition_campaign: attribution.campaign
}
});
// Create deal
await hubspot.deals.create({
properties: {
dealname: `Conversion - ${data.conversion.name}`,
amount: value,
dealstage: 'closedwon',
pipeline: 'default'
},
associations: [
{ to: user.hubspot_contact_id, types: ['deal_to_contact'] }
]
});
}Send Slack Alerts
async function handleAlert(data: AlertData) {
const emoji = data.alert.severity === 'critical' ? '🚨' : '⚠️';
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `${emoji} *${data.alert.name}*`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${emoji} *${data.alert.name}*\n${data.alert.description}`
}
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Current:*\n${data.current_value}` },
{ type: 'mrkdwn', text: `*Previous:*\n${data.previous_value}` },
{ type: 'mrkdwn', text: `*Change:*\n${data.change_percent}%` }
]
}
]
})
});
}Update Data Warehouse
async function handleConversion(data: ConversionData) {
await bigquery.dataset('analytics').table('conversions').insert([
{
event_id: data.conversion.event_id,
conversion_name: data.conversion.name,
value: data.value,
currency: data.currency,
user_id: data.user.user_id,
source: data.attribution.source,
medium: data.attribution.medium,
campaign: data.attribution.campaign,
timestamp: new Date().toISOString()
}
]);
}Next: See Rate Limits to understand API usage limits and best practices.