committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2159 additions and 24 deletions
-
1api/package.json
-
35api/pnpm-lock.yaml
-
4api/src/app.module.ts
-
2api/src/gateway/gateway.module.ts
-
14api/src/gateway/gateway.service.ts
-
41api/src/webhook/schemas/webhook-notification.schema.ts
-
43api/src/webhook/schemas/webhook-subscription.schema.ts
-
3api/src/webhook/webhook-event.enum.ts
-
68api/src/webhook/webhook.controller.ts
-
14api/src/webhook/webhook.dto.ts
-
35api/src/webhook/webhook.module.ts
-
270api/src/webhook/webhook.service.ts
-
25web/app/(app)/dashboard/(components)/main-dashboard.tsx
-
198web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx
-
50web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx
-
201web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx
-
139web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx
-
169web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx
-
178web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx
-
49web/components/shared/copy-button.tsx
-
141web/components/ui/alert-dialog.tsx
-
17web/components/ui/code.tsx
-
15web/components/ui/skeleton.tsx
-
32web/components/ui/tooltip.tsx
-
3web/config/api.ts
-
3web/lib/constants.ts
-
17web/lib/types.ts
-
3web/package.json
-
413web/pnpm-lock.yaml
@ -0,0 +1,41 @@ |
|||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' |
||||
|
import { Document, Types } from 'mongoose' |
||||
|
import { WebhookSubscription } from './webhook-subscription.schema' |
||||
|
import { SMS } from 'src/gateway/schemas/sms.schema' |
||||
|
|
||||
|
export type WebhookNotificationDocument = WebhookNotification & Document |
||||
|
|
||||
|
@Schema({ timestamps: true }) |
||||
|
export class WebhookNotification { |
||||
|
_id?: Types.ObjectId |
||||
|
|
||||
|
@Prop({ type: Types.ObjectId, ref: WebhookSubscription.name, required: true }) |
||||
|
webhookSubscription: WebhookSubscription |
||||
|
|
||||
|
@Prop({ type: String, required: true }) |
||||
|
event: string |
||||
|
|
||||
|
@Prop({ type: Object, required: true }) |
||||
|
payload: object |
||||
|
|
||||
|
@Prop({ type: Types.ObjectId, ref: SMS.name }) |
||||
|
sms: SMS |
||||
|
|
||||
|
@Prop({ type: Date }) |
||||
|
deliveredAt: Date |
||||
|
|
||||
|
@Prop({ type: Date }) |
||||
|
lastDeliveryAttemptAt: Date |
||||
|
|
||||
|
@Prop({ type: Date }) |
||||
|
nextDeliveryAttemptAt: Date |
||||
|
|
||||
|
@Prop({ type: Number, default: 0 }) |
||||
|
deliveryAttemptCount: number |
||||
|
|
||||
|
@Prop({ type: Date }) |
||||
|
deliveryAttemptAbortedAt: Date |
||||
|
} |
||||
|
|
||||
|
export const WebhookNotificationSchema = |
||||
|
SchemaFactory.createForClass(WebhookNotification) |
||||
@ -0,0 +1,43 @@ |
|||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' |
||||
|
import { Document, Types } from 'mongoose' |
||||
|
import { User } from 'src/users/schemas/user.schema' |
||||
|
import { WebhookEvent } from '../webhook-event.enum' |
||||
|
|
||||
|
export type WebhookSubscriptionDocument = WebhookSubscription & Document |
||||
|
|
||||
|
@Schema({ timestamps: true }) |
||||
|
export class WebhookSubscription { |
||||
|
_id?: Types.ObjectId |
||||
|
|
||||
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true }) |
||||
|
user: User |
||||
|
|
||||
|
@Prop({ type: Boolean, default: true }) |
||||
|
isActive: boolean |
||||
|
|
||||
|
@Prop({ type: [String], default: [WebhookEvent.MESSAGE_RECEIVED] }) |
||||
|
events: string[] |
||||
|
|
||||
|
@Prop({ type: String, required: true }) |
||||
|
deliveryUrl: string |
||||
|
|
||||
|
@Prop({ type: String, required: true }) |
||||
|
signingSecret: string |
||||
|
|
||||
|
@Prop({ type: Number, default: 0 }) |
||||
|
successfulDeliveryCount: number |
||||
|
|
||||
|
@Prop({ type: Number, default: 0 }) |
||||
|
deliveryAttemptCount: number |
||||
|
|
||||
|
@Prop({ type: Date }) |
||||
|
lastDeliveryAttemptAt: Date |
||||
|
|
||||
|
@Prop({ type: Date }) |
||||
|
lastDeliverySuccessAt: Date |
||||
|
} |
||||
|
|
||||
|
export const WebhookSubscriptionSchema = |
||||
|
SchemaFactory.createForClass(WebhookSubscription) |
||||
|
|
||||
|
WebhookSubscriptionSchema.index({ user: 1, events: 1 }, { unique: true }) |
||||
@ -0,0 +1,3 @@ |
|||||
|
export enum WebhookEvent { |
||||
|
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED', |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
import { |
||||
|
Body, |
||||
|
Request, |
||||
|
Param, |
||||
|
Post, |
||||
|
Patch, |
||||
|
Controller, |
||||
|
Get, |
||||
|
UseGuards, |
||||
|
} from '@nestjs/common' |
||||
|
import { WebhookService } from './webhook.service' |
||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' |
||||
|
import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto' |
||||
|
import { AuthGuard } from 'src/auth/guards/auth.guard' |
||||
|
|
||||
|
@ApiTags('webhooks') |
||||
|
@ApiBearerAuth() |
||||
|
@Controller('webhooks') |
||||
|
export class WebhookController { |
||||
|
constructor(private readonly webhookService: WebhookService) {} |
||||
|
|
||||
|
@Get() |
||||
|
@UseGuards(AuthGuard) |
||||
|
async getWebhooks(@Request() req) { |
||||
|
const data = await this.webhookService.findWebhooksForUser({ |
||||
|
user: req.user, |
||||
|
}) |
||||
|
return { data } |
||||
|
} |
||||
|
|
||||
|
@Get(':webhookId') |
||||
|
@UseGuards(AuthGuard) |
||||
|
async getWebhook(@Request() req, @Param('webhookId') webhookId: string) { |
||||
|
const data = await this.webhookService.findOne({ |
||||
|
user: req.user, |
||||
|
webhookId, |
||||
|
}) |
||||
|
return { data } |
||||
|
} |
||||
|
|
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard) |
||||
|
async createWebhook( |
||||
|
@Request() req, |
||||
|
@Body() createWebhookDto: CreateWebhookDto, |
||||
|
) { |
||||
|
const data = await this.webhookService.create({ |
||||
|
user: req.user, |
||||
|
createWebhookDto, |
||||
|
}) |
||||
|
return { data } |
||||
|
} |
||||
|
|
||||
|
@Patch(':webhookId') |
||||
|
@UseGuards(AuthGuard) |
||||
|
async updateWebhook( |
||||
|
@Request() req, |
||||
|
@Param('webhookId') webhookId: string, |
||||
|
@Body() updateWebhookDto: UpdateWebhookDto, |
||||
|
) { |
||||
|
const data = await this.webhookService.update({ |
||||
|
user: req.user, |
||||
|
webhookId, |
||||
|
updateWebhookDto, |
||||
|
}) |
||||
|
return { data } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { WebhookEvent } from './webhook-event.enum' |
||||
|
|
||||
|
export class CreateWebhookDto { |
||||
|
deliveryUrl: string |
||||
|
signingSecret?: string |
||||
|
events: WebhookEvent[] |
||||
|
} |
||||
|
|
||||
|
export class UpdateWebhookDto { |
||||
|
isActive: boolean |
||||
|
deliveryUrl: string |
||||
|
signingSecret: string |
||||
|
events: WebhookEvent[] |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
import { Module } from '@nestjs/common' |
||||
|
import { MongooseModule } from '@nestjs/mongoose' |
||||
|
import { WebhookController } from './webhook.controller' |
||||
|
import { WebhookService } from './webhook.service' |
||||
|
import { |
||||
|
WebhookSubscription, |
||||
|
WebhookSubscriptionSchema, |
||||
|
} from './schemas/webhook-subscription.schema' |
||||
|
import { |
||||
|
WebhookNotification, |
||||
|
WebhookNotificationSchema, |
||||
|
} from './schemas/webhook-notification.schema' |
||||
|
import { AuthModule } from 'src/auth/auth.module' |
||||
|
import { UsersModule } from 'src/users/users.module' |
||||
|
|
||||
|
@Module({ |
||||
|
imports: [ |
||||
|
MongooseModule.forFeature([ |
||||
|
{ |
||||
|
name: WebhookSubscription.name, |
||||
|
schema: WebhookSubscriptionSchema, |
||||
|
}, |
||||
|
{ |
||||
|
name: WebhookNotification.name, |
||||
|
schema: WebhookNotificationSchema, |
||||
|
}, |
||||
|
]), |
||||
|
AuthModule, |
||||
|
UsersModule, |
||||
|
], |
||||
|
controllers: [WebhookController], |
||||
|
providers: [WebhookService], |
||||
|
exports: [MongooseModule, WebhookService], |
||||
|
}) |
||||
|
export class WebhookModule {} |
||||
@ -0,0 +1,270 @@ |
|||||
|
import { HttpException, HttpStatus, Injectable } from '@nestjs/common' |
||||
|
import { Model } from 'mongoose' |
||||
|
import { |
||||
|
WebhookSubscription, |
||||
|
WebhookSubscriptionDocument, |
||||
|
} from './schemas/webhook-subscription.schema' |
||||
|
import { InjectModel } from '@nestjs/mongoose' |
||||
|
import { WebhookEvent } from './webhook-event.enum' |
||||
|
import { |
||||
|
WebhookNotification, |
||||
|
WebhookNotificationDocument, |
||||
|
} from './schemas/webhook-notification.schema' |
||||
|
import axios from 'axios' |
||||
|
import { v4 as uuidv4 } from 'uuid' |
||||
|
import { Cron } from '@nestjs/schedule' |
||||
|
import { CronExpression } from '@nestjs/schedule' |
||||
|
import * as crypto from 'crypto' |
||||
|
|
||||
|
@Injectable() |
||||
|
export class WebhookService { |
||||
|
constructor( |
||||
|
@InjectModel(WebhookSubscription.name) |
||||
|
private webhookSubscriptionModel: Model<WebhookSubscriptionDocument>, |
||||
|
@InjectModel(WebhookNotification.name) |
||||
|
private webhookNotificationModel: Model<WebhookNotificationDocument>, |
||||
|
) {} |
||||
|
|
||||
|
async findOne({ user, webhookId }) { |
||||
|
const webhook = await this.webhookSubscriptionModel.findOne({ |
||||
|
_id: webhookId, |
||||
|
user: user._id, |
||||
|
}) |
||||
|
|
||||
|
if (!webhook) { |
||||
|
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND) |
||||
|
} |
||||
|
return webhook |
||||
|
} |
||||
|
|
||||
|
async findWebhooksForUser({ user }) { |
||||
|
return await this.webhookSubscriptionModel.find({ user: user._id }) |
||||
|
} |
||||
|
|
||||
|
async create({ user, createWebhookDto }) { |
||||
|
const { events, deliveryUrl, signingSecret } = createWebhookDto |
||||
|
|
||||
|
// Add URL validation
|
||||
|
try { |
||||
|
new URL(deliveryUrl) |
||||
|
} catch (e) { |
||||
|
throw new HttpException('Invalid delivery URL', HttpStatus.BAD_REQUEST) |
||||
|
} |
||||
|
|
||||
|
// validate signing secret
|
||||
|
if (signingSecret.length < 20) { |
||||
|
throw new HttpException('Invalid signing secret', HttpStatus.BAD_REQUEST) |
||||
|
} |
||||
|
|
||||
|
const existingSubscription = await this.webhookSubscriptionModel.findOne({ |
||||
|
user: user._id, |
||||
|
events, |
||||
|
}) |
||||
|
|
||||
|
if (existingSubscription) { |
||||
|
throw new HttpException( |
||||
|
'You have already subscribed to this event', |
||||
|
HttpStatus.BAD_REQUEST, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
if (!events.every((event) => Object.values(WebhookEvent).includes(event))) { |
||||
|
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST) |
||||
|
} |
||||
|
|
||||
|
// TODO: Encrypt signing secret
|
||||
|
// const webhookSignatureKey = process.env.WEBHOOK_SIGNATURE_KEY
|
||||
|
// const encryptedSigningSecret = encrypt(signingSecret, webhookSignatureKey)
|
||||
|
|
||||
|
const webhookSubscription = await this.webhookSubscriptionModel.create({ |
||||
|
user: user._id, |
||||
|
events, |
||||
|
deliveryUrl, |
||||
|
signingSecret, |
||||
|
}) |
||||
|
|
||||
|
return webhookSubscription |
||||
|
} |
||||
|
|
||||
|
async update({ user, webhookId, updateWebhookDto }) { |
||||
|
const webhookSubscription = await this.webhookSubscriptionModel.findOne({ |
||||
|
_id: webhookId, |
||||
|
user: user._id, |
||||
|
}) |
||||
|
|
||||
|
if (!webhookSubscription) { |
||||
|
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND) |
||||
|
} |
||||
|
|
||||
|
if (updateWebhookDto.hasOwnProperty('isActive')) { |
||||
|
webhookSubscription.isActive = updateWebhookDto.isActive |
||||
|
} |
||||
|
|
||||
|
if (updateWebhookDto.hasOwnProperty('deliveryUrl')) { |
||||
|
webhookSubscription.deliveryUrl = updateWebhookDto.deliveryUrl |
||||
|
} |
||||
|
|
||||
|
// if there is a valid uuid signing secret, update it
|
||||
|
if ( |
||||
|
updateWebhookDto.hasOwnProperty('signingSecret') && |
||||
|
updateWebhookDto.signingSecret.length < 20 |
||||
|
) { |
||||
|
throw new HttpException('Invalid signing secret', HttpStatus.BAD_REQUEST) |
||||
|
} else if (updateWebhookDto.hasOwnProperty('signingSecret')) { |
||||
|
webhookSubscription.signingSecret = updateWebhookDto.signingSecret |
||||
|
} |
||||
|
|
||||
|
await webhookSubscription.save() |
||||
|
|
||||
|
return webhookSubscription |
||||
|
} |
||||
|
|
||||
|
async deliverNotification({ sms, user, event }) { |
||||
|
const webhookSubscription = await this.webhookSubscriptionModel.findOne({ |
||||
|
user: user._id, |
||||
|
events: { $in: [event] }, |
||||
|
isActive: true, |
||||
|
}) |
||||
|
|
||||
|
if (!webhookSubscription) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if (event === WebhookEvent.MESSAGE_RECEIVED) { |
||||
|
const payload = { |
||||
|
smsId: sms._id, |
||||
|
sender: sms.sender, |
||||
|
message: sms.message, |
||||
|
receivedAt: sms.receivedAt, |
||||
|
deviceId: sms.device, |
||||
|
webhookSubscriptionId: webhookSubscription._id, |
||||
|
webhookEvent: event, |
||||
|
} |
||||
|
const webhookNotification = await this.webhookNotificationModel.create({ |
||||
|
webhookSubscription: webhookSubscription._id, |
||||
|
event, |
||||
|
payload, |
||||
|
sms, |
||||
|
}) |
||||
|
|
||||
|
await this.attemptWebhookDelivery(webhookNotification) |
||||
|
} else { |
||||
|
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async attemptWebhookDelivery( |
||||
|
webhookNotification: WebhookNotificationDocument, |
||||
|
) { |
||||
|
const now = new Date() |
||||
|
|
||||
|
const webhookSubscription = await this.webhookSubscriptionModel.findById( |
||||
|
webhookNotification.webhookSubscription, |
||||
|
) |
||||
|
|
||||
|
if (!webhookSubscription) { |
||||
|
console.error( |
||||
|
`Webhook subscription not found for ${webhookNotification._id}`, |
||||
|
) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if (!webhookSubscription.isActive) { |
||||
|
webhookNotification.deliveryAttemptAbortedAt = now |
||||
|
await webhookNotification.save() |
||||
|
console.error( |
||||
|
`Webhook subscription is not active for ${webhookNotification._id}, aborting delivery`, |
||||
|
) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const deliveryUrl = webhookSubscription?.deliveryUrl |
||||
|
const signingSecret = webhookSubscription?.signingSecret |
||||
|
|
||||
|
const signature = crypto |
||||
|
.createHmac('sha256', signingSecret) |
||||
|
.update(JSON.stringify(webhookNotification.payload)) |
||||
|
.digest('hex') |
||||
|
|
||||
|
try { |
||||
|
await axios.post(deliveryUrl, webhookNotification.payload, { |
||||
|
headers: { |
||||
|
'X-Signature': signature, |
||||
|
}, |
||||
|
timeout: 10000, |
||||
|
}) |
||||
|
webhookNotification.deliveryAttemptCount += 1 |
||||
|
webhookNotification.lastDeliveryAttemptAt = now |
||||
|
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt( |
||||
|
webhookNotification.deliveryAttemptCount, |
||||
|
) |
||||
|
webhookNotification.deliveredAt = now |
||||
|
await webhookNotification.save() |
||||
|
|
||||
|
webhookSubscription.successfulDeliveryCount += 1 |
||||
|
webhookSubscription.lastDeliverySuccessAt = now |
||||
|
} catch (e) { |
||||
|
console.error( |
||||
|
`Failed to deliver webhook notification ${webhookNotification._id}: received response status code ${e.response.status}`, |
||||
|
) |
||||
|
webhookNotification.deliveryAttemptCount += 1 |
||||
|
webhookNotification.lastDeliveryAttemptAt = now |
||||
|
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt( |
||||
|
webhookNotification.deliveryAttemptCount, |
||||
|
) |
||||
|
await webhookNotification.save() |
||||
|
} finally { |
||||
|
webhookSubscription.deliveryAttemptCount += 1 |
||||
|
await webhookSubscription.save() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getNextDeliveryAttemptAt(deliveryAttemptCount: number): Date { |
||||
|
// Delays in minutes after a failed delivery attempt
|
||||
|
const delaySequence = [ |
||||
|
3, // 3 minutes
|
||||
|
5, // 5 minutes
|
||||
|
30, // 30 minutes
|
||||
|
60, // 1 hour
|
||||
|
360, // 6 hours
|
||||
|
1440, // 1 day
|
||||
|
4320, // 3 days
|
||||
|
10080, // 7 days
|
||||
|
43200, // 30 days
|
||||
|
] |
||||
|
|
||||
|
// Get the delay in minutes (use last value if attempt count exceeds sequence length)
|
||||
|
const delayInMinutes = |
||||
|
delaySequence[ |
||||
|
Math.min(deliveryAttemptCount - 1, delaySequence.length - 1) |
||||
|
] || delaySequence[delaySequence.length - 1] |
||||
|
|
||||
|
// Convert minutes to milliseconds and add to current time
|
||||
|
return new Date(Date.now() + delayInMinutes * 60 * 1000) |
||||
|
} |
||||
|
|
||||
|
// Check for notifications that need to be delivered every 3 minutes
|
||||
|
@Cron('0 */3 * * * *') |
||||
|
async checkForNotificationsToDeliver() { |
||||
|
const now = new Date() |
||||
|
const notifications = await this.webhookNotificationModel |
||||
|
.find({ |
||||
|
nextDeliveryAttemptAt: { $lte: now }, |
||||
|
deliveredAt: null, |
||||
|
deliveryAttemptCount: { $lt: 10 }, |
||||
|
deliveryAttemptAbortedAt: null, |
||||
|
}) |
||||
|
.sort({ nextDeliveryAttemptAt: 1 }) |
||||
|
.limit(30) |
||||
|
|
||||
|
if (notifications.length === 0) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
console.log(`delivering ${notifications.length} webhook notifications`) |
||||
|
|
||||
|
for (const notification of notifications) { |
||||
|
await this.attemptWebhookDelivery(notification) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,198 @@ |
|||||
|
'use client' |
||||
|
|
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogDescription, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { |
||||
|
Form, |
||||
|
FormControl, |
||||
|
FormDescription, |
||||
|
FormField, |
||||
|
FormItem, |
||||
|
FormLabel, |
||||
|
FormMessage, |
||||
|
} from '@/components/ui/form' |
||||
|
import { |
||||
|
Select, |
||||
|
SelectContent, |
||||
|
SelectItem, |
||||
|
SelectTrigger, |
||||
|
SelectValue, |
||||
|
} from '@/components/ui/select' |
||||
|
import { zodResolver } from '@hookform/resolvers/zod' |
||||
|
import { useForm } from 'react-hook-form' |
||||
|
import * as z from 'zod' |
||||
|
import { v4 as uuidv4 } from 'uuid' |
||||
|
import { WebhookData } from '@/lib/types' |
||||
|
import { WEBHOOK_EVENTS } from '@/lib/constants' |
||||
|
import httpBrowserClient from '@/lib/httpBrowserClient' |
||||
|
import { ApiEndpoints } from '@/config/api' |
||||
|
import { useToast } from '@/hooks/use-toast' |
||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query' |
||||
|
|
||||
|
const formSchema = z.object({ |
||||
|
deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }), |
||||
|
events: z.array(z.string()).min(1, { message: 'Select at least one event' }), |
||||
|
isActive: z.boolean().default(true), |
||||
|
signingSecret: z.string().min(1, { message: 'Signing secret is required' }), |
||||
|
}) |
||||
|
|
||||
|
interface CreateWebhookDialogProps { |
||||
|
open: boolean |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
} |
||||
|
|
||||
|
export function CreateWebhookDialog({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
}: CreateWebhookDialogProps) { |
||||
|
const { toast } = useToast() |
||||
|
const queryClient = useQueryClient() |
||||
|
|
||||
|
const form = useForm<z.infer<typeof formSchema>>({ |
||||
|
resolver: zodResolver(formSchema), |
||||
|
defaultValues: { |
||||
|
deliveryUrl: '', |
||||
|
events: [WEBHOOK_EVENTS.MESSAGE_RECEIVED], |
||||
|
isActive: true, |
||||
|
signingSecret: uuidv4(), |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const createWebhookMutation = useMutation({ |
||||
|
mutationFn: (values: z.infer<typeof formSchema>) => |
||||
|
httpBrowserClient.post(ApiEndpoints.gateway.createWebhook(), values), |
||||
|
onSuccess: () => { |
||||
|
toast({ |
||||
|
title: 'Success', |
||||
|
description: 'Webhook created successfully', |
||||
|
}) |
||||
|
queryClient.invalidateQueries({ queryKey: ['webhooks'] }) |
||||
|
onOpenChange(false) |
||||
|
form.reset() |
||||
|
}, |
||||
|
onError: () => { |
||||
|
toast({ |
||||
|
title: 'Error', |
||||
|
description: 'Failed to create webhook', |
||||
|
variant: 'destructive', |
||||
|
}) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const onSubmit = (values: z.infer<typeof formSchema>) => { |
||||
|
createWebhookMutation.mutate(values) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent className='sm:max-w-[500px]'> |
||||
|
<DialogHeader> |
||||
|
<DialogTitle>Create Webhook</DialogTitle> |
||||
|
<DialogDescription> |
||||
|
Configure your webhook endpoint to receive real-time SMS |
||||
|
notifications. |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
<Form {...form}> |
||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'> |
||||
|
<FormField |
||||
|
control={form.control} |
||||
|
name='deliveryUrl' |
||||
|
render={({ field }) => ( |
||||
|
<FormItem> |
||||
|
<FormLabel>Delivery URL</FormLabel> |
||||
|
<FormControl> |
||||
|
<Input |
||||
|
placeholder='https://api.example.com/webhooks' |
||||
|
{...field} |
||||
|
/> |
||||
|
</FormControl> |
||||
|
<FormDescription> |
||||
|
The URL where webhook notifications will be sent via POST |
||||
|
requests |
||||
|
</FormDescription> |
||||
|
<FormMessage /> |
||||
|
</FormItem> |
||||
|
)} |
||||
|
/> |
||||
|
<FormField |
||||
|
control={form.control} |
||||
|
name='signingSecret' |
||||
|
render={({ field }) => ( |
||||
|
<FormItem> |
||||
|
<FormLabel>Signing Secret</FormLabel> |
||||
|
<FormControl> |
||||
|
<div className='flex space-x-2'> |
||||
|
<Input {...field} type='text' /> |
||||
|
<Button |
||||
|
type='button' |
||||
|
variant='outline' |
||||
|
onClick={() => field.onChange(uuidv4())} |
||||
|
> |
||||
|
Generate |
||||
|
</Button> |
||||
|
</div> |
||||
|
</FormControl> |
||||
|
<FormDescription> |
||||
|
Used to verify webhook payload authenticity |
||||
|
</FormDescription> |
||||
|
<FormMessage /> |
||||
|
</FormItem> |
||||
|
)} |
||||
|
/> |
||||
|
<FormField |
||||
|
control={form.control} |
||||
|
name='events' |
||||
|
render={({ field }) => ( |
||||
|
<FormItem> |
||||
|
<FormLabel>Events</FormLabel> |
||||
|
<Select |
||||
|
value={field.value[0]} |
||||
|
onValueChange={(value) => field.onChange([value])} |
||||
|
> |
||||
|
<FormControl> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder='Select events to subscribe to' /> |
||||
|
</SelectTrigger> |
||||
|
</FormControl> |
||||
|
<SelectContent> |
||||
|
<SelectItem value={WEBHOOK_EVENTS.MESSAGE_RECEIVED}> |
||||
|
SMS Received |
||||
|
</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
<FormDescription> |
||||
|
Choose the events you want to receive notifications for |
||||
|
</FormDescription> |
||||
|
<FormMessage /> |
||||
|
</FormItem> |
||||
|
)} |
||||
|
/> |
||||
|
<div className='flex justify-end space-x-2'> |
||||
|
<Button |
||||
|
type='button' |
||||
|
variant='outline' |
||||
|
onClick={() => onOpenChange(false)} |
||||
|
> |
||||
|
Cancel |
||||
|
</Button> |
||||
|
<Button |
||||
|
type='submit' |
||||
|
disabled={createWebhookMutation.isPending} |
||||
|
> |
||||
|
{createWebhookMutation.isPending ? 'Creating...' : 'Create'} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</Form> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
'use client' |
||||
|
|
||||
|
import { |
||||
|
AlertDialog, |
||||
|
AlertDialogAction, |
||||
|
AlertDialogCancel, |
||||
|
AlertDialogContent, |
||||
|
AlertDialogDescription, |
||||
|
AlertDialogFooter, |
||||
|
AlertDialogHeader, |
||||
|
AlertDialogTitle, |
||||
|
AlertDialogTrigger, |
||||
|
} from '@/components/ui/alert-dialog' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Trash2 } from 'lucide-react' |
||||
|
|
||||
|
interface DeleteWebhookButtonProps { |
||||
|
onDelete: () => void |
||||
|
} |
||||
|
|
||||
|
export function DeleteWebhookButton({ onDelete }: DeleteWebhookButtonProps) { |
||||
|
return ( |
||||
|
<AlertDialog> |
||||
|
<AlertDialogTrigger asChild> |
||||
|
<Button variant='outline' size='sm' disabled> |
||||
|
<Trash2 className='h-4 w-4 text-destructive' /> |
||||
|
</Button> |
||||
|
</AlertDialogTrigger> |
||||
|
<AlertDialogContent> |
||||
|
<AlertDialogHeader> |
||||
|
<AlertDialogTitle>Delete Webhook</AlertDialogTitle> |
||||
|
<AlertDialogDescription> |
||||
|
Are you sure you want to delete this webhook? This action cannot be |
||||
|
undone. |
||||
|
</AlertDialogDescription> |
||||
|
</AlertDialogHeader> |
||||
|
<AlertDialogFooter> |
||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel> |
||||
|
<AlertDialogAction |
||||
|
onClick={onDelete} |
||||
|
disabled |
||||
|
className='bg-destructive text-destructive-foreground hover:bg-destructive/90' |
||||
|
> |
||||
|
Delete |
||||
|
</AlertDialogAction> |
||||
|
</AlertDialogFooter> |
||||
|
</AlertDialogContent> |
||||
|
</AlertDialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,201 @@ |
|||||
|
'use client' |
||||
|
|
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogDescription, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { |
||||
|
Form, |
||||
|
FormControl, |
||||
|
FormDescription, |
||||
|
FormField, |
||||
|
FormItem, |
||||
|
FormLabel, |
||||
|
FormMessage, |
||||
|
} from '@/components/ui/form' |
||||
|
import { |
||||
|
Select, |
||||
|
SelectContent, |
||||
|
SelectItem, |
||||
|
SelectTrigger, |
||||
|
SelectValue, |
||||
|
} from '@/components/ui/select' |
||||
|
import { zodResolver } from '@hookform/resolvers/zod' |
||||
|
import { useForm } from 'react-hook-form' |
||||
|
import * as z from 'zod' |
||||
|
import { useEffect, useState } from 'react' |
||||
|
import { v4 as uuidv4 } from 'uuid' |
||||
|
import { WebhookData } from '@/lib/types' |
||||
|
import { WEBHOOK_EVENTS } from '@/lib/constants' |
||||
|
import httpBrowserClient from '@/lib/httpBrowserClient' |
||||
|
import { ApiEndpoints } from '@/config/api' |
||||
|
import { useToast } from '@/hooks/use-toast' |
||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query' |
||||
|
|
||||
|
const formSchema = z.object({ |
||||
|
deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }), |
||||
|
events: z.array(z.string()).min(1, { message: 'Select at least one event' }), |
||||
|
isActive: z.boolean().default(true), |
||||
|
signingSecret: z.string().min(1, { message: 'Signing secret is required' }), |
||||
|
}) |
||||
|
|
||||
|
interface EditWebhookDialogProps { |
||||
|
open: boolean |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
webhook: WebhookData |
||||
|
} |
||||
|
|
||||
|
export function EditWebhookDialog({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
webhook, |
||||
|
}: EditWebhookDialogProps) { |
||||
|
const queryClient = useQueryClient() |
||||
|
const { toast } = useToast() |
||||
|
|
||||
|
const form = useForm<z.infer<typeof formSchema>>({ |
||||
|
resolver: zodResolver(formSchema), |
||||
|
values: { |
||||
|
deliveryUrl: webhook.deliveryUrl, |
||||
|
events: webhook.events, |
||||
|
isActive: webhook.isActive, |
||||
|
signingSecret: webhook.signingSecret, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const { mutate: updateWebhook, isPending } = useMutation({ |
||||
|
mutationFn: async (values: z.infer<typeof formSchema>) => { |
||||
|
return httpBrowserClient.patch( |
||||
|
ApiEndpoints.gateway.updateWebhook(webhook._id), |
||||
|
values |
||||
|
) |
||||
|
}, |
||||
|
onSuccess: () => { |
||||
|
toast({ |
||||
|
title: 'Success', |
||||
|
description: 'Webhook updated successfully', |
||||
|
}) |
||||
|
// Invalidate and refetch webhooks list
|
||||
|
queryClient.invalidateQueries({ queryKey: ['webhooks'] }) |
||||
|
onOpenChange(false) |
||||
|
}, |
||||
|
onError: () => { |
||||
|
toast({ |
||||
|
title: 'Error', |
||||
|
description: 'Failed to update webhook', |
||||
|
variant: 'destructive', |
||||
|
}) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const onSubmit = (values: z.infer<typeof formSchema>) => { |
||||
|
updateWebhook(values) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent className='sm:max-w-[500px]'> |
||||
|
<DialogHeader> |
||||
|
<DialogTitle>Edit Webhook</DialogTitle> |
||||
|
<DialogDescription> |
||||
|
Update your webhook configuration. |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
<Form {...form}> |
||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'> |
||||
|
<FormField |
||||
|
control={form.control} |
||||
|
name='deliveryUrl' |
||||
|
render={({ field }) => ( |
||||
|
<FormItem> |
||||
|
<FormLabel>Delivery URL</FormLabel> |
||||
|
<FormControl> |
||||
|
<Input |
||||
|
placeholder='https://api.example.com/webhooks' |
||||
|
{...field} |
||||
|
/> |
||||
|
</FormControl> |
||||
|
<FormDescription> |
||||
|
The URL where webhook notifications will be sent via POST |
||||
|
requests |
||||
|
</FormDescription> |
||||
|
<FormMessage /> |
||||
|
</FormItem> |
||||
|
)} |
||||
|
/> |
||||
|
<FormField |
||||
|
control={form.control} |
||||
|
name='signingSecret' |
||||
|
render={({ field }) => ( |
||||
|
<FormItem> |
||||
|
<FormLabel>Signing Secret</FormLabel> |
||||
|
<FormControl> |
||||
|
<div className='flex space-x-2'> |
||||
|
<Input {...field} type='text' /> |
||||
|
<Button |
||||
|
type='button' |
||||
|
variant='outline' |
||||
|
onClick={() => field.onChange(uuidv4())} |
||||
|
> |
||||
|
Generate |
||||
|
</Button> |
||||
|
</div> |
||||
|
</FormControl> |
||||
|
<FormDescription> |
||||
|
Used to verify webhook payload authenticity |
||||
|
</FormDescription> |
||||
|
<FormMessage /> |
||||
|
</FormItem> |
||||
|
)} |
||||
|
/> |
||||
|
<FormField |
||||
|
control={form.control} |
||||
|
name='events' |
||||
|
render={({ field }) => ( |
||||
|
<FormItem> |
||||
|
<FormLabel>Events</FormLabel> |
||||
|
<Select |
||||
|
value={field.value[0]} |
||||
|
onValueChange={(value) => field.onChange([value])} |
||||
|
> |
||||
|
<FormControl> |
||||
|
<SelectTrigger> |
||||
|
<SelectValue placeholder='Select events to subscribe to' /> |
||||
|
</SelectTrigger> |
||||
|
</FormControl> |
||||
|
<SelectContent> |
||||
|
<SelectItem value={WEBHOOK_EVENTS.MESSAGE_RECEIVED}> |
||||
|
SMS Received |
||||
|
</SelectItem> |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
<FormDescription> |
||||
|
Choose the events you want to receive notifications for |
||||
|
</FormDescription> |
||||
|
<FormMessage /> |
||||
|
</FormItem> |
||||
|
)} |
||||
|
/> |
||||
|
<div className='flex justify-end space-x-2'> |
||||
|
<Button |
||||
|
type='button' |
||||
|
variant='outline' |
||||
|
onClick={() => onOpenChange(false)} |
||||
|
> |
||||
|
Cancel |
||||
|
</Button> |
||||
|
<Button type='submit' disabled={isPending}> |
||||
|
{isPending ? 'Updating...' : 'Update'} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</Form> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,139 @@ |
|||||
|
'use client' |
||||
|
|
||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { DeleteWebhookButton } from './delete-webhook-button' |
||||
|
import { Edit2, Eye, EyeOff } from 'lucide-react' |
||||
|
import { Switch } from '@/components/ui/switch' |
||||
|
import { useState } from 'react' |
||||
|
import { useToast } from '@/hooks/use-toast' |
||||
|
import { CopyButton } from '@/components/shared/copy-button' |
||||
|
import { WebhookData } from '@/lib/types' |
||||
|
import httpBrowserClient from '@/lib/httpBrowserClient' |
||||
|
import { ApiEndpoints } from '@/config/api' |
||||
|
import { useQueryClient } from '@tanstack/react-query' |
||||
|
|
||||
|
interface WebhookCardProps { |
||||
|
webhook: WebhookData |
||||
|
onEdit: () => void |
||||
|
onDelete?: () => void |
||||
|
} |
||||
|
|
||||
|
export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) { |
||||
|
const { toast } = useToast() |
||||
|
const [isLoading, setIsLoading] = useState(false) |
||||
|
const queryClient = useQueryClient() |
||||
|
const [showSecret, setShowSecret] = useState(false) |
||||
|
|
||||
|
const handleToggle = async (checked: boolean) => { |
||||
|
setIsLoading(true) |
||||
|
try { |
||||
|
await httpBrowserClient.patch( |
||||
|
ApiEndpoints.gateway.updateWebhook(webhook._id), |
||||
|
{ isActive: checked } |
||||
|
) |
||||
|
|
||||
|
await queryClient.invalidateQueries({ |
||||
|
queryKey: ['webhooks'] |
||||
|
}) |
||||
|
|
||||
|
toast({ |
||||
|
title: `Webhook ${checked ? 'enabled' : 'disabled'}`, |
||||
|
description: `Webhook notifications are now ${ |
||||
|
checked ? 'enabled' : 'disabled' |
||||
|
}.`,
|
||||
|
}) |
||||
|
} catch (error) { |
||||
|
toast({ |
||||
|
title: 'Error', |
||||
|
description: `Failed to ${checked ? 'enable' : 'disable'} webhook`, |
||||
|
variant: 'destructive', |
||||
|
}) |
||||
|
} finally { |
||||
|
setIsLoading(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const maskSecret = (secret: string) => { |
||||
|
// if the secret is less than 18 characters, show all
|
||||
|
if (secret.length <= 18) { |
||||
|
return secret.slice(0, 18) |
||||
|
} |
||||
|
return secret.slice(0, 18) + '*'.repeat(secret.length - 24) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<Card> |
||||
|
<CardHeader className='flex flex-row items-center justify-between'> |
||||
|
<div className='space-y-1'> |
||||
|
<div className='flex items-center space-x-2'> |
||||
|
<h3 className='text-lg font-semibold'>Webhook Endpoint</h3> |
||||
|
<Badge variant={webhook.isActive ? 'default' : 'secondary'}> |
||||
|
{webhook.isActive ? 'Active' : 'Inactive'} |
||||
|
</Badge> |
||||
|
</div> |
||||
|
<p className='text-sm text-muted-foreground'> |
||||
|
Notifications for SMS events |
||||
|
</p> |
||||
|
</div> |
||||
|
<div className='flex items-center space-x-2'> |
||||
|
<Switch |
||||
|
checked={webhook.isActive} |
||||
|
onCheckedChange={handleToggle} |
||||
|
disabled={isLoading} |
||||
|
/> |
||||
|
<Button variant='outline' size='sm' onClick={onEdit}> |
||||
|
<Edit2 className='h-4 w-4 mr-2' /> |
||||
|
Edit |
||||
|
</Button> |
||||
|
<DeleteWebhookButton onDelete={onDelete} /> |
||||
|
</div> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className='space-y-4'> |
||||
|
<div> |
||||
|
<label className='text-sm font-medium'>Delivery URL</label> |
||||
|
<div className='flex items-center mt-1'> |
||||
|
<code className='flex-1 bg-muted px-3 py-2 rounded-md text-sm'> |
||||
|
{webhook.deliveryUrl} |
||||
|
</code> |
||||
|
<CopyButton value={webhook.deliveryUrl} label='Copy URL' /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div> |
||||
|
<label className='text-sm font-medium'>Signing Secret</label> |
||||
|
<div className='flex items-center mt-1'> |
||||
|
<code className='flex-1 bg-muted px-3 py-2 rounded-md text-sm font-mono'> |
||||
|
{showSecret ? webhook.signingSecret : maskSecret(webhook.signingSecret)} |
||||
|
</code> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="icon" |
||||
|
onClick={() => setShowSecret(!showSecret)} |
||||
|
className="mx-2" |
||||
|
> |
||||
|
{showSecret ? ( |
||||
|
<EyeOff className="h-4 w-4" /> |
||||
|
) : ( |
||||
|
<Eye className="h-4 w-4" /> |
||||
|
)} |
||||
|
</Button> |
||||
|
<CopyButton value={webhook.signingSecret} label='Copy Secret' /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div> |
||||
|
<label className='text-sm font-medium'>Events</label> |
||||
|
<div className='flex flex-wrap gap-2 mt-1'> |
||||
|
{webhook.events.map((event) => ( |
||||
|
<Badge key={event} variant='secondary'> |
||||
|
{event} |
||||
|
</Badge> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,169 @@ |
|||||
|
'use client' |
||||
|
|
||||
|
import { |
||||
|
Accordion, |
||||
|
AccordionContent, |
||||
|
AccordionItem, |
||||
|
AccordionTrigger, |
||||
|
} from '@/components/ui/accordion' |
||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
||||
|
import { Code } from '@/components/ui/code' |
||||
|
import { AlertCircle } from 'lucide-react' |
||||
|
|
||||
|
const SAMPLE_PAYLOAD = { |
||||
|
smsId: 'smsId', |
||||
|
sender: '+123456789', |
||||
|
message: 'message', |
||||
|
receivedAt: 'datetime', |
||||
|
deviceId: 'deviceId', |
||||
|
webhookSubscriptionId: 'webhookSubscriptionId', |
||||
|
webhookEvent: 'sms.received', |
||||
|
} |
||||
|
|
||||
|
const VERIFICATION_CODE = `
|
||||
|
// Node.js example using crypto
|
||||
|
const crypto = require('crypto'); |
||||
|
|
||||
|
function verifyWebhookSignature(payload, signature, secret) { |
||||
|
const hmac = crypto.createHmac('sha256', secret); |
||||
|
const digest = hmac.update(JSON.stringify(payload)).digest('hex'); |
||||
|
const signatureHash = signature.split('=')[1]; |
||||
|
|
||||
|
return crypto.timingSafeEqual( |
||||
|
Buffer.from(signatureHash), |
||||
|
Buffer.from(digest) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Express middleware example
|
||||
|
app.post('/webhook', (req, res) => { |
||||
|
const signature = req.headers['x-signature']; |
||||
|
const payload = req.body; |
||||
|
|
||||
|
if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) { |
||||
|
return res.status(401).json({ error: 'Invalid signature' }); |
||||
|
} |
||||
|
|
||||
|
// Process the webhook
|
||||
|
console.log('Webhook verified:', payload); |
||||
|
res.status(200).send('OK'); |
||||
|
}); |
||||
|
`
|
||||
|
|
||||
|
const PYTHON_CODE = `
|
||||
|
# Python example using hmac |
||||
|
import hmac |
||||
|
import hashlib |
||||
|
import json |
||||
|
from flask import Flask, request |
||||
|
|
||||
|
app = Flask(__name__) |
||||
|
|
||||
|
def verify_signature(payload, signature, secret): |
||||
|
expected = hmac.new( |
||||
|
secret.encode('utf-8'), |
||||
|
json.dumps(payload).encode('utf-8'), |
||||
|
hashlib.sha256 |
||||
|
).hexdigest() |
||||
|
return hmac.compare_digest(signature.split('=')[1], expected) |
||||
|
|
||||
|
@app.route('/webhook', methods=['POST']) |
||||
|
def webhook(): |
||||
|
signature = request.headers.get('X-Signature') |
||||
|
if not verify_signature(request.json, signature, WEBHOOK_SECRET): |
||||
|
return 'Invalid signature', 401 |
||||
|
|
||||
|
# Process the webhook |
||||
|
print('Webhook verified:', request.json) |
||||
|
return 'OK', 200 |
||||
|
`
|
||||
|
|
||||
|
export function WebhookDocs() { |
||||
|
return ( |
||||
|
<Accordion type='multiple' className='w-full space-y-4'> |
||||
|
<AccordionItem value='delivery' className='border rounded-lg'> |
||||
|
<AccordionTrigger className='px-4 hover:no-underline [&[data-state=open]>div]:bg-muted'> |
||||
|
<div className='flex items-center gap-2 py-2 -my-2 px-2 rounded-md'> |
||||
|
<AlertCircle className='h-4 w-4' /> |
||||
|
<span>Webhook Delivery Information</span> |
||||
|
</div> |
||||
|
</AccordionTrigger> |
||||
|
<AccordionContent className='px-4 pb-4'> |
||||
|
<div className='space-y-2 mt-2 text-sm text-muted-foreground'> |
||||
|
<p> |
||||
|
When a new SMS is received, we'll send a POST request to your |
||||
|
webhook URL with the event data. Your endpoint should: |
||||
|
</p> |
||||
|
<ul className='list-disc pl-6 space-y-1'> |
||||
|
<li>Accept POST requests</li> |
||||
|
<li>Return a 2XX status code to acknowledge receipt</li> |
||||
|
<li>Process the request within 10 seconds</li> |
||||
|
</ul> |
||||
|
<p className='mt-2'> |
||||
|
If we don't receive a successful response, we'll retry the |
||||
|
delivery at increasing intervals: 3 minutes, 5 minutes, 30 minutes, |
||||
|
1 hour, 6 hours, 1 day, 3 days, 7 days, 30 days. |
||||
|
</p> |
||||
|
</div> |
||||
|
</AccordionContent> |
||||
|
</AccordionItem> |
||||
|
|
||||
|
<AccordionItem value='implementation' className='border rounded-lg'> |
||||
|
<AccordionTrigger className='px-4 hover:no-underline [&[data-state=open]>div]:bg-muted'> |
||||
|
<div className='flex items-center gap-2 py-2 -my-2 px-2 rounded-md'> |
||||
|
<AlertCircle className='h-4 w-4' /> |
||||
|
<span>Security & Implementation Guide</span> |
||||
|
</div> |
||||
|
</AccordionTrigger> |
||||
|
<AccordionContent className='px-4 pb-4'> |
||||
|
<Tabs defaultValue='overview' className='w-full mt-4'> |
||||
|
<TabsList> |
||||
|
<TabsTrigger value='overview'>Overview</TabsTrigger> |
||||
|
<TabsTrigger value='payload'>Payload</TabsTrigger> |
||||
|
<TabsTrigger value='verification'>Verification</TabsTrigger> |
||||
|
</TabsList> |
||||
|
|
||||
|
<TabsContent value='overview'> |
||||
|
<div className='space-y-2 mt-4 text-sm text-muted-foreground'> |
||||
|
<p>Each webhook request includes:</p> |
||||
|
<ul className='list-disc pl-6 space-y-1'> |
||||
|
<li>Payload in JSON format</li> |
||||
|
<li>X-Signature header for verification</li> |
||||
|
<li> |
||||
|
Signature format: sha256=HMAC_SHA256(payload, secret) |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
<TabsContent value='payload'> |
||||
|
<div className='space-y-4 mt-4'> |
||||
|
<h4 className='text-sm font-medium'>Sample Payload</h4> |
||||
|
<Code>{JSON.stringify(SAMPLE_PAYLOAD, null, 2)}</Code> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
|
||||
|
<TabsContent value='verification'> |
||||
|
<div className='space-y-4 mt-4'> |
||||
|
<Tabs defaultValue='node'> |
||||
|
<TabsList> |
||||
|
<TabsTrigger value='node'>Node.js</TabsTrigger> |
||||
|
<TabsTrigger value='python'>Python</TabsTrigger> |
||||
|
</TabsList> |
||||
|
|
||||
|
<TabsContent value='node'> |
||||
|
<Code>{VERIFICATION_CODE}</Code> |
||||
|
</TabsContent> |
||||
|
|
||||
|
<TabsContent value='python'> |
||||
|
<Code>{PYTHON_CODE}</Code> |
||||
|
</TabsContent> |
||||
|
</Tabs> |
||||
|
</div> |
||||
|
</TabsContent> |
||||
|
</Tabs> |
||||
|
</AccordionContent> |
||||
|
</AccordionItem> |
||||
|
</Accordion> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,178 @@ |
|||||
|
'use client' |
||||
|
|
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { PlusCircle, Webhook } from 'lucide-react' |
||||
|
import { useState } from 'react' |
||||
|
import { WebhookData } from '@/lib/types' |
||||
|
import { WebhookCard } from './webhook-card' |
||||
|
import { WebhookDocs } from './webhook-docs' |
||||
|
import { CreateWebhookDialog } from './create-webhook-dialog' |
||||
|
import { EditWebhookDialog } from './edit-webhook-dialog' |
||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query' |
||||
|
import httpBrowserClient from '@/lib/httpBrowserClient' |
||||
|
import { ApiEndpoints } from '@/config/api' |
||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||
|
import { |
||||
|
Tooltip, |
||||
|
TooltipContent, |
||||
|
TooltipProvider, |
||||
|
TooltipTrigger, |
||||
|
} from "@/components/ui/tooltip" |
||||
|
|
||||
|
function WebhookCardSkeleton() { |
||||
|
return ( |
||||
|
<div className='rounded-lg border p-6 space-y-4'> |
||||
|
<div className='flex items-center justify-between'> |
||||
|
<div className='space-y-2'> |
||||
|
<Skeleton className='h-5 w-[200px]' /> |
||||
|
<Skeleton className='h-4 w-[150px]' /> |
||||
|
</div> |
||||
|
<div className='flex space-x-2'> |
||||
|
<Skeleton className='h-9 w-9' /> |
||||
|
<Skeleton className='h-9 w-16' /> |
||||
|
<Skeleton className='h-9 w-9' /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className='space-y-4'> |
||||
|
<div> |
||||
|
<Skeleton className='h-4 w-[100px] mb-2' /> |
||||
|
<Skeleton className='h-10 w-full' /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<Skeleton className='h-4 w-[100px] mb-2' /> |
||||
|
<Skeleton className='h-10 w-full' /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<Skeleton className='h-4 w-[100px] mb-2' /> |
||||
|
<div className='flex gap-2'> |
||||
|
<Skeleton className='h-6 w-20' /> |
||||
|
<Skeleton className='h-6 w-20' /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default function WebhooksSection() { |
||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false) |
||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false) |
||||
|
const [selectedWebhook, setSelectedWebhook] = useState<WebhookData | null>( |
||||
|
null |
||||
|
) |
||||
|
const queryClient = useQueryClient() |
||||
|
|
||||
|
const { |
||||
|
data: webhooks, |
||||
|
isLoading, |
||||
|
error, |
||||
|
} = useQuery({ |
||||
|
queryKey: ['webhooks'], |
||||
|
queryFn: () => |
||||
|
httpBrowserClient |
||||
|
.get(ApiEndpoints.gateway.getWebhooks()) |
||||
|
.then((res) => res.data), |
||||
|
}) |
||||
|
|
||||
|
const handleCreateClick = () => { |
||||
|
setCreateDialogOpen(true) |
||||
|
} |
||||
|
|
||||
|
const handleEditClick = (webhook: WebhookData) => { |
||||
|
setSelectedWebhook(webhook) |
||||
|
setEditDialogOpen(true) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className='container mx-auto py-8'> |
||||
|
<div className='flex justify-between items-center mb-8'> |
||||
|
<div> |
||||
|
<h1 className='text-3xl font-bold flex items-center gap-2'> |
||||
|
<Webhook className='h-8 w-8' /> |
||||
|
Webhooks |
||||
|
<TooltipProvider> |
||||
|
<Tooltip> |
||||
|
<TooltipTrigger> |
||||
|
<span className='text-xs font-medium px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200'> |
||||
|
BETA |
||||
|
</span> |
||||
|
</TooltipTrigger> |
||||
|
<TooltipContent> |
||||
|
<p>This feature is in beta and may undergo changes. Use with caution in production environments.</p> |
||||
|
</TooltipContent> |
||||
|
</Tooltip> |
||||
|
</TooltipProvider> |
||||
|
</h1> |
||||
|
<p className='text-muted-foreground mt-2'> |
||||
|
Manage webhook notifications for your SMS events |
||||
|
</p> |
||||
|
</div> |
||||
|
<Button |
||||
|
onClick={handleCreateClick} |
||||
|
disabled={webhooks?.data?.length > 0 || isLoading} |
||||
|
variant='default' |
||||
|
> |
||||
|
<PlusCircle className='mr-2 h-4 w-4' /> |
||||
|
Create Webhook |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className='grid grid-cols-1 lg:grid-cols-2 gap-8'> |
||||
|
<div> |
||||
|
{isLoading ? ( |
||||
|
<div className='grid gap-4'> |
||||
|
<WebhookCardSkeleton /> |
||||
|
<WebhookCardSkeleton /> |
||||
|
</div> |
||||
|
) : error ? ( |
||||
|
<div className='rounded-lg border border-destructive/50 p-4 text-destructive'> |
||||
|
Error: {error.message} |
||||
|
</div> |
||||
|
) : webhooks?.data?.length > 0 ? ( |
||||
|
<div className='grid gap-4'> |
||||
|
{webhooks.data.map((webhook) => ( |
||||
|
<WebhookCard |
||||
|
key={webhook._id} |
||||
|
webhook={webhook} |
||||
|
onEdit={() => handleEditClick(webhook)} |
||||
|
/> |
||||
|
))} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className='bg-muted/50 rounded-lg p-8 text-center'> |
||||
|
<h3 className='text-lg font-medium mb-2'>No webhook configured</h3> |
||||
|
<p className='text-muted-foreground mb-4'> |
||||
|
Create a webhook to receive real-time notifications for SMS events |
||||
|
</p> |
||||
|
<Button onClick={handleCreateClick} variant='default'> |
||||
|
<PlusCircle className='mr-2 h-4 w-4' /> |
||||
|
Create Webhook |
||||
|
</Button> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className='hidden lg:block sticky top-8 self-start'> |
||||
|
<WebhookDocs /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className='block lg:hidden mt-8'> |
||||
|
<WebhookDocs /> |
||||
|
</div> |
||||
|
|
||||
|
<CreateWebhookDialog |
||||
|
open={createDialogOpen} |
||||
|
onOpenChange={setCreateDialogOpen} |
||||
|
/> |
||||
|
|
||||
|
{selectedWebhook && ( |
||||
|
<EditWebhookDialog |
||||
|
open={editDialogOpen} |
||||
|
onOpenChange={setEditDialogOpen} |
||||
|
webhook={selectedWebhook} |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { Button } from "@/components/ui/button"; |
||||
|
import { Check, Copy } from "lucide-react"; |
||||
|
import { useState } from "react"; |
||||
|
import { useToast } from "@/hooks/use-toast"; |
||||
|
|
||||
|
interface CopyButtonProps { |
||||
|
value: string; |
||||
|
label: string; |
||||
|
} |
||||
|
|
||||
|
export function CopyButton({ value, label }: CopyButtonProps) { |
||||
|
const [copied, setCopied] = useState(false); |
||||
|
const { toast } = useToast(); |
||||
|
|
||||
|
const copyToClipboard = async () => { |
||||
|
try { |
||||
|
await navigator.clipboard.writeText(value); |
||||
|
setCopied(true); |
||||
|
toast({ |
||||
|
title: "Copied!", |
||||
|
description: `${label} copied to clipboard`, |
||||
|
}); |
||||
|
setTimeout(() => setCopied(false), 2000); |
||||
|
} catch (err) { |
||||
|
toast({ |
||||
|
title: "Failed to copy", |
||||
|
description: "Please try again", |
||||
|
variant: "destructive", |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={copyToClipboard} |
||||
|
className="ml-2" |
||||
|
> |
||||
|
{copied ? ( |
||||
|
<Check className="h-4 w-4 text-green-500" /> |
||||
|
) : ( |
||||
|
<Copy className="h-4 w-4" /> |
||||
|
)} |
||||
|
</Button> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
"use client" |
||||
|
|
||||
|
import * as React from "react" |
||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" |
||||
|
|
||||
|
import { cn } from "@/lib/utils" |
||||
|
import { buttonVariants } from "@/components/ui/button" |
||||
|
|
||||
|
const AlertDialog = AlertDialogPrimitive.Root |
||||
|
|
||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger |
||||
|
|
||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal |
||||
|
|
||||
|
const AlertDialogOverlay = React.forwardRef< |
||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, |
||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<AlertDialogPrimitive.Overlay |
||||
|
className={cn( |
||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
ref={ref} |
||||
|
/> |
||||
|
)) |
||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName |
||||
|
|
||||
|
const AlertDialogContent = React.forwardRef< |
||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>, |
||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<AlertDialogPortal> |
||||
|
<AlertDialogOverlay /> |
||||
|
<AlertDialogPrimitive.Content |
||||
|
ref={ref} |
||||
|
className={cn( |
||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
</AlertDialogPortal> |
||||
|
)) |
||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName |
||||
|
|
||||
|
const AlertDialogHeader = ({ |
||||
|
className, |
||||
|
...props |
||||
|
}: React.HTMLAttributes<HTMLDivElement>) => ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
"flex flex-col space-y-2 text-center sm:text-left", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
AlertDialogHeader.displayName = "AlertDialogHeader" |
||||
|
|
||||
|
const AlertDialogFooter = ({ |
||||
|
className, |
||||
|
...props |
||||
|
}: React.HTMLAttributes<HTMLDivElement>) => ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
AlertDialogFooter.displayName = "AlertDialogFooter" |
||||
|
|
||||
|
const AlertDialogTitle = React.forwardRef< |
||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>, |
||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<AlertDialogPrimitive.Title |
||||
|
ref={ref} |
||||
|
className={cn("text-lg font-semibold", className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName |
||||
|
|
||||
|
const AlertDialogDescription = React.forwardRef< |
||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>, |
||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<AlertDialogPrimitive.Description |
||||
|
ref={ref} |
||||
|
className={cn("text-sm text-muted-foreground", className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
AlertDialogDescription.displayName = |
||||
|
AlertDialogPrimitive.Description.displayName |
||||
|
|
||||
|
const AlertDialogAction = React.forwardRef< |
||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>, |
||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<AlertDialogPrimitive.Action |
||||
|
ref={ref} |
||||
|
className={cn(buttonVariants(), className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName |
||||
|
|
||||
|
const AlertDialogCancel = React.forwardRef< |
||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, |
||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> |
||||
|
>(({ className, ...props }, ref) => ( |
||||
|
<AlertDialogPrimitive.Cancel |
||||
|
ref={ref} |
||||
|
className={cn( |
||||
|
buttonVariants({ variant: "outline" }), |
||||
|
"mt-2 sm:mt-0", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
)) |
||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName |
||||
|
|
||||
|
export { |
||||
|
AlertDialog, |
||||
|
AlertDialogPortal, |
||||
|
AlertDialogOverlay, |
||||
|
AlertDialogTrigger, |
||||
|
AlertDialogContent, |
||||
|
AlertDialogHeader, |
||||
|
AlertDialogFooter, |
||||
|
AlertDialogTitle, |
||||
|
AlertDialogDescription, |
||||
|
AlertDialogAction, |
||||
|
AlertDialogCancel, |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
import { cn } from "@/lib/utils"; |
||||
|
|
||||
|
interface CodeProps extends React.HTMLAttributes<HTMLPreElement> {} |
||||
|
|
||||
|
export function Code({ className, children, ...props }: CodeProps) { |
||||
|
return ( |
||||
|
<pre |
||||
|
className={cn( |
||||
|
"rounded-lg bg-muted p-4 overflow-x-auto text-sm", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
> |
||||
|
<code>{children}</code> |
||||
|
</pre> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { cn } from "@/lib/utils" |
||||
|
|
||||
|
function Skeleton({ |
||||
|
className, |
||||
|
...props |
||||
|
}: React.HTMLAttributes<HTMLDivElement>) { |
||||
|
return ( |
||||
|
<div |
||||
|
className={cn("animate-pulse rounded-md bg-primary/10", className)} |
||||
|
{...props} |
||||
|
/> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export { Skeleton } |
||||
@ -0,0 +1,32 @@ |
|||||
|
"use client" |
||||
|
|
||||
|
import * as React from "react" |
||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip" |
||||
|
|
||||
|
import { cn } from "@/lib/utils" |
||||
|
|
||||
|
const TooltipProvider = TooltipPrimitive.Provider |
||||
|
|
||||
|
const Tooltip = TooltipPrimitive.Root |
||||
|
|
||||
|
const TooltipTrigger = TooltipPrimitive.Trigger |
||||
|
|
||||
|
const TooltipContent = React.forwardRef< |
||||
|
React.ElementRef<typeof TooltipPrimitive.Content>, |
||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> |
||||
|
>(({ className, sideOffset = 4, ...props }, ref) => ( |
||||
|
<TooltipPrimitive.Portal> |
||||
|
<TooltipPrimitive.Content |
||||
|
ref={ref} |
||||
|
sideOffset={sideOffset} |
||||
|
className={cn( |
||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", |
||||
|
className |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
</TooltipPrimitive.Portal> |
||||
|
)) |
||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName |
||||
|
|
||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } |
||||
@ -0,0 +1,3 @@ |
|||||
|
export const WEBHOOK_EVENTS = { |
||||
|
MESSAGE_RECEIVED: 'MESSAGE_RECEIVED', |
||||
|
} as const |
||||
@ -0,0 +1,17 @@ |
|||||
|
export interface WebhookData { |
||||
|
_id?: string |
||||
|
deliveryUrl: string |
||||
|
events: string[] |
||||
|
isActive: boolean |
||||
|
signingSecret: string |
||||
|
} |
||||
|
|
||||
|
export interface WebhookPayload { |
||||
|
smsId: string |
||||
|
sender: string |
||||
|
message: string |
||||
|
receivedAt: string |
||||
|
deviceId: string |
||||
|
webhookSubscriptionId: string |
||||
|
webhookEvent: string |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue