MicProtocol / apps /api /src /integrations /webhook-idempotency.service.ts
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
3.05 kB
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createClient } from "redis";
import type { RedisClientType } from "redis";
import * as crypto from "crypto";
@Injectable()
export class WebhookIdempotencyService {
private readonly logger = new Logger(WebhookIdempotencyService.name);
private client: RedisClientType | null = null;
private readonly ttlSeconds = 86400; // 24 hours
constructor(private readonly configService: ConfigService) {}
private async getClient(): Promise<RedisClientType> {
if (this.client?.isOpen) return this.client;
const redisUrl = this.configService.get<string>(
"REDIS_URL",
"redis://:redis_secret@localhost:6379"
);
this.client = createClient({ url: redisUrl });
this.client.on("error", (err) => this.logger.error("Redis error", err));
await this.client.connect();
return this.client;
}
/**
* Check if webhook event was already processed.
* Returns true if this is a duplicate (already processed).
* Uses SETNX for atomic check-and-set.
*/
async checkAndMark(eventKey: string): Promise<boolean> {
const client = await this.getClient();
const key = `webhook:idempotency:${eventKey}`;
// SET key value NX EX ttl - only sets if not exists
const result = await client.set(key, "1", {
NX: true,
EX: this.ttlSeconds,
});
// result is "OK" if set (first time), null if already exists (duplicate)
return result === null;
}
/**
* Generate a unique event key from provider and webhook payload
*/
generateEventKey(provider: string, body: any): string {
const eventId = this.generateEventId(provider, body);
return `${provider}:${eventId}`;
}
/**
* Generate a unique event ID from webhook payload
*/
generateEventId(provider: string, body: any): string {
switch (provider) {
case "calcom":
// Cal.com uses booking UID or event ID
return body?.payload?.uid || body?.uid || body?.bookingId ||
body?.payload?.id || cryptoRandomId();
case "chatwoot":
// Chatwoot uses conversation ID + message ID
return `${body?.conversation?.id || "unknown"}:${body?.id || body?.message?.id || cryptoRandomId()}`;
case "formbricks":
// Formbricks uses response ID
return body?.responseId || body?.data?.id || body?.id || cryptoRandomId();
case "listmonk":
// Listmonk uses subscriber ID + event type
const event = body?.event || "unknown";
const subId = body?.data?.subscriber?.id || body?.subscriber_id || "unknown";
return `${event}:${subId}`;
case "documenso":
return body?.documentId || body?.data?.id || cryptoRandomId();
case "livekit":
return `${body?.event || "unknown"}:${body?.room?.name || body?.roomName || cryptoRandomId()}`;
default:
return cryptoRandomId();
}
}
}
function cryptoRandomId(): string {
return crypto.randomBytes(16).toString("hex");
}