Skip to main content
Time: 25 minutes | Level: Expert
Prerequisites: Webhooks & Automations
Requires: Pro plan or higher

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:
  1. Receives expense webhooks from Peanuts
  2. Stores entries in a local JSON file or SQLite
  3. Exposes /api/stats endpoint with:
    • Total expenses this month
    • Count by category
    • Average expense amount
  4. 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