Skip to Content
API ReferenceRimo Webhooks

Rimo Webhooks

The Patient Graph API receives webhooks from Rimo Health for treatment and prescription lifecycle events. Webhooks are authenticated via HMAC-SHA256 signatures.

Endpoint

POST https://patient-graph.loop.health/webhooks/rimo

Authentication: HMAC-SHA256 signature (no Clerk JWT).

Signature Verification

Every webhook request includes two headers:

HeaderDescription
X-Rimo-SignatureHMAC-SHA256 hex digest of the request body
X-Rimo-TimestampUnix timestamp of when the event was generated

Verification Steps

  1. Check X-Rimo-Timestamp is within 5 minutes of current time
  2. Compute HMAC-SHA256(timestamp + "." + body, RIMO_WEBHOOK_SECRET)
  3. Compare computed signature with X-Rimo-Signature
  4. Reject if signatures don’t match

Example Verification (Node.js)

import { createHmac } from 'crypto'; function verifySignature(body: string, timestamp: string, signature: string): boolean { const payload = `${timestamp}.${body}`; const expected = createHmac('sha256', process.env.RIMO_WEBHOOK_SECRET!) .update(payload) .digest('hex'); return expected === signature; }

Event Types

The Rimo webhook system supports 16 event types across the complete treatment lifecycle:

Treatment Events

treatment.created

Fired when a new treatment is created in Rimo Health.

