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

Capital Events (from Colectiva → Rito)

EventDescriptionTrigger
capital_call.payment_receivedLP paid a capital callPayment completed
capital_call.payment_failedCapital call payment failedPayment error
distribution.completedDistribution sent to LPTransfer completed
distribution.failedDistribution payment failedTransfer error

SPV/Accounting Events (from Constanza → Rito)

EventDescriptionTrigger
spv.createdSPV entity createdConstanza org created
poliza.generatedJournal entry generatedDeal event recorded
tax.calculatedISR/IVA calculations updatedTax run completed

CRM Events (from Camino → Rito)

EventDescriptionTrigger
contact.updatedInvestor contact info changedCRM profile updated
notification.sentLP notification deliveredEmail/WhatsApp sent

Scheduling Events (from Mancha → Rito)

EventDescriptionTrigger
schedule.createdNew event scheduledMancha booking
schedule.updatedEvent modifiedMancha reschedule
schedule.cancelledEvent cancelledMancha cancellation
schedule.completedEvent completedMancha completion

Contract Events (from Agora → Rito)

EventDescriptionTrigger
contract.createdNew contract createdAgora upload
contract.updatedContract modifiedAgora edit
contract.executedContract executedSignature completed
contract.expiredContract expiredExpiration date passed

AI Usage Events (from Rito → Colectiva)

EventDescriptionTrigger
ai.usageAI token consumptionCopilot interaction

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
RitoYesYessrc/routes/api/ecosystem/webhooks/+server.ts, src/lib/services/ai-copilot.service.ts
La HojaYesNosrc/services/ecosystem-webhook.service.ts
Cosmos PetYesNo-

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

Red Broom Software Ecosystem