What You’ll Learn
- Understand Peanuts’ push-based architecture
- Build custom integrations using webhooks
- Structure data for programmatic access
- Design patterns for reliable integrations
Architecture Overview
Peanuts uses a push-based architecture rather than a traditional REST API:
Why push-based? Real-time updates, no polling overhead, and simpler integration for most use cases.
Webhook as API
Your webhook endpoint becomes your API receiver. Structure your handler to process different event types:
interface WebhookEvent {
event: 'entry.created' | 'entry.updated' | 'entry.deleted';
timestamp: string;
helper: {
id: string;
name: string;
command: string;
};
entry: {
id: string;
created_at: string;
data: Record<string, unknown>;
};
user: {
id: string;
};
}
// Express handler
app.post('/api/peanuts', (req, res) => {
const event: WebhookEvent = req.body;
switch (event.event) {
case 'entry.created':
handleNewEntry(event);
break;
case 'entry.updated':
handleUpdatedEntry(event);
break;
case 'entry.deleted':
handleDeletedEntry(event);
break;
}
res.status(200).json({ processed: true });
});
Type Definitions
Use these TypeScript types for type-safe integrations:
// Core types
interface HelperInfo {
id: string;
name: string;
command: string;
}
interface EntryData {
id: string;
created_at: string;
data: {
[fieldId: string]: string | number | boolean | string[];
};
}
interface WebhookPayload {
event: string;
timestamp: string;
helper: HelperInfo;
entry: EntryData;
user: { id: string };
}
// Signature verification
function verifySignature(
payload: string,
signature: string,
secret: string
): boolean {
const crypto = require('crypto');
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Integration Patterns
Pattern 1: Data Sync
Sync Peanuts entries to your database in real-time:
async function handleNewEntry(event: WebhookEvent) {
const { entry, helper } = event;
// Map Peanuts fields to your schema
const record = {
external_id: entry.id,
source: 'peanuts',
helper_name: helper.name,
amount: entry.data.amount,
category: entry.data.category,
description: entry.data.description,
logged_at: entry.created_at,
synced_at: new Date().toISOString()
};
await db.expenses.upsert({
where: { external_id: entry.id },
create: record,
update: record
});
}
Pattern 2: Notifications
Send alerts based on entry conditions:
async function handleNewEntry(event: WebhookEvent) {
const { entry, helper } = event;
// High-value expense alert
if (helper.command === '/expenses' && entry.data.amount > 500) {
await slack.send({
channel: '#finance-alerts',
text: `🚨 High expense: $${entry.data.amount} - ${entry.data.description}`
});
}
// Task deadline warning
if (helper.command === '/tasks' && entry.data.due_date) {
const dueDate = new Date(entry.data.due_date);
const now = new Date();
const daysUntilDue = Math.ceil((dueDate - now) / (1000 * 60 * 60 * 24));
if (daysUntilDue <= 1) {
await sendUrgentNotification(entry.data.title);
}
}
}
Pattern 3: Aggregation Service
Build dashboards by aggregating webhook data:
// In-memory aggregation (use Redis for production)
const dailyStats = new Map<string, number>();
async function handleNewEntry(event: WebhookEvent) {
if (event.helper.command !== '/expenses') return;
const date = event.entry.created_at.split('T')[0];
const amount = Number(event.entry.data.amount) || 0;
const current = dailyStats.get(date) || 0;
dailyStats.set(date, current + amount);
// Expose via API
console.log(`Daily total for ${date}: $${dailyStats.get(date)}`);
}
// Dashboard endpoint
app.get('/api/dashboard', (req, res) => {
res.json({
daily_totals: Object.fromEntries(dailyStats),
total: [...dailyStats.values()].reduce((a, b) => a + b, 0)
});
});
Building a Custom Client
Create a client library for your integration:
class PeanutsClient {
private webhookSecret: string;
private handlers: Map<string, Function[]> = new Map();
constructor(secret: string) {
this.webhookSecret = secret;
}
// Register event handlers
on(event: string, handler: Function) {
const existing = this.handlers.get(event) || [];
this.handlers.set(event, [...existing, handler]);
}
// Verify and process incoming webhooks
async handleWebhook(req: Request): Promise<boolean> {
const signature = req.headers['x-peanuts-signature'] as string;
const payload = JSON.stringify(req.body);
if (!this.verifySignature(payload, signature)) {
throw new Error('Invalid signature');
}
const event = req.body as WebhookEvent;
const handlers = this.handlers.get(event.event) || [];
for (const handler of handlers) {
await handler(event);
}
return true;
}
private verifySignature(payload: string, signature: string): boolean {
const crypto = require('crypto');
const expected = crypto
.createHmac('sha256', this.webhookSecret)
.update(payload)
.digest('hex');
return signature === expected;
}
}
// Usage
const peanuts = new PeanutsClient(process.env.PEANUTS_SECRET!);
peanuts.on('entry.created', async (event) => {
console.log('New entry:', event.entry.data);
});
app.post('/webhook', async (req, res) => {
try {
await peanuts.handleWebhook(req);
res.status(200).json({ success: true });
} catch (err) {
res.status(401).json({ error: err.message });
}
});
Error Handling
Build resilient integrations:
async function processWebhook(event: WebhookEvent) {
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
try {
await syncToDatabase(event);
await sendNotifications(event);
return; // Success
} catch (error) {
attempt++;
console.error(`Attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
// Exponential backoff
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
// All retries failed - queue for manual review
await deadLetterQueue.add(event);
}
Testing Your Integration
Local Development
Use ngrok to expose your local server:
# Terminal 1: Start your server
npm run dev
# Terminal 2: Expose with ngrok
ngrok http 3000
# Copy the https:// URL to Peanuts webhook settings
Mock Payloads
Test with sample data:
const mockEntry = {
event: 'entry.created',
timestamp: new Date().toISOString(),
helper: {
id: 'test-helper',
name: 'Test Tracker',
command: '/test'
},
entry: {
id: 'entry-123',
created_at: new Date().toISOString(),
data: {
amount: 99.99,
category: 'Test',
description: 'Mock entry for testing'
}
},
user: { id: 'user-456' }
};
// Test your handler
await handleNewEntry(mockEntry);
Rate Limits & Best Practices
Respond Fast
Return 200 within 30 seconds. Process heavy work async.
Idempotent Handlers
Same entry ID should produce same result. Use upserts.
Log Raw Payloads
Store raw JSON for debugging failed syncs.
Monitor Failures
Track webhook success rates. Alert on spikes.
Exercise
Practice: Build an Expense Dashboard
Create a simple dashboard that:
- Receives expense webhooks from Peanuts
- Stores entries in a local JSON file or SQLite
- Exposes
/api/stats endpoint with:
- Total expenses this month
- Count by category
- Average expense amount
- Test with 5+ expense entries
Stack suggestion: Node.js + Express + lowdb (JSON database)
Key Takeaways
Remember: Peanuts pushes data to you via webhooks. Build idempotent handlers, respond quickly, and queue heavy processing for async execution.
Next Steps