{ "id": "evt_abc123", "type": "treatment.created", "data": { "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "offeringId": "offering_789", "offeringName": "TRT Protocol", "status": "pending", "createdAt": "2024-06-15T12:00:00Z" }, "timestamp": "2024-06-15T12:00:00Z" }

treatment.updated

Fired when treatment details are modified.

{ "id": "evt_abc124", "type": "treatment.updated", "data": { "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "changes": { "status": { "from": "pending", "to": "under_review" }, "notes": { "from": null, "to": "Updated dosage recommendations" } }, "updatedBy": "dr_smith", "updatedAt": "2024-06-15T13:30:00Z" }, "timestamp": "2024-06-15T13:30:00Z" }

treatment.approved

Fired when a clinician approves a treatment.

{ "id": "evt_def456", "type": "treatment.approved", "data": { "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "clinicianId": "dr_smith", "clinicianName": "Dr. Smith", "approvedAt": "2024-06-15T14:00:00Z", "notes": "Approved with standard dosing protocol" }, "timestamp": "2024-06-15T14:00:00Z" }

treatment.rejected

Fired when a clinician rejects a treatment.

{ "id": "evt_def457", "type": "treatment.rejected", "data": { "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "clinicianId": "dr_smith", "clinicianName": "Dr. Smith", "rejectedAt": "2024-06-15T14:00:00Z", "reason": "Contraindicated with current medications", "notes": "Patient should discontinue current supplement before starting" }, "timestamp": "2024-06-15T14:00:00Z" }

treatment.cancelled

Fired when a treatment is cancelled by patient or clinician.

{ "id": "evt_def458", "type": "treatment.cancelled", "data": { "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "cancelledBy": "patient", "cancelledAt": "2024-06-16T10:00:00Z", "reason": "Patient request" }, "timestamp": "2024-06-16T10:00:00Z" }

Consultation Events

consultation.scheduled

Fired when a telehealth consultation is scheduled.

{ "id": "evt_cons123", "type": "consultation.scheduled", "data": { "consultationId": "cons_456", "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "clinicianId": "dr_smith", "scheduledAt": "2024-06-20T15:00:00Z", "duration": 30, "type": "initial_consultation" }, "timestamp": "2024-06-15T16:00:00Z" }

consultation.completed

Fired when a consultation is completed.

{ "id": "evt_cons124", "type": "consultation.completed", "data": { "consultationId": "cons_456", "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "clinicianId": "dr_smith", "completedAt": "2024-06-20T15:30:00Z", "duration": 28, "outcome": "treatment_approved", "notes": "Patient cleared for TRT protocol" }, "timestamp": "2024-06-20T15:30:00Z" }

consultation.rescheduled

Fired when a consultation is rescheduled.

{ "id": "evt_cons125", "type": "consultation.rescheduled", "data": { "consultationId": "cons_456", "customerId": "prof_abc123", "originalTime": "2024-06-20T15:00:00Z", "newTime": "2024-06-21T10:00:00Z", "rescheduledBy": "patient", "reason": "Schedule conflict" }, "timestamp": "2024-06-19T14:00:00Z" }

Order & Prescription Events

order.created

Fired when a prescription order is created.

{ "id": "evt_ord123", "type": "order.created", "data": { "orderId": "rimo_ord_789", "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "medications": [ { "name": "Testosterone Cypionate", "dosage": "200mg/mL", "quantity": 1, "refills": 5, "instructions": "Inject 0.5mL intramuscularly twice weekly" } ], "pharmacy": { "name": "Empower Pharmacy", "address": "Houston, TX" } }, "timestamp": "2024-06-16T09:00:00Z" }

order.transmitted

Fired when a prescription order is sent to the pharmacy.

{ "id": "evt_ghi789", "type": "order.transmitted", "data": { "orderId": "rimo_ord_789", "treatmentId": "rimo_treat_456", "customerId": "prof_abc123", "transmittedAt": "2024-06-16T09:15:00Z", "pharmacy": { "name": "Empower Pharmacy", "id": "pharm_123" } }, "timestamp": "2024-06-16T09:15:00Z" }

order.filled

Fired when the pharmacy fills the prescription.

{ "id": "evt_ord124", "type": "order.filled", "data": { "orderId": "rimo_ord_789", "filledAt": "2024-06-16T14:00:00Z", "pharmacy": { "name": "Empower Pharmacy", "id": "pharm_123" }, "medications": [ { "name": "Testosterone Cypionate", "lotNumber": "TC240615A", "expirationDate": "2025-06-15" } ] }, "timestamp": "2024-06-16T14:00:00Z" }

order.shipped

Fired when the pharmacy ships an order.

{ "id": "evt_jkl012", "type": "order.shipped", "data": { "orderId": "rimo_ord_789", "shippedAt": "2024-06-17T10:00:00Z", "trackingNumber": "1Z999AA10123456784", "carrier": "UPS", "estimatedDelivery": "2024-06-19", "shippingAddress": { "street": "123 Main St", "city": "Austin", "state": "TX", "zipCode": "78701" } }, "timestamp": "2024-06-17T10:00:00Z" }

order.delivered

Fired when an order is delivered to the patient.

{ "id": "evt_ord125", "type": "order.delivered", "data": { "orderId": "rimo_ord_789", "deliveredAt": "2024-06-19T15:30:00Z", "trackingNumber": "1Z999AA10123456784", "carrier": "UPS", "signedBy": "John Doe" }, "timestamp": "2024-06-19T15:30:00Z" }

Payment Events

charge.authorized

Fired when payment is authorized but not yet captured.

{ "id": "evt_pay123", "type": "charge.authorized", "data": { "chargeId": "ch_abc123", "orderId": "rimo_ord_789", "amount": 14999, "currency": "usd", "paymentMethod": "card_ending_4242", "authorizedAt": "2024-06-15T14:25:00Z" }, "timestamp": "2024-06-15T14:25:00Z" }

charge.captured

Fired when payment is captured for an order.

{ "id": "evt_mno345", "type": "charge.captured", "data": { "chargeId": "ch_abc123", "orderId": "rimo_ord_789", "amount": 14999, "currency": "usd", "capturedAt": "2024-06-15T14:30:00Z", "fees": { "processing": 435, "platform": 299 } }, "timestamp": "2024-06-15T14:30:00Z" }

charge.failed

Fired when a payment attempt fails.

{ "id": "evt_pay124", "type": "charge.failed", "data": { "chargeId": "ch_abc124", "orderId": "rimo_ord_789", "amount": 14999, "currency": "usd", "failedAt": "2024-06-15T14:30:00Z", "failureReason": "insufficient_funds", "failureMessage": "Your card was declined due to insufficient funds" }, "timestamp": "2024-06-15T14:30:00Z" }

Idempotency & Reliability

Redis-based Idempotency System

Webhook events are deduplicated using Redis to ensure exactly-once processing:

import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, }); const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hours const processWebhook = async (eventId: string, eventData: RimoWebhookEvent) => { // Check if event already processed const processed = await redis.get(`rimo_webhook:${eventId}`); if (processed) { console.log(`Event ${eventId} already processed, skipping`); return { success: true, duplicate: true }; } try { // Process the webhook event await handleRimoEvent(eventData); // Mark as processed with TTL await redis.setex(`rimo_webhook:${eventId}`, IDEMPOTENCY_TTL, 'processed'); return { success: true, duplicate: false }; } catch (error) { console.error(`Failed to process webhook ${eventId}:`, error); // Don't mark as processed on failure - allow retry throw error; } };

Dead Letter Queue

Failed webhook processing is handled with a dead letter queue using the webhook_failures table:

-- Migration: 0007_webhook_failures.sql CREATE TABLE webhook_failures ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id VARCHAR(255) NOT NULL, event_type VARCHAR(100) NOT NULL, payload JSONB NOT NULL, error_message TEXT NOT NULL, retry_count INTEGER DEFAULT 0, max_retries INTEGER DEFAULT 3, next_retry_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX idx_webhook_failures_next_retry ON webhook_failures(next_retry_at) WHERE retry_count < max_retries; CREATE INDEX idx_webhook_failures_event_id ON webhook_failures(event_id);

Retry Logic with Exponential Backoff

const handleWebhookFailure = async ( eventId: string, eventType: string, payload: any, error: Error ) => { try { // Check if failure record exists const existing = await db .select() .from(webhookFailures) .where(eq(webhookFailures.eventId, eventId)) .limit(1); if (existing.length > 0) { const failure = existing[0]; if (failure.retryCount >= failure.maxRetries) { console.error(`Max retries exceeded for webhook ${eventId}`); return; } // Update retry count and schedule next retry const nextRetryDelay = Math.pow(2, failure.retryCount) * 60; // Exponential backoff in minutes const nextRetryAt = new Date(Date.now() + nextRetryDelay * 60 * 1000); await db .update(webhookFailures) .set({ retryCount: failure.retryCount + 1, nextRetryAt, errorMessage: error.message, updatedAt: new Date(), }) .where(eq(webhookFailures.id, failure.id)); } else { // Create new failure record const nextRetryAt = new Date(Date.now() + 60 * 1000); // 1 minute await db.insert(webhookFailures).values({ eventId, eventType, payload, errorMessage: error.message, retryCount: 1, maxRetries: 3, nextRetryAt, }); } } catch (dbError) { console.error('Failed to record webhook failure:', dbError); } };

Retry Processing Job

const processWebhookRetries = async () => { try { const now = new Date(); // Get failed webhooks ready for retry const failedWebhooks = await db .select() .from(webhookFailures) .where( and( lt(webhookFailures.nextRetryAt, now), lt(webhookFailures.retryCount, webhookFailures.maxRetries) ) ) .limit(10); // Process 10 at a time for (const failure of failedWebhooks) { try { console.log(`Retrying webhook ${failure.eventId} (attempt ${failure.retryCount + 1})`); // Attempt to process the webhook again await handleRimoEvent(failure.payload); // Success - remove from failures table await db .delete(webhookFailures) .where(eq(webhookFailures.id, failure.id)); console.log(`Webhook ${failure.eventId} processed successfully on retry`); } catch (error) { console.error(`Retry failed for webhook ${failure.eventId}:`, error); // Update failure record await handleWebhookFailure( failure.eventId, failure.eventType, failure.payload, error as Error ); } } } catch (error) { console.error('Failed to process webhook retries:', error); } }; // Run retry job every 5 minutes setInterval(processWebhookRetries, 5 * 60 * 1000);

Health Check

curl https://patient-graph.loop.health/webhooks/rimo/health
{ "status": "ok" }

Testing Webhooks

Webhook Testing Endpoint

For development and testing, use the webhook testing endpoint:

curl -X POST "https://patient-graph.loop.health/webhooks/rimo/test" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "eventType": "treatment.approved", "customerId": "prof_test123", "data": { "treatmentId": "test_treatment_456", "clinicianName": "Dr. Test" } }'

Webhook Simulator

The webhook simulator allows testing all event types:

const simulateWebhookEvent = async (eventType: string, customData?: any) => { const baseEvent = { id: `evt_test_${Date.now()}`, type: eventType, timestamp: new Date().toISOString(), }; const eventData = { ...baseEvent, data: { customerId: 'prof_test123', treatmentId: 'test_treatment_456', ...getDefaultDataForEventType(eventType), ...customData, }, }; // Generate test signature const signature = generateTestSignature(JSON.stringify(eventData)); // Send to webhook endpoint const response = await fetch('/webhooks/rimo', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Rimo-Signature': signature, 'X-Rimo-Timestamp': Math.floor(Date.now() / 1000).toString(), }, body: JSON.stringify(eventData), }); return response.json(); }; // Test all event types const testAllWebhooks = async () => { const eventTypes = [ 'treatment.created', 'treatment.approved', 'treatment.rejected', 'consultation.scheduled', 'consultation.completed', 'order.created', 'order.transmitted', 'order.shipped', 'order.delivered', 'charge.captured', // ... all 16 event types ]; for (const eventType of eventTypes) { console.log(`Testing ${eventType}...`); const result = await simulateWebhookEvent(eventType); console.log(`${eventType}: ${result.success ? 'PASS' : 'FAIL'}`); } };

Integration Tests

describe('Rimo Webhooks', () => { beforeEach(async () => { // Clear Redis cache and database await redis.flushall(); await db.delete(webhookFailures); }); it('should process treatment.approved webhook', async () => { const event = { id: 'evt_test_123', type: 'treatment.approved', data: { treatmentId: 'rimo_treat_456', customerId: 'prof_abc123', clinicianName: 'Dr. Smith', approvedAt: '2024-06-15T14:00:00Z', }, timestamp: '2024-06-15T14:00:00Z', }; const response = await request(app) .post('/webhooks/rimo') .set('X-Rimo-Signature', generateSignature(JSON.stringify(event))) .set('X-Rimo-Timestamp', Math.floor(Date.now() / 1000).toString()) .send(event); expect(response.status).toBe(200); // Verify treatment was created in database const treatment = await getTreatment(event.data.treatmentId); expect(treatment.status).toBe('approved'); }); it('should handle duplicate webhooks with idempotency', async () => { const event = { id: 'evt_duplicate_test', type: 'treatment.created', /* ... */ }; // Send webhook twice await sendWebhook(event); const secondResponse = await sendWebhook(event); expect(secondResponse.body.duplicate).toBe(true); // Verify only one treatment was created const treatments = await getTreatmentsByEventId(event.id); expect(treatments).toHaveLength(1); }); it('should retry failed webhooks with exponential backoff', async () => { // Mock database error jest.spyOn(db, 'insert').mockRejectedValueOnce(new Error('Database error')); const event = { id: 'evt_retry_test', type: 'treatment.created', /* ... */ }; // First attempt should fail and create failure record await sendWebhook(event); const failures = await db.select().from(webhookFailures); expect(failures).toHaveLength(1); expect(failures[0].retryCount).toBe(1); // Restore database function jest.restoreAllMocks(); // Process retries await processWebhookRetries(); // Failure record should be removed const remainingFailures = await db.select().from(webhookFailures); expect(remainingFailures).toHaveLength(0); }); });

Environment Variables

# Rimo Webhook Configuration RIMO_WEBHOOK_SECRET=your-rimo-webhook-secret # Redis for Idempotency (Upstash) UPSTASH_REDIS_REST_URL=https://your-redis-instance.upstash.io UPSTASH_REDIS_REST_TOKEN=your-redis-token # Database Configuration DATABASE_URL=postgresql://user:pass@localhost:5432/patient_graph # Optional: Webhook Processing Configuration WEBHOOK_RETRY_MAX_ATTEMPTS=3 WEBHOOK_RETRY_BASE_DELAY=60 # seconds WEBHOOK_IDEMPOTENCY_TTL=86400 # 24 hours

Monitoring & Observability

Webhook Metrics

Track key webhook metrics for monitoring:

const webhookMetrics = { // Success/failure rates by event type processed: new Map<string, number>(), failed: new Map<string, number>(), // Processing times processingTimes: new Map<string, number[]>(), // Retry statistics retries: new Map<string, number>(), record: (eventType: string, success: boolean, processingTime: number, retryCount = 0) => { if (success) { webhookMetrics.processed.set(eventType, (webhookMetrics.processed.get(eventType) || 0) + 1); } else { webhookMetrics.failed.set(eventType, (webhookMetrics.failed.get(eventType) || 0) + 1); } const times = webhookMetrics.processingTimes.get(eventType) || []; times.push(processingTime); webhookMetrics.processingTimes.set(eventType, times); if (retryCount > 0) { webhookMetrics.retries.set(eventType, (webhookMetrics.retries.get(eventType) || 0) + 1); } }, getStats: () => ({ processed: Object.fromEntries(webhookMetrics.processed), failed: Object.fromEntries(webhookMetrics.failed), avgProcessingTimes: Object.fromEntries( Array.from(webhookMetrics.processingTimes.entries()).map(([type, times]) => [ type, times.reduce((sum, time) => sum + time, 0) / times.length ]) ), retries: Object.fromEntries(webhookMetrics.retries), }), };

Health Check with Metrics

curl https://patient-graph.loop.health/webhooks/rimo/health
{ "status": "ok", "uptime": 86400, "metrics": { "processed": { "treatment.approved": 45, "order.shipped": 23, "charge.captured": 67 }, "failed": { "treatment.created": 2 }, "avgProcessingTimes": { "treatment.approved": 150, "order.shipped": 89 }, "retries": { "treatment.created": 2 } }, "failureQueue": { "pending": 0, "maxRetries": 3 } }