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/rimoAuthentication: HMAC-SHA256 signature (no Clerk JWT).
Signature Verification
Every webhook request includes two headers:
| Header | Description |
|---|---|
X-Rimo-Signature | HMAC-SHA256 hex digest of the request body |
X-Rimo-Timestamp | Unix timestamp of when the event was generated |
Verification Steps
- Check
X-Rimo-Timestampis within 5 minutes of current time - Compute
HMAC-SHA256(timestamp + "." + body, RIMO_WEBHOOK_SECRET) - Compare computed signature with
X-Rimo-Signature - 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 hoursMonitoring & 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
}
}