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)
| Event | Description | Trigger |
|---|---|---|
employee.created | New employee added | Employee record created |
employee.updated | Employee data changed | Profile updated |
employee.terminated | Employment ended | Termination processed |
Payroll Events (from Colectiva)
| Event | Description | Trigger |
|---|---|---|
payroll.calculated | Payroll run completed | Calculation finished |
payroll.paid | Payments processed | Bank transfers sent |
payroll.cancelled | Payroll cancelled | Admin cancellation |
Decision Events (from Colectiva)
| Event | Description | Trigger |
|---|---|---|
hiring.approve | New hire approved | Decision finalized |
termination.approve | Termination approved | Decision finalized |
salary_change.approve | Salary change approved | Decision finalized |
expense.approve | Expense approved | Decision finalized |
vacation.approve | Vacation approved | Decision finalized |
Invoice Events (from Constanza)
| Event | Description | Trigger |
|---|---|---|
invoice.created | Invoice generated | CFDI stamped |
invoice.cancelled | Invoice cancelled | Cancellation accepted |
payroll_stamp.completed | Payroll receipts stamped | Bulk stamping done |
POS Events (from La Hoja, Caracol, Cosmos Pet)
| Event | Description | Trigger |
|---|---|---|
sale.completed | Sale finished | Payment received |
inventory.low | Stock below threshold | Auto-detected |
shift.closed | Cash register closed | End 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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
- Always verify signatures - Never process unsigned webhooks
- Check timestamp - Reject old webhooks (>5 min default)
- Use HTTPS only - Never send webhooks to HTTP endpoints
- Rotate secrets periodically - Support multiple active secrets during rotation
- Log all webhooks - For debugging and audit trails
- 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_xxxxxReference Implementations
| App | Sends | Receives | Files |
|---|---|---|---|
| Colectiva | Yes | No | src/lib/services/webhook.service.ts |
| Constanza | Yes | Yes | src/routes/api/ecosystem/webhooks/+server.ts |
| La Hoja | Yes | No | src/services/ecosystem-webhook.service.ts |
| Cosmos Pet | Yes | No | - |
For webhook issues, check logs and dead letter queue first.