Setting Up Your Endpoint
Before creating a webhook, you need an endpoint to receive events. Here are example implementations:
Express.js (Node.js)
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: verify against the raw bytes, not a parsed object.
// Mount a raw body parser on just the webhook route:
app.use('/webhooks/trix', express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.TRIX_WEBHOOK_SECRET;
app.post('/webhooks/trix', (req, res) => {
// 1. Verify signature (req.body is a Buffer because of express.raw)
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!verifySignature(req.body, signature, timestamp)) {
return res.status(401).send('Invalid signature');
}
// 2. Process the event
const event = JSON.parse(req.body.toString('utf8'));
console.log(`Received: ${event.event}`);
// Handle specific event types
switch (event.event) {
case 'memory.created':
handleMemoryCreated(event.data);
break;
case 'memory.deleted':
handleMemoryDeleted(event.data);
break;
default:
console.log(`Unhandled event: ${event.event}`);
}
// 3. Respond quickly with 200
res.status(200).send('OK');
});
// NOTE: Mount this route with `express.raw({type: 'application/json'})` - the
// signature must be verified against the raw request bytes, not a re-serialized object.
function verifySignature(rawBody, signatureHeader, timestamp) {
// Header shape: "t=<ts>,v1=<hex>"
const parts = Object.fromEntries(
(signatureHeader || '').split(',').map(s => s.split('=', 2))
);
const providedHex = parts.v1 || '';
// Check timestamp freshness (5 minute window)
const age = Date.now() / 1000 - parseInt(timestamp);
if (age > 300) return false;
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(providedHex),
Buffer.from(expected)
);
}
app.listen(3000);Flask (Python)
from flask import Flask, request, abort
import hmac
import hashlib
import time
import os
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('TRIX_WEBHOOK_SECRET')
@app.route('/webhooks/trix', methods=['POST'])
def handle_webhook():
# 1. Verify signature
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
if not verify_signature(request.data, signature, timestamp):
abort(401)
# 2. Process the event
event = request.get_json()
print(f"Received: {event['event']}")
# Handle specific event types
if event['event'] == 'memory.created':
handle_memory_created(event['data'])
elif event['event'] == 'memory.deleted':
handle_memory_deleted(event['data'])
# 3. Respond quickly with 200
return 'OK', 200
def verify_signature(payload, signature_header, timestamp):
# Signature header is "t=<ts>,v1=<hex>"
parts = dict(p.split('=', 1) for p in (signature_header or '').split(','))
provided_hex = parts.get('v1', '')
# Check timestamp freshness (5 minute window)
age = time.time() - int(timestamp)
if age > 300:
return False
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(provided_hex, expected)
if __name__ == '__main__':
app.run(port=3000)FastAPI (Python)
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import time
import os
app = FastAPI()
WEBHOOK_SECRET = os.environ.get('TRIX_WEBHOOK_SECRET')
@app.post('/webhooks/trix')
async def handle_webhook(request: Request):
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
body = await request.body()
if not verify_signature(body, signature, timestamp):
raise HTTPException(status_code=401, detail='Invalid signature')
event = await request.json()
print(f"Received: {event['event']}")
# Process asynchronously for long-running tasks
# background_tasks.add_task(process_event, event)
return {'status': 'ok'}
def verify_signature(payload: bytes, signature_header: str, timestamp: str) -> bool:
parts = dict(p.split('=', 1) for p in (signature_header or '').split(','))
provided_hex = parts.get('v1', '')
age = time.time() - int(timestamp)
if age > 300:
return False
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(provided_hex, expected)ASP.NET Core (C#)
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private static readonly string WebhookSecret =
Environment.GetEnvironmentVariable("TRIX_WEBHOOK_SECRET")!;
[HttpPost("trix")]
public async Task<IActionResult> HandleWebhook()
{
// 1. Read raw body for signature verification
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var signature = Request.Headers["X-Webhook-Signature"].ToString();
var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString();
if (!VerifySignature(body, signature, timestamp))
{
return Unauthorized("Invalid signature");
}
// 2. Process the event
var payload = System.Text.Json.JsonDocument.Parse(body);
var eventType = payload.RootElement.GetProperty("event").GetString();
Console.WriteLine($"Received: {eventType}");
// Handle specific event types
switch (eventType)
{
case "memory.created":
// Handle memory created
break;
case "memory.deleted":
// Handle memory deleted
break;
}
// 3. Respond quickly with 200
return Ok("OK");
}
private static bool VerifySignature(string payload, string signatureHeader, string timestamp)
{
// Signature header is "t=<ts>,v1=<hex>"
var parts = (signatureHeader ?? "").Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("v1", out var providedHex)) return false;
// Check timestamp freshness (5 minute window)
var age = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp);
if (age > 300) return false;
var signedPayload = $"{timestamp}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
var expected = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(providedHex),
Encoding.UTF8.GetBytes(expected)
);
}
}Signature Verification
Always verify webhook signatures to ensure requests are authentic and haven't been tampered with.
How Signatures Work
- Trix creates a signed payload:
timestamp.JSON_payload - Computes HMAC-SHA256 using your webhook secret
- Includes the signature and timestamp in request headers
Headers Sent
| Header | Example Value | Description |
|---|---|---|
X-Webhook-Signature | a1b2c3d4e5... | HMAC-SHA256 hex digest |
X-Webhook-Timestamp | 1705315800 | Unix timestamp (seconds) |
X-Webhook-Delivery-ID | del_abc123 | Unique delivery ID |
X-Webhook-Event | memory.created | Event type |
X-Webhook-ID | whk_xyz789 | Webhook ID |
Verification Steps
// 1. Extract headers
const signature = headers['x-webhook-signature'];
const timestamp = headers['x-webhook-timestamp'];
// 2. Check timestamp freshness (prevent replay attacks)
const age = Date.now() / 1000 - parseInt(timestamp);
if (age > 300) {
throw new Error('Timestamp too old');
}
// 3. Create signed payload
const signedPayload = `${timestamp}.${JSON.stringify(body)}`;
// 4. Compute expected signature
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// 5. Use constant-time comparison
const valid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);Security: Always use constant-time comparison (like timingSafeEqual or hmac.compare_digest) to prevent timing attacks.
Handling Duplicates
Webhooks may be delivered more than once due to retries. Use the X-Webhook-Delivery-ID header
to deduplicate events.
In-Memory Deduplication
const processedDeliveries = new Set();
function handleWebhook(req, res) {
const deliveryId = req.headers['x-webhook-delivery-id'];
// Skip if already processed
if (processedDeliveries.has(deliveryId)) {
console.log(`Duplicate: ${deliveryId}`);
return res.status(200).send('OK');
}
// Process event
processEvent(req.body);
// Mark as processed
processedDeliveries.add(deliveryId);
res.status(200).send('OK');
}Redis Deduplication
const Redis = require('ioredis');
const redis = new Redis();
async function handleWebhook(req, res) {
const deliveryId = req.headers['x-webhook-delivery-id'];
// Try to set key (NX = only if not exists)
const isNew = await redis.set(
`webhook:${deliveryId}`,
'1',
'EX', 86400, // Expire after 24 hours
'NX' // Only set if not exists
);
if (!isNew) {
console.log(`Duplicate: ${deliveryId}`);
return res.status(200).send('OK');
}
// Process event
await processEvent(req.body);
res.status(200).send('OK');
}Database Deduplication
async function handleWebhook(req, res) {
const deliveryId = req.headers['x-webhook-delivery-id'];
try {
// Insert with unique constraint on delivery_id
await db.query(`
INSERT INTO webhook_deliveries (delivery_id, processed_at)
VALUES ($1, NOW())
`, [deliveryId]);
} catch (err) {
if (err.code === '23505') { // Unique violation
console.log(`Duplicate: ${deliveryId}`);
return res.status(200).send('OK');
}
throw err;
}
await processEvent(req.body);
res.status(200).send('OK');
}Async Processing
For reliable webhook handling, respond immediately with 200 and process events asynchronously. This prevents timeouts and ensures no events are lost.
Using a Job Queue (BullMQ)
const { Queue, Worker } = require('bullmq');
const webhookQueue = new Queue('webhooks');
// Endpoint: Queue the event and respond immediately
app.post('/webhooks/trix', async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Invalid');
}
// Add to queue
await webhookQueue.add('process', {
event: req.body,
deliveryId: req.headers['x-webhook-delivery-id']
});
// Respond immediately
res.status(200).send('OK');
});
// Worker: Process events from queue
const worker = new Worker('webhooks', async (job) => {
const { event, deliveryId } = job.data;
// Deduplicate
if (await isProcessed(deliveryId)) {
return;
}
// Process based on event type
switch (event.event) {
case 'memory.created':
await syncToDatabase(event.data);
break;
case 'memory.deleted':
await removeFromDatabase(event.data.id);
break;
}
await markProcessed(deliveryId);
});Using a queue provides automatic retries, dead letter handling, and visibility into processing status.
Configuring Retries
Customize retry behavior when creating or updating webhooks:
{
"name": "My Webhook",
"url": "https://example.com/webhook",
"events": ["memory.created"],
"max_retries": 5,
"retry_delay_seconds": 120
}| Setting | Default | Range | Description |
|---|---|---|---|
max_retries | 3 | 0-10 | Number of retry attempts after initial failure |
retry_delay_seconds | 60 | 10-3600 | Base delay between retries (exponential backoff) |
Retry Behavior
- Initial attempt fails → wait
retry_delay_seconds - First retry fails → wait
retry_delay_seconds * 2 - Second retry fails → wait
retry_delay_seconds * 4 - And so on until
max_retriesis reached
Space-Scoped Webhooks
Limit events to a specific space by setting space_id:
{
"name": "Project Alpha Webhook",
"url": "https://example.com/webhook",
"events": ["memory.created", "memory.updated"],
"space_id": "spc_project_alpha"
}This webhook will only receive events for memories in the "Project Alpha" space.
If space_id is not set, the webhook receives events from all spaces in your account.
Custom Headers
Add custom headers to webhook requests for authentication with your endpoint:
{
"name": "Authenticated Webhook",
"url": "https://api.example.com/webhook",
"events": ["memory.created"],
"headers": {
"Authorization": "Bearer your-api-token",
"X-Custom-Header": "custom-value"
}
}Testing Webhooks
Local Development
Use ngrok or similar tools to expose your local server:
# Start your local server
node server.js
# In another terminal, expose it
ngrok http 3000
# Use the ngrok URL when creating your webhook
# https://abc123.ngrok.io/webhooks/trixTest Endpoint
Use the test endpoint to verify your setup:
curl -X POST https://api.trixdb.com/v1/webhooks/YOUR_WEBHOOK_ID/test \
-H "Authorization: Bearer YOUR_API_KEY"Manual Testing
Simulate a webhook delivery locally:
const crypto = require('crypto');
const secret = 'your_webhook_secret';
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = JSON.stringify({
id: 'evt_test',
event: 'memory.created',
data: { id: 'mem_123', content: 'Test memory' }
});
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
// Make request to your local endpoint
fetch('http://localhost:3000/webhooks/trix', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': timestamp,
'X-Webhook-Delivery-ID': 'test_123',
'X-Webhook-Event': 'memory.created'
},
body: payload
});Best Practices
- Respond quickly (under 30 seconds) - Return 200 immediately, then process asynchronously. Long-running handlers may timeout.
- Always verify signatures - Never trust unverified payloads. Check both signature and timestamp.
- Handle duplicates - Use the delivery ID to deduplicate. Events may be delivered multiple times.
- Use a queue for processing - For high-volume webhooks, queue events and process with workers.
- Monitor failures - Check delivery history regularly. Set up alerts for repeated failures.
- Rotate secrets periodically - Update webhook secrets and deploy new verification code before removing the old secret.
- Use HTTPS only - Webhook URLs must use HTTPS for security.
- Log everything - Log incoming webhooks (with sensitive data redacted) for debugging.
Troubleshooting
Common Issues
| Problem | Cause | Solution |
|---|---|---|
| Signature verification fails | Wrong secret or payload mismatch | Check secret matches, ensure raw body is used |
| Timestamp validation fails | Server clock drift or old requests | Sync server time with NTP |
| Timeouts | Handler takes too long | Process asynchronously, return 200 quickly |
| Missing events | Wrong event types subscribed | Check webhook configuration for event types |
| Duplicate processing | Retries without deduplication | Implement idempotency using delivery ID |
Debugging
# Check webhook configuration
curl https://api.trixdb.com/v1/webhooks/YOUR_WEBHOOK_ID \
-H "Authorization: Bearer YOUR_API_KEY"
# View recent deliveries
curl "https://api.trixdb.com/v1/webhooks/YOUR_WEBHOOK_ID/deliveries?limit=10" \
-H "Authorization: Bearer YOUR_API_KEY"
# Check for failed deliveries
curl "https://api.trixdb.com/v1/webhooks/YOUR_WEBHOOK_ID/deliveries?status=failed" \
-H "Authorization: Bearer YOUR_API_KEY"