Skip to content

RBS Ecosystem Webhooks

Status: IMPLEMENTED (multiple apps) Pattern: Event-driven with signature verification


Overview

Webhooks enable real-time communication between ecosystem apps. When significant events occur (employee hired, invoice created, etc.), the source app sends a webhook to interested consumers.

Architecture

┌──────────────────────────────────────────────────────────────────┐
│                    SOURCE APP (e.g., Colectiva)                  │
│                                                                  │
│  Event occurs → Build payload → Sign with secret → POST to URL   │
└──────────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────┐
│                    CONSUMER APP (e.g., Constanza)                │
│                                                                  │
│  Receive → Verify signature → Check idempotency → Process event  │
└──────────────────────────────────────────────────────────────────┘

Webhook Payload Structure

All ecosystem webhooks follow this structure:

typescript
interface WebhookPayload {
  // Required fields
  eventId: string;           // UUID for idempotency
  eventType: string;         // e.g., "employee.created"
  timestamp: string;         // ISO 8601
  source: string;            // App name, e.g., "colectiva"
  organizationId: string;    // RBS org ID

  // Event-specific data
  data: {
    // Varies by event type
  };

  // Optional metadata
  metadata?: {
    correlationId?: string;  // For tracing across systems
    userId?: string;         // Who triggered the event
    version?: string;        // Payload version
  };
}

Event Types

Employee Events (from Colectiva)

EventDescriptionTrigger
employee.createdNew employee addedEmployee record created
employee.updatedEmployee data changedProfile updated
employee.terminatedEmployment endedTermination processed

Payroll Events (from Colectiva)

EventDescriptionTrigger
payroll.calculatedPayroll run completedCalculation finished
payroll.paidPayments processedBank transfers sent
payroll.cancelledPayroll cancelledAdmin cancellation

Decision Events (from Colectiva)

EventDescriptionTrigger
hiring.approveNew hire approvedDecision finalized
termination.approveTermination approvedDecision finalized
salary_change.approveSalary change approvedDecision finalized
expense.approveExpense approvedDecision finalized
vacation.approveVacation approvedDecision finalized

Invoice Events (from Constanza)

EventDescriptionTrigger
invoice.createdInvoice generatedCFDI stamped
invoice.cancelledInvoice cancelledCancellation accepted
payroll_stamp.completedPayroll receipts stampedBulk stamping done

POS Events (from La Hoja, Caracol, Cosmos Pet)

EventDescriptionTrigger
sale.completedSale finishedPayment received
inventory.lowStock below thresholdAuto-detected
shift.closedCash register closedEnd of shift

Sending Webhooks

1. Configure Destination

Store webhook URLs and secrets per organization:

typescript
// Firestore: organizations/{orgId}/webhookConfig
{
  endpoints: {
    constanza: {
      url: "https://constanza.redbroomsoftware.com/api/ecosystem/webhooks",
      secret: "whsec_xxx",
      events: ["employee.*", "payroll.*", "decision.*"]
    }
  }
}

2. Sign the Payload

Use HMAC-SHA256 with the shared secret:

typescript
import { createHmac } from 'crypto';

function signPayload(payload: object, secret: string): string {
  const body = JSON.stringify(payload);
  const timestamp = Date.now().toString();
  const signature = createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  return `t=${timestamp},v1=${signature}`;
}

3. Send with Headers

typescript
async function sendWebhook(url: string, payload: object, secret: string) {
  const signature = signPayload(payload, secret);

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Signature': signature,
      'X-Webhook-ID': payload.eventId
    },
    body: JSON.stringify(payload)
  });

  if (!response.ok) {
    // Queue for retry
    await queueRetry(url, payload, secret);
  }
}

Receiving Webhooks

1. Verify Signature

typescript
function verifySignature(
  body: string,
  signature: string,
  secret: string,
  tolerance: number = 300000  // 5 minutes
): boolean {
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
  const hash = parts.find(p => p.startsWith('v1='))?.slice(3);

  if (!timestamp || !hash) return false;

  // Check timestamp tolerance
  const age = Date.now() - parseInt(timestamp);
  if (age > tolerance) return false;

  // Verify signature
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  return timingSafeEqual(Buffer.from(hash), Buffer.from(expected));
}

2. Check Idempotency

Prevent duplicate processing:

typescript
async function isProcessed(eventId: string): Promise<boolean> {
  const ref = doc(db, 'processedEvents', eventId);
  const snap = await getDoc(ref);
  return snap.exists();
}

async function markProcessed(eventId: string): Promise<void> {
  await setDoc(doc(db, 'processedEvents', eventId), {
    processedAt: new Date(),
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)  // 7 days
  });
}

3. Route by Event Type

typescript
const handlers: Record<string, (data: any) => Promise<void>> = {
  'employee.created': handleEmployeeCreated,
  'employee.updated': handleEmployeeUpdated,
  'payroll.calculated': handlePayrollCalculated,
  'hiring.approve': handleHiringDecision,
  // ...
};

async function processWebhook(payload: WebhookPayload) {
  const handler = handlers[payload.eventType];
  if (!handler) {
    console.warn(`No handler for ${payload.eventType}`);
    return;
  }
  await handler(payload.data);
}

Complete Handler Example

From Constanza (src/routes/api/ecosystem/webhooks/+server.ts):

typescript
import { json, error } from '@sveltejs/kit';
import { verifyWebhookSignature } from '$lib/services/webhook.service';

export const POST = async ({ request }) => {
  // Get raw body for signature verification
  const body = await request.text();
  const signature = request.headers.get('X-Webhook-Signature');
  const eventId = request.headers.get('X-Webhook-ID');

  // Verify signature
  const secret = env.COLECTIVA_WEBHOOK_SECRET;
  if (!verifySignature(body, signature, secret)) {
    throw error(401, 'Invalid signature');
  }

  // Parse payload
  const payload: WebhookPayload = JSON.parse(body);

  // Check idempotency
  if (await isProcessed(payload.eventId)) {
    return json({ status: 'already_processed' });
  }

  // Process event
  try {
    await processWebhook(payload);
    await markProcessed(payload.eventId);
    return json({ status: 'processed' });
  } catch (err) {
    console.error('Webhook processing failed:', err);
    throw error(500, 'Processing failed');
  }
};

Retry Policy

Implement exponential backoff for failed deliveries:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
6+24 hours (max 3 days)

After max retries, send to dead letter queue and alert ops team.

Dead Letter Queue

For webhooks that fail all retries:

typescript
// Firestore: deadLetterQueue/{id}
{
  originalPayload: { ... },
  targetUrl: "https://...",
  lastError: "Connection refused",
  attempts: 6,
  createdAt: Timestamp,
  lastAttemptAt: Timestamp
}

Manual replay via admin dashboard or API.

Security Best Practices

  1. Always verify signatures - Never process unsigned webhooks
  2. Check timestamp - Reject old webhooks (>5 min default)
  3. Use HTTPS only - Never send webhooks to HTTP endpoints
  4. Rotate secrets periodically - Support multiple active secrets during rotation
  5. Log all webhooks - For debugging and audit trails
  6. Implement circuit breaker - Stop sending after repeated failures

Environment Variables

env
# For sending webhooks
CONSTANZA_WEBHOOK_SECRET=whsec_xxxxx
LA_HOJA_WEBHOOK_SECRET=whsec_xxxxx

# For receiving webhooks
COLECTIVA_WEBHOOK_SECRET=whsec_xxxxx

Reference Implementations

AppSendsReceivesFiles
ColectivaYesNosrc/lib/services/webhook.service.ts
ConstanzaYesYessrc/routes/api/ecosystem/webhooks/+server.ts
La HojaYesNosrc/services/ecosystem-webhook.service.ts
Cosmos PetYesNo-

For webhook issues, check logs and dead letter queue first.

Red Broom Software Ecosystem