Browse Source

Webhooks with HMAC signing, SSRF guard, retry, and i18n (#159)

* improved public and private notes

* ai write location

* tooltip

* improvements

* test

* webhooks

* fix(webhooks): replace ICU-reserved <tag> placeholders that broke next-intl

The helpVerify string used `<timestamp>.<raw body>` which next-intl's
ICU MessageFormat parser interprets as markup tags, throwing
INVALID_MESSAGE: INVALID_TAG and crashing the settings page. Replaced
with `[timestamp].[body]` across all 12 locales and updated the matching
literal in the request-shape preview JSX for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* migrations

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bernt Christian Egeland 5 tháng trước cách đây
mục cha
commit
aea0e2ffb6

+ 103 - 0
messages/de/settings.json

@@ -7,6 +7,7 @@
       "billing": "Abrechnung & Dokumente",
       "billing": "Abrechnung & Dokumente",
       "communications": "Kommunikation",
       "communications": "Kommunikation",
       "workshop": "Werkstatt",
       "workshop": "Werkstatt",
+      "integrations": "Integrationen",
       "system": "System"
       "system": "System"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Berichtsplanung",
         "title": "Berichtsplanung",
         "description": "Automatischer Berichtsversand"
         "description": "Automatischer Berichtsversand"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Echtzeit-Ereignisbenachrichtigungen"
+      },
       "appearance": {
       "appearance": {
         "title": "Darstellung",
         "title": "Darstellung",
         "description": "Design & Anzeige"
         "description": "Design & Anzeige"
@@ -1201,5 +1206,103 @@
       "retention": "Kundenbindung",
       "retention": "Kundenbindung",
       "inventory": "Inventar"
       "inventory": "Inventar"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Senden Sie Echtzeit-Ereignisbenachrichtigungen an Ihre eigenen Systeme über HTTPS. Jedes Ereignis wird mit HMAC-SHA256 signiert, sodass Sie die Authentizität überprüfen können.",
+    "create": "Webhook hinzufügen",
+    "createTitle": "Webhook hinzufügen",
+    "editTitle": "Webhook bearbeiten",
+    "createDescription": "Webhooks werden als JSON per POST gesendet. Fehlgeschlagene Zustellungen werden bis zu 5 Mal mit exponentiellem Backoff wiederholt.",
+    "name": "Name",
+    "namePlaceholder": "z. B. Buchhaltungs-Sync",
+    "url": "Endpunkt-URL",
+    "urlHint": "Muss mit http:// oder https:// beginnen. HTTPS wird dringend empfohlen.",
+    "description": "Beschreibung (optional)",
+    "events": "Ereignisse",
+    "eventsHint": "Wählen Sie aus, welche Ereignisse diesen Webhook auslösen.",
+    "subscribeToAll": "Alle Ereignisse",
+    "selectAtLeastOneEvent": "Wählen Sie mindestens ein Ereignis aus",
+    "groups": {
+      "customers": "Kunden",
+      "vehicles": "Fahrzeuge",
+      "services": "Serviceaufträge",
+      "quotes": "Angebote",
+      "payments": "Zahlungen",
+      "inspections": "Inspektionen",
+      "inventory": "Lagerbestand",
+      "findings": "Befunde"
+    },
+    "save": "Speichern",
+    "cancel": "Abbrechen",
+    "delete": "Löschen",
+    "rotate": "Erneuern",
+    "done": "Fertig",
+    "paused": "Pausiert",
+    "statusOk": "OK",
+    "statusFailing": "{count, plural, one {# kürzlicher Fehler} other {# kürzliche Fehler}}",
+    "lastDelivery": "Letzte Zustellung {when}",
+    "totalDeliveries": "{count, plural, one {# Zustellung} other {# Zustellungen}}",
+    "toggleAria": "Diesen Webhook aktivieren oder deaktivieren",
+    "deliveries": "Zustellungen",
+    "sendTest": "Test senden",
+    "testSent": "Test-Ereignis gesendet",
+    "testFailed": "Test-Ereignis konnte nicht gesendet werden",
+    "rotateSecret": "Signaturschlüssel erneuern",
+    "confirmRotateTitle": "Signaturschlüssel erneuern?",
+    "confirmRotateDescription": "Der aktuelle Schlüssel funktioniert sofort nicht mehr. Aktualisieren Sie Ihren Endpunkt mit dem neuen Schlüssel.",
+    "secretRotated": "Schlüssel erneuert",
+    "secretRotatedDescription": "Speichern Sie diesen Schlüssel jetzt — er kann später nicht wiederhergestellt werden.",
+    "confirmDeleteTitle": "Webhook löschen?",
+    "confirmDeleteDescription": "„{name}“ empfängt sofort keine Ereignisse mehr. Dies kann nicht rückgängig gemacht werden.",
+    "secretShownOnceTitle": "Webhook „{name}“ erstellt",
+    "secretShownOnceDescription": "Kopieren Sie diesen Signaturschlüssel jetzt. Er wird nicht erneut angezeigt.",
+    "secretCopyHint": "Verwenden Sie diesen Schlüssel, um den X-Torqvoice-Signature-Header bei jeder Anfrage zu überprüfen.",
+    "deliveriesTitle": "Zustellungen — {name}",
+    "deliveriesDescription": "Letzte 50 Versuche. Fehlgeschlagene Ereignisse werden automatisch mit exponentiellem Backoff wiederholt.",
+    "deliveriesEmpty": "Noch keine Zustellungen. Senden Sie ein Test-Ereignis, um es auszuprobieren.",
+    "attempt": "Versuch {n}/{max}",
+    "nextRetry": "Nächster Versuch {when}",
+    "retryQueued": "Wiederholung in der Warteschlange",
+    "retryFailed": "Wiederholung fehlgeschlagen",
+    "saveFailed": "Speichern fehlgeschlagen",
+    "deleteFailed": "Löschen fehlgeschlagen",
+    "toggleFailed": "Umschalten fehlgeschlagen",
+    "rotateFailed": "Erneuern fehlgeschlagen",
+    "created": "Webhook erstellt",
+    "updated": "Webhook aktualisiert",
+    "deleted": "Webhook gelöscht",
+    "emptyTitle": "Noch keine Webhooks",
+    "emptyDescription": "Fügen Sie einen Webhook hinzu, um Ereignisse an Ihre eigenen Systeme zu senden — Buchhaltungs-Sync, eigene Dashboards, Zapier, alles was HTTP spricht.",
+    "helpTitle": "Webhook-Signaturen überprüfen",
+    "helpIntro": "Jede Zustellung enthält eine HMAC-SHA256-Signatur, damit Sie bestätigen können, dass die Anfrage von Torqvoice stammt.",
+    "helpHeadersTitle": "Header, die mit jeder Anfrage gesendet werden",
+    "helpVerify": "Setzen Sie den signierten String als `[timestamp].[body]` zusammen und vergleichen Sie zeitsicher:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Ereignisreferenz",
+    "autoDisabled": "Automatisch pausiert",
+    "autoDisabledBanner": "Dieser Webhook wurde nach 20 aufeinanderfolgenden Fehlern automatisch pausiert. Aktivieren Sie ihn wieder, um die Zustellung fortzusetzen.",
+    "reEnable": "Wieder aktivieren",
+    "resumed": "Webhook fortgesetzt",
+    "copyUrl": "URL kopieren",
+    "refresh": "Aktualisieren",
+    "noMatches": "Keine Zustellungen entsprechen diesem Filter.",
+    "retry": "Wiederholen",
+    "viewPayload": "Payload anzeigen",
+    "payloadCopied": "Payload kopiert",
+    "loadingPayload": "Payload wird geladen…",
+    "requestPayload": "Anfrage-Payload",
+    "response": "Antwort",
+    "close": "Schließen",
+    "samplePayload": "Beispiel-Envelope",
+    "referenceTitle": "Ereignisreferenz",
+    "referenceIntro": "Jede Zustellung folgt dieser Envelope-Form. Klicken Sie auf ein Ereignis, um ein Beispiel-Payload zu sehen, das Ihr Endpunkt empfangen wird.",
+    "filter": {
+      "all": "Alle",
+      "success": "Erfolgreich",
+      "failed": "Fehlgeschlagen",
+      "retrying": "Wiederholt",
+      "pending": "Ausstehend"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/en/settings.json

@@ -7,6 +7,7 @@
       "billing": "Billing & Documents",
       "billing": "Billing & Documents",
       "communications": "Communications",
       "communications": "Communications",
       "workshop": "Workshop",
       "workshop": "Workshop",
+      "integrations": "Integrations",
       "system": "System"
       "system": "System"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Report Schedule",
         "title": "Report Schedule",
         "description": "Automated report delivery"
         "description": "Automated report delivery"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Real-time event notifications"
+      },
       "appearance": {
       "appearance": {
         "title": "Appearance",
         "title": "Appearance",
         "description": "Theme & display"
         "description": "Theme & display"
@@ -1201,5 +1206,103 @@
       "retention": "Customer Retention",
       "retention": "Customer Retention",
       "inventory": "Inventory"
       "inventory": "Inventory"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Send real-time event notifications to your own systems via HTTPS. Each event is signed with HMAC-SHA256 so you can verify authenticity.",
+    "create": "Add webhook",
+    "createTitle": "Add webhook",
+    "editTitle": "Edit webhook",
+    "createDescription": "Webhooks are POSTed as JSON. Failed deliveries are retried with exponential backoff up to 5 times.",
+    "name": "Name",
+    "namePlaceholder": "e.g. Accounting sync",
+    "url": "Endpoint URL",
+    "urlHint": "Must start with http:// or https://. We strongly recommend HTTPS.",
+    "description": "Description (optional)",
+    "events": "Events",
+    "eventsHint": "Choose which events trigger this webhook.",
+    "subscribeToAll": "All events",
+    "selectAtLeastOneEvent": "Select at least one event",
+    "groups": {
+      "customers": "Customers",
+      "vehicles": "Vehicles",
+      "services": "Service Records",
+      "quotes": "Quotes",
+      "payments": "Payments",
+      "inspections": "Inspections",
+      "inventory": "Inventory",
+      "findings": "Findings"
+    },
+    "save": "Save",
+    "cancel": "Cancel",
+    "delete": "Delete",
+    "rotate": "Rotate",
+    "done": "Done",
+    "paused": "Paused",
+    "statusOk": "Healthy",
+    "statusFailing": "{count, plural, one {# recent failure} other {# recent failures}}",
+    "lastDelivery": "Last delivery {when}",
+    "totalDeliveries": "{count, plural, one {# delivery} other {# deliveries}}",
+    "toggleAria": "Enable or disable this webhook",
+    "deliveries": "Deliveries",
+    "sendTest": "Send test",
+    "testSent": "Test event sent",
+    "testFailed": "Failed to send test event",
+    "rotateSecret": "Rotate signing secret",
+    "confirmRotateTitle": "Rotate signing secret?",
+    "confirmRotateDescription": "The current secret will stop working immediately. Update your endpoint with the new secret.",
+    "secretRotated": "Secret rotated",
+    "secretRotatedDescription": "Save this secret now — it cannot be recovered later.",
+    "confirmDeleteTitle": "Delete webhook?",
+    "confirmDeleteDescription": "“{name}” will stop receiving events immediately. This cannot be undone.",
+    "secretShownOnceTitle": "Webhook “{name}” created",
+    "secretShownOnceDescription": "Copy this signing secret now. It will not be shown again.",
+    "secretCopyHint": "Use this secret to verify the X-Torqvoice-Signature header on each request.",
+    "deliveriesTitle": "Deliveries — {name}",
+    "deliveriesDescription": "Last 50 attempts. Failed events are retried automatically with exponential backoff.",
+    "deliveriesEmpty": "No deliveries yet. Send a test event to try it out.",
+    "attempt": "Attempt {n}/{max}",
+    "nextRetry": "Next retry {when}",
+    "retryQueued": "Retry queued",
+    "retryFailed": "Retry failed",
+    "saveFailed": "Save failed",
+    "deleteFailed": "Delete failed",
+    "toggleFailed": "Toggle failed",
+    "rotateFailed": "Rotate failed",
+    "created": "Webhook created",
+    "updated": "Webhook updated",
+    "deleted": "Webhook deleted",
+    "emptyTitle": "No webhooks yet",
+    "emptyDescription": "Add a webhook to push events to your own systems — accounting sync, custom dashboards, Zapier, anything that speaks HTTP.",
+    "helpTitle": "Verifying webhook signatures",
+    "helpIntro": "Each delivery includes an HMAC-SHA256 signature so you can confirm the request came from Torqvoice.",
+    "helpHeadersTitle": "Headers sent with every request",
+    "helpVerify": "Reconstruct the signed string as `[timestamp].[body]` and compare with timing-safe equality:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Events Reference",
+    "autoDisabled": "Auto-paused",
+    "autoDisabledBanner": "This webhook was auto-paused after 20 consecutive failures. Re-enable to resume deliveries.",
+    "reEnable": "Re-enable",
+    "resumed": "Webhook resumed",
+    "copyUrl": "Copy URL",
+    "refresh": "Refresh",
+    "noMatches": "No deliveries match this filter.",
+    "retry": "Retry",
+    "viewPayload": "View payload",
+    "payloadCopied": "Payload copied",
+    "loadingPayload": "Loading payload…",
+    "requestPayload": "Request payload",
+    "response": "Response",
+    "close": "Close",
+    "samplePayload": "Sample envelope",
+    "referenceTitle": "Event reference",
+    "referenceIntro": "Every delivery follows this envelope shape. Click an event to see a sample payload your endpoint will receive.",
+    "filter": {
+      "all": "All",
+      "success": "Success",
+      "failed": "Failed",
+      "retrying": "Retrying",
+      "pending": "Pending"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/es/settings.json

@@ -7,6 +7,7 @@
       "billing": "Facturacion y documentos",
       "billing": "Facturacion y documentos",
       "communications": "Comunicaciones",
       "communications": "Comunicaciones",
       "workshop": "Taller",
       "workshop": "Taller",
+      "integrations": "Integraciones",
       "system": "Sistema"
       "system": "Sistema"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Informes programados",
         "title": "Informes programados",
         "description": "Envío automático de informes"
         "description": "Envío automático de informes"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Notificaciones de eventos en tiempo real"
+      },
       "appearance": {
       "appearance": {
         "title": "Apariencia",
         "title": "Apariencia",
         "description": "Tema y visualizacion"
         "description": "Tema y visualizacion"
@@ -1201,5 +1206,103 @@
       "retention": "Retención de clientes",
       "retention": "Retención de clientes",
       "inventory": "Inventario"
       "inventory": "Inventario"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Envía notificaciones de eventos en tiempo real a tus propios sistemas mediante HTTPS. Cada evento se firma con HMAC-SHA256 para que puedas verificar su autenticidad.",
+    "create": "Añadir webhook",
+    "createTitle": "Añadir webhook",
+    "editTitle": "Editar webhook",
+    "createDescription": "Los webhooks se envían como JSON mediante POST. Las entregas fallidas se reintentan con retroceso exponencial hasta 5 veces.",
+    "name": "Nombre",
+    "namePlaceholder": "p. ej. Sincronización contable",
+    "url": "URL del endpoint",
+    "urlHint": "Debe empezar con http:// o https://. Recomendamos encarecidamente HTTPS.",
+    "description": "Descripción (opcional)",
+    "events": "Eventos",
+    "eventsHint": "Elige qué eventos activan este webhook.",
+    "subscribeToAll": "Todos los eventos",
+    "selectAtLeastOneEvent": "Selecciona al menos un evento",
+    "groups": {
+      "customers": "Clientes",
+      "vehicles": "Vehículos",
+      "services": "Órdenes de servicio",
+      "quotes": "Presupuestos",
+      "payments": "Pagos",
+      "inspections": "Inspecciones",
+      "inventory": "Inventario",
+      "findings": "Hallazgos"
+    },
+    "save": "Guardar",
+    "cancel": "Cancelar",
+    "delete": "Eliminar",
+    "rotate": "Rotar",
+    "done": "Listo",
+    "paused": "Pausado",
+    "statusOk": "Saludable",
+    "statusFailing": "{count, plural, one {# fallo reciente} other {# fallos recientes}}",
+    "lastDelivery": "Última entrega {when}",
+    "totalDeliveries": "{count, plural, one {# entrega} other {# entregas}}",
+    "toggleAria": "Activar o desactivar este webhook",
+    "deliveries": "Entregas",
+    "sendTest": "Enviar prueba",
+    "testSent": "Evento de prueba enviado",
+    "testFailed": "No se pudo enviar el evento de prueba",
+    "rotateSecret": "Rotar clave de firma",
+    "confirmRotateTitle": "¿Rotar clave de firma?",
+    "confirmRotateDescription": "La clave actual dejará de funcionar inmediatamente. Actualiza tu endpoint con la nueva clave.",
+    "secretRotated": "Clave rotada",
+    "secretRotatedDescription": "Guarda esta clave ahora — no podrá recuperarse después.",
+    "confirmDeleteTitle": "¿Eliminar webhook?",
+    "confirmDeleteDescription": "«{name}» dejará de recibir eventos inmediatamente. Esto no se puede deshacer.",
+    "secretShownOnceTitle": "Webhook «{name}» creado",
+    "secretShownOnceDescription": "Copia esta clave de firma ahora. No volverá a mostrarse.",
+    "secretCopyHint": "Usa esta clave para verificar el encabezado X-Torqvoice-Signature de cada solicitud.",
+    "deliveriesTitle": "Entregas — {name}",
+    "deliveriesDescription": "Últimos 50 intentos. Los eventos fallidos se reintentan automáticamente con retroceso exponencial.",
+    "deliveriesEmpty": "Aún no hay entregas. Envía un evento de prueba para probarlo.",
+    "attempt": "Intento {n}/{max}",
+    "nextRetry": "Próximo reintento {when}",
+    "retryQueued": "Reintento en cola",
+    "retryFailed": "Reintento fallido",
+    "saveFailed": "Error al guardar",
+    "deleteFailed": "Error al eliminar",
+    "toggleFailed": "Error al alternar",
+    "rotateFailed": "Error al rotar",
+    "created": "Webhook creado",
+    "updated": "Webhook actualizado",
+    "deleted": "Webhook eliminado",
+    "emptyTitle": "Aún no hay webhooks",
+    "emptyDescription": "Añade un webhook para enviar eventos a tus propios sistemas — sincronización contable, paneles personalizados, Zapier, cualquier cosa que hable HTTP.",
+    "helpTitle": "Verificar firmas de webhook",
+    "helpIntro": "Cada entrega incluye una firma HMAC-SHA256 para que puedas confirmar que la solicitud proviene de Torqvoice.",
+    "helpHeadersTitle": "Encabezados enviados con cada solicitud",
+    "helpVerify": "Reconstruye la cadena firmada como `[timestamp].[body]` y compara con igualdad de tiempo seguro:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Referencia de eventos",
+    "autoDisabled": "Pausado automáticamente",
+    "autoDisabledBanner": "Este webhook se pausó automáticamente tras 20 fallos consecutivos. Reactívalo para reanudar las entregas.",
+    "reEnable": "Reactivar",
+    "resumed": "Webhook reanudado",
+    "copyUrl": "Copiar URL",
+    "refresh": "Actualizar",
+    "noMatches": "Ninguna entrega coincide con este filtro.",
+    "retry": "Reintentar",
+    "viewPayload": "Ver payload",
+    "payloadCopied": "Payload copiado",
+    "loadingPayload": "Cargando payload…",
+    "requestPayload": "Payload de la solicitud",
+    "response": "Respuesta",
+    "close": "Cerrar",
+    "samplePayload": "Envoltorio de ejemplo",
+    "referenceTitle": "Referencia de eventos",
+    "referenceIntro": "Cada entrega sigue esta estructura de envoltorio. Haz clic en un evento para ver un payload de ejemplo que recibirá tu endpoint.",
+    "filter": {
+      "all": "Todas",
+      "success": "Éxito",
+      "failed": "Fallida",
+      "retrying": "Reintentando",
+      "pending": "Pendiente"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/fr/settings.json

@@ -7,6 +7,7 @@
       "billing": "Facturation et documents",
       "billing": "Facturation et documents",
       "communications": "Communications",
       "communications": "Communications",
       "workshop": "Atelier",
       "workshop": "Atelier",
+      "integrations": "Intégrations",
       "system": "Systeme"
       "system": "Systeme"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Rapports planifiés",
         "title": "Rapports planifiés",
         "description": "Envoi automatique de rapports"
         "description": "Envoi automatique de rapports"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Notifications d'événements en temps réel"
+      },
       "appearance": {
       "appearance": {
         "title": "Apparence",
         "title": "Apparence",
         "description": "Theme et affichage"
         "description": "Theme et affichage"
@@ -1201,5 +1206,103 @@
       "retention": "Fidélisation clients",
       "retention": "Fidélisation clients",
       "inventory": "Inventaire"
       "inventory": "Inventaire"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Envoyez des notifications d'événements en temps réel vers vos propres systèmes via HTTPS. Chaque événement est signé avec HMAC-SHA256 afin que vous puissiez vérifier son authenticité.",
+    "create": "Ajouter un webhook",
+    "createTitle": "Ajouter un webhook",
+    "editTitle": "Modifier le webhook",
+    "createDescription": "Les webhooks sont envoyés en JSON via POST. Les livraisons échouées sont réessayées avec un délai exponentiel jusqu'à 5 fois.",
+    "name": "Nom",
+    "namePlaceholder": "p. ex. Sync comptable",
+    "url": "URL du point de terminaison",
+    "urlHint": "Doit commencer par http:// ou https://. Nous recommandons fortement HTTPS.",
+    "description": "Description (facultatif)",
+    "events": "Événements",
+    "eventsHint": "Choisissez les événements qui déclenchent ce webhook.",
+    "subscribeToAll": "Tous les événements",
+    "selectAtLeastOneEvent": "Sélectionnez au moins un événement",
+    "groups": {
+      "customers": "Clients",
+      "vehicles": "Véhicules",
+      "services": "Ordres de service",
+      "quotes": "Devis",
+      "payments": "Paiements",
+      "inspections": "Inspections",
+      "inventory": "Inventaire",
+      "findings": "Constatations"
+    },
+    "save": "Enregistrer",
+    "cancel": "Annuler",
+    "delete": "Supprimer",
+    "rotate": "Renouveler",
+    "done": "Terminé",
+    "paused": "En pause",
+    "statusOk": "Sain",
+    "statusFailing": "{count, plural, one {# échec récent} other {# échecs récents}}",
+    "lastDelivery": "Dernière livraison {when}",
+    "totalDeliveries": "{count, plural, one {# livraison} other {# livraisons}}",
+    "toggleAria": "Activer ou désactiver ce webhook",
+    "deliveries": "Livraisons",
+    "sendTest": "Envoyer un test",
+    "testSent": "Événement de test envoyé",
+    "testFailed": "Impossible d'envoyer l'événement de test",
+    "rotateSecret": "Renouveler la clé de signature",
+    "confirmRotateTitle": "Renouveler la clé de signature ?",
+    "confirmRotateDescription": "La clé actuelle cessera immédiatement de fonctionner. Mettez à jour votre endpoint avec la nouvelle clé.",
+    "secretRotated": "Clé renouvelée",
+    "secretRotatedDescription": "Enregistrez cette clé maintenant — elle ne pourra pas être récupérée plus tard.",
+    "confirmDeleteTitle": "Supprimer le webhook ?",
+    "confirmDeleteDescription": "« {name} » cessera immédiatement de recevoir des événements. Cette action est irréversible.",
+    "secretShownOnceTitle": "Webhook « {name} » créé",
+    "secretShownOnceDescription": "Copiez cette clé de signature maintenant. Elle ne sera pas affichée à nouveau.",
+    "secretCopyHint": "Utilisez cette clé pour vérifier l'en-tête X-Torqvoice-Signature de chaque requête.",
+    "deliveriesTitle": "Livraisons — {name}",
+    "deliveriesDescription": "50 dernières tentatives. Les événements échoués sont réessayés automatiquement avec un délai exponentiel.",
+    "deliveriesEmpty": "Aucune livraison pour le moment. Envoyez un événement de test pour essayer.",
+    "attempt": "Tentative {n}/{max}",
+    "nextRetry": "Prochaine tentative {when}",
+    "retryQueued": "Nouvelle tentative en file d'attente",
+    "retryFailed": "Échec de la nouvelle tentative",
+    "saveFailed": "Échec de l'enregistrement",
+    "deleteFailed": "Échec de la suppression",
+    "toggleFailed": "Échec du basculement",
+    "rotateFailed": "Échec du renouvellement",
+    "created": "Webhook créé",
+    "updated": "Webhook mis à jour",
+    "deleted": "Webhook supprimé",
+    "emptyTitle": "Aucun webhook pour le moment",
+    "emptyDescription": "Ajoutez un webhook pour envoyer des événements vers vos propres systèmes — sync comptable, tableaux de bord personnalisés, Zapier, tout ce qui parle HTTP.",
+    "helpTitle": "Vérifier les signatures des webhooks",
+    "helpIntro": "Chaque livraison comprend une signature HMAC-SHA256 pour que vous puissiez confirmer que la requête provient de Torqvoice.",
+    "helpHeadersTitle": "En-têtes envoyés avec chaque requête",
+    "helpVerify": "Reconstruisez la chaîne signée comme `[timestamp].[body]` et comparez avec une égalité à temps constant :",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Référence des événements",
+    "autoDisabled": "Mis en pause automatiquement",
+    "autoDisabledBanner": "Ce webhook a été mis en pause automatiquement après 20 échecs consécutifs. Réactivez-le pour reprendre les livraisons.",
+    "reEnable": "Réactiver",
+    "resumed": "Webhook réactivé",
+    "copyUrl": "Copier l'URL",
+    "refresh": "Actualiser",
+    "noMatches": "Aucune livraison ne correspond à ce filtre.",
+    "retry": "Réessayer",
+    "viewPayload": "Voir le payload",
+    "payloadCopied": "Payload copié",
+    "loadingPayload": "Chargement du payload…",
+    "requestPayload": "Payload de la requête",
+    "response": "Réponse",
+    "close": "Fermer",
+    "samplePayload": "Enveloppe exemple",
+    "referenceTitle": "Référence des événements",
+    "referenceIntro": "Chaque livraison suit cette enveloppe. Cliquez sur un événement pour voir un exemple de payload que votre endpoint recevra.",
+    "filter": {
+      "all": "Toutes",
+      "success": "Réussie",
+      "failed": "Échouée",
+      "retrying": "Nouvelle tentative",
+      "pending": "En attente"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/it/settings.json

@@ -7,6 +7,7 @@
       "billing": "Fatturazione e Documenti",
       "billing": "Fatturazione e Documenti",
       "communications": "Comunicazioni",
       "communications": "Comunicazioni",
       "workshop": "Officina",
       "workshop": "Officina",
+      "integrations": "Integrazioni",
       "system": "Sistema"
       "system": "Sistema"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Report programmati",
         "title": "Report programmati",
         "description": "Invio automatico dei report"
         "description": "Invio automatico dei report"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Notifiche di eventi in tempo reale"
+      },
       "appearance": {
       "appearance": {
         "title": "Aspetto",
         "title": "Aspetto",
         "description": "Tema e visualizzazione"
         "description": "Tema e visualizzazione"
@@ -1201,5 +1206,103 @@
       "retention": "Fidelizzazione clienti",
       "retention": "Fidelizzazione clienti",
       "inventory": "Inventario"
       "inventory": "Inventario"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Invia notifiche di eventi in tempo reale ai tuoi sistemi tramite HTTPS. Ogni evento è firmato con HMAC-SHA256 per verificarne l'autenticità.",
+    "create": "Aggiungi webhook",
+    "createTitle": "Aggiungi webhook",
+    "editTitle": "Modifica webhook",
+    "createDescription": "I webhook vengono inviati come JSON via POST. Le consegne fallite vengono riprovate con backoff esponenziale fino a 5 volte.",
+    "name": "Nome",
+    "namePlaceholder": "es. Sync contabile",
+    "url": "URL endpoint",
+    "urlHint": "Deve iniziare con http:// o https://. Consigliamo vivamente HTTPS.",
+    "description": "Descrizione (opzionale)",
+    "events": "Eventi",
+    "eventsHint": "Scegli quali eventi attivano questo webhook.",
+    "subscribeToAll": "Tutti gli eventi",
+    "selectAtLeastOneEvent": "Seleziona almeno un evento",
+    "groups": {
+      "customers": "Clienti",
+      "vehicles": "Veicoli",
+      "services": "Ordini di lavoro",
+      "quotes": "Preventivi",
+      "payments": "Pagamenti",
+      "inspections": "Ispezioni",
+      "inventory": "Magazzino",
+      "findings": "Anomalie"
+    },
+    "save": "Salva",
+    "cancel": "Annulla",
+    "delete": "Elimina",
+    "rotate": "Rigenera",
+    "done": "Fatto",
+    "paused": "In pausa",
+    "statusOk": "OK",
+    "statusFailing": "{count, plural, one {# errore recente} other {# errori recenti}}",
+    "lastDelivery": "Ultima consegna {when}",
+    "totalDeliveries": "{count, plural, one {# consegna} other {# consegne}}",
+    "toggleAria": "Attiva o disattiva questo webhook",
+    "deliveries": "Consegne",
+    "sendTest": "Invia test",
+    "testSent": "Evento di test inviato",
+    "testFailed": "Invio evento di test fallito",
+    "rotateSecret": "Rigenera chiave di firma",
+    "confirmRotateTitle": "Rigenerare la chiave di firma?",
+    "confirmRotateDescription": "La chiave attuale smetterà di funzionare immediatamente. Aggiorna il tuo endpoint con la nuova chiave.",
+    "secretRotated": "Chiave rigenerata",
+    "secretRotatedDescription": "Salva questa chiave ora — non potrà essere recuperata in seguito.",
+    "confirmDeleteTitle": "Eliminare il webhook?",
+    "confirmDeleteDescription": "«{name}» smetterà di ricevere eventi immediatamente. L'operazione non può essere annullata.",
+    "secretShownOnceTitle": "Webhook «{name}» creato",
+    "secretShownOnceDescription": "Copia questa chiave di firma ora. Non verrà mostrata di nuovo.",
+    "secretCopyHint": "Usa questa chiave per verificare l'header X-Torqvoice-Signature di ogni richiesta.",
+    "deliveriesTitle": "Consegne — {name}",
+    "deliveriesDescription": "Ultimi 50 tentativi. Gli eventi falliti vengono riprovati automaticamente con backoff esponenziale.",
+    "deliveriesEmpty": "Nessuna consegna ancora. Invia un evento di test per provare.",
+    "attempt": "Tentativo {n}/{max}",
+    "nextRetry": "Prossimo tentativo {when}",
+    "retryQueued": "Tentativo in coda",
+    "retryFailed": "Tentativo fallito",
+    "saveFailed": "Salvataggio fallito",
+    "deleteFailed": "Eliminazione fallita",
+    "toggleFailed": "Cambio stato fallito",
+    "rotateFailed": "Rigenerazione fallita",
+    "created": "Webhook creato",
+    "updated": "Webhook aggiornato",
+    "deleted": "Webhook eliminato",
+    "emptyTitle": "Nessun webhook ancora",
+    "emptyDescription": "Aggiungi un webhook per inviare eventi ai tuoi sistemi — sync contabile, dashboard personalizzate, Zapier, qualsiasi cosa parli HTTP.",
+    "helpTitle": "Verificare le firme dei webhook",
+    "helpIntro": "Ogni consegna include una firma HMAC-SHA256 per confermare che la richiesta proviene da Torqvoice.",
+    "helpHeadersTitle": "Header inviati con ogni richiesta",
+    "helpVerify": "Ricostruisci la stringa firmata come `[timestamp].[body]` e confronta con uguaglianza a tempo costante:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Riferimento eventi",
+    "autoDisabled": "Pausa automatica",
+    "autoDisabledBanner": "Questo webhook è stato messo in pausa automaticamente dopo 20 errori consecutivi. Riattivalo per riprendere le consegne.",
+    "reEnable": "Riattiva",
+    "resumed": "Webhook riattivato",
+    "copyUrl": "Copia l'URL",
+    "refresh": "Aggiorna",
+    "noMatches": "Nessuna consegna corrisponde a questo filtro.",
+    "retry": "Riprova",
+    "viewPayload": "Visualizza payload",
+    "payloadCopied": "Payload copiato",
+    "loadingPayload": "Caricamento del payload…",
+    "requestPayload": "Payload della richiesta",
+    "response": "Risposta",
+    "close": "Chiudi",
+    "samplePayload": "Envelope di esempio",
+    "referenceTitle": "Riferimento eventi",
+    "referenceIntro": "Ogni consegna segue questa forma di envelope. Clicca su un evento per vedere un payload di esempio che il tuo endpoint riceverà.",
+    "filter": {
+      "all": "Tutte",
+      "success": "Successo",
+      "failed": "Fallita",
+      "retrying": "In ritentativo",
+      "pending": "In attesa"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/lt/settings.json

@@ -7,6 +7,7 @@
       "billing": "Atsiskaitymai ir dokumentai",
       "billing": "Atsiskaitymai ir dokumentai",
       "communications": "Komunikacija",
       "communications": "Komunikacija",
       "workshop": "Dirbtuvės",
       "workshop": "Dirbtuvės",
+      "integrations": "Integracijos",
       "system": "Sistema"
       "system": "Sistema"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Ataskaitų grafikas",
         "title": "Ataskaitų grafikas",
         "description": "Automatinis ataskaitų siuntimas"
         "description": "Automatinis ataskaitų siuntimas"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Realaus laiko įvykių pranešimai"
+      },
       "appearance": {
       "appearance": {
         "title": "Išvaizda",
         "title": "Išvaizda",
         "description": "Tema ir atvaizdavimas"
         "description": "Tema ir atvaizdavimas"
@@ -1201,5 +1206,103 @@
       "retention": "Klientų grįžtamumas",
       "retention": "Klientų grįžtamumas",
       "inventory": "Inventorius"
       "inventory": "Inventorius"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Siųskite realaus laiko įvykių pranešimus į savo sistemas per HTTPS. Kiekvienas įvykis pasirašomas HMAC-SHA256, kad galėtumėte patikrinti autentiškumą.",
+    "create": "Pridėti webhook",
+    "createTitle": "Pridėti webhook",
+    "editTitle": "Redaguoti webhook",
+    "createDescription": "Webhooks siunčiami POST užklausa kaip JSON. Nepavykę pristatymai bandomi pakartotinai su eksponentine atidėjimo strategija iki 5 kartų.",
+    "name": "Pavadinimas",
+    "namePlaceholder": "pvz. Apskaitos sinchronizavimas",
+    "url": "Galinio taško URL",
+    "urlHint": "Turi prasidėti http:// arba https://. Primygtinai rekomenduojame HTTPS.",
+    "description": "Aprašymas (neprivaloma)",
+    "events": "Įvykiai",
+    "eventsHint": "Pasirinkite, kurie įvykiai suaktyvins šį webhook.",
+    "subscribeToAll": "Visi įvykiai",
+    "selectAtLeastOneEvent": "Pasirinkite bent vieną įvykį",
+    "groups": {
+      "customers": "Klientai",
+      "vehicles": "Transporto priemonės",
+      "services": "Darbo užsakymai",
+      "quotes": "Sąmatos",
+      "payments": "Mokėjimai",
+      "inspections": "Apžiūros",
+      "inventory": "Atsargos",
+      "findings": "Pastabos"
+    },
+    "save": "Išsaugoti",
+    "cancel": "Atšaukti",
+    "delete": "Ištrinti",
+    "rotate": "Atnaujinti",
+    "done": "Atlikta",
+    "paused": "Pristabdyta",
+    "statusOk": "Sveikas",
+    "statusFailing": "{count, plural, one {# nesena nesėkmė} few {# nesenos nesėkmės} many {# nesenos nesėkmės} other {# nesenų nesėkmių}}",
+    "lastDelivery": "Paskutinis pristatymas {when}",
+    "totalDeliveries": "{count, plural, one {# pristatymas} few {# pristatymai} many {# pristatymo} other {# pristatymų}}",
+    "toggleAria": "Įjungti arba išjungti šį webhook",
+    "deliveries": "Pristatymai",
+    "sendTest": "Siųsti testą",
+    "testSent": "Testo įvykis išsiųstas",
+    "testFailed": "Nepavyko išsiųsti testo įvykio",
+    "rotateSecret": "Atnaujinti pasirašymo raktą",
+    "confirmRotateTitle": "Atnaujinti pasirašymo raktą?",
+    "confirmRotateDescription": "Dabartinis raktas iš karto nustos veikti. Atnaujinkite savo galinį tašką nauju raktu.",
+    "secretRotated": "Raktas atnaujintas",
+    "secretRotatedDescription": "Išsaugokite šį raktą dabar — vėliau jo nepavyks atkurti.",
+    "confirmDeleteTitle": "Ištrinti webhook?",
+    "confirmDeleteDescription": "„{name}“ iš karto nustos gauti įvykius. Šio veiksmo negalima atšaukti.",
+    "secretShownOnceTitle": "Webhook „{name}“ sukurtas",
+    "secretShownOnceDescription": "Nukopijuokite šį pasirašymo raktą dabar. Jis nebus rodomas dar kartą.",
+    "secretCopyHint": "Naudokite šį raktą, kad patikrintumėte X-Torqvoice-Signature antraštę kiekvienoje užklausoje.",
+    "deliveriesTitle": "Pristatymai — {name}",
+    "deliveriesDescription": "Paskutiniai 50 bandymų. Nepavykę įvykiai automatiškai bandomi pakartotinai su eksponentine atidėjimo strategija.",
+    "deliveriesEmpty": "Pristatymų dar nėra. Siųskite testo įvykį, kad išbandytumėte.",
+    "attempt": "Bandymas {n}/{max}",
+    "nextRetry": "Kitas bandymas {when}",
+    "retryQueued": "Bandymas eilėje",
+    "retryFailed": "Bandymas nepavyko",
+    "saveFailed": "Nepavyko išsaugoti",
+    "deleteFailed": "Nepavyko ištrinti",
+    "toggleFailed": "Nepavyko perjungti",
+    "rotateFailed": "Nepavyko atnaujinti",
+    "created": "Webhook sukurtas",
+    "updated": "Webhook atnaujintas",
+    "deleted": "Webhook ištrintas",
+    "emptyTitle": "Webhook dar nėra",
+    "emptyDescription": "Pridėkite webhook, kad siųstumėte įvykius į savo sistemas — apskaitos sinchronizavimą, pasirinktinus skydelius, Zapier, viską, kas kalba HTTP.",
+    "helpTitle": "Webhook parašų tikrinimas",
+    "helpIntro": "Kiekvienas pristatymas turi HMAC-SHA256 parašą, kad galėtumėte patvirtinti, jog užklausa atvyko iš Torqvoice.",
+    "helpHeadersTitle": "Antraštės, siunčiamos su kiekviena užklausa",
+    "helpVerify": "Sudarykite pasirašytą eilutę kaip `[timestamp].[body]` ir palyginkite su saugiu laiko atžvilgiu palyginimu:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Įvykių žinynas",
+    "autoDisabled": "Automatiškai pristabdyta",
+    "autoDisabledBanner": "Šis webhook buvo automatiškai pristabdytas po 20 nuoseklių nesėkmių. Įjunkite jį iš naujo, kad atnaujintumėte pristatymus.",
+    "reEnable": "Įjungti iš naujo",
+    "resumed": "Webhook atnaujintas",
+    "copyUrl": "Kopijuoti URL",
+    "refresh": "Atnaujinti",
+    "noMatches": "Šiam filtrui pristatymų nerasta.",
+    "retry": "Bandyti dar kartą",
+    "viewPayload": "Peržiūrėti payload",
+    "payloadCopied": "Payload nukopijuotas",
+    "loadingPayload": "Įkeliamas payload…",
+    "requestPayload": "Užklausos payload",
+    "response": "Atsakymas",
+    "close": "Uždaryti",
+    "samplePayload": "Pavyzdinis vokas",
+    "referenceTitle": "Įvykių žinynas",
+    "referenceIntro": "Kiekvienas pristatymas seka šią voko formą. Spustelėkite įvykį, kad pamatytumėte pavyzdinį payload, kurį gaus jūsų galinis taškas.",
+    "filter": {
+      "all": "Visi",
+      "success": "Sėkmingi",
+      "failed": "Nepavykę",
+      "retrying": "Bandoma dar kartą",
+      "pending": "Laukiama"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/nb/settings.json

@@ -7,6 +7,7 @@
       "billing": "Fakturering og dokumenter",
       "billing": "Fakturering og dokumenter",
       "communications": "Kommunikasjon",
       "communications": "Kommunikasjon",
       "workshop": "Verksted",
       "workshop": "Verksted",
+      "integrations": "Integrasjoner",
       "system": "System"
       "system": "System"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Planlagte rapporter",
         "title": "Planlagte rapporter",
         "description": "Automatisk rapportlevering"
         "description": "Automatisk rapportlevering"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Sanntidsvarsler om hendelser"
+      },
       "appearance": {
       "appearance": {
         "title": "Utseende",
         "title": "Utseende",
         "description": "Tema og visning"
         "description": "Tema og visning"
@@ -1201,5 +1206,103 @@
       "retention": "Kundelojalitet",
       "retention": "Kundelojalitet",
       "inventory": "Inventar"
       "inventory": "Inventar"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Send sanntidsvarsler om hendelser til dine egne systemer via HTTPS. Hver hendelse signeres med HMAC-SHA256 så du kan verifisere autentisiteten.",
+    "create": "Legg til webhook",
+    "createTitle": "Legg til webhook",
+    "editTitle": "Rediger webhook",
+    "createDescription": "Webhooks sendes som JSON via POST. Mislykkede leveranser prøves på nytt med eksponentiell backoff opptil 5 ganger.",
+    "name": "Navn",
+    "namePlaceholder": "f.eks. Regnskapssynk",
+    "url": "Endepunkt-URL",
+    "urlHint": "Må starte med http:// eller https://. Vi anbefaler sterkt HTTPS.",
+    "description": "Beskrivelse (valgfritt)",
+    "events": "Hendelser",
+    "eventsHint": "Velg hvilke hendelser som utløser denne webhooken.",
+    "subscribeToAll": "Alle hendelser",
+    "selectAtLeastOneEvent": "Velg minst én hendelse",
+    "groups": {
+      "customers": "Kunder",
+      "vehicles": "Kjøretøy",
+      "services": "Arbeidsordrer",
+      "quotes": "Tilbud",
+      "payments": "Betalinger",
+      "inspections": "Inspeksjoner",
+      "inventory": "Lager",
+      "findings": "Funn"
+    },
+    "save": "Lagre",
+    "cancel": "Avbryt",
+    "delete": "Slett",
+    "rotate": "Forny",
+    "done": "Ferdig",
+    "paused": "Pauset",
+    "statusOk": "Sunn",
+    "statusFailing": "{count, plural, one {# nylig feil} other {# nylige feil}}",
+    "lastDelivery": "Siste levering {when}",
+    "totalDeliveries": "{count, plural, one {# levering} other {# leveringer}}",
+    "toggleAria": "Aktiver eller deaktiver denne webhooken",
+    "deliveries": "Leveringer",
+    "sendTest": "Send test",
+    "testSent": "Testhendelse sendt",
+    "testFailed": "Kunne ikke sende testhendelse",
+    "rotateSecret": "Forny signeringsnøkkel",
+    "confirmRotateTitle": "Fornye signeringsnøkkel?",
+    "confirmRotateDescription": "Den gjeldende nøkkelen vil slutte å virke umiddelbart. Oppdater endepunktet ditt med den nye nøkkelen.",
+    "secretRotated": "Nøkkel fornyet",
+    "secretRotatedDescription": "Lagre denne nøkkelen nå — den kan ikke gjenopprettes senere.",
+    "confirmDeleteTitle": "Slette webhook?",
+    "confirmDeleteDescription": "«{name}» vil slutte å motta hendelser umiddelbart. Dette kan ikke angres.",
+    "secretShownOnceTitle": "Webhook «{name}» opprettet",
+    "secretShownOnceDescription": "Kopier denne signeringsnøkkelen nå. Den vil ikke vises igjen.",
+    "secretCopyHint": "Bruk denne nøkkelen til å verifisere X-Torqvoice-Signature-headeren i hver forespørsel.",
+    "deliveriesTitle": "Leveringer — {name}",
+    "deliveriesDescription": "Siste 50 forsøk. Mislykkede hendelser prøves automatisk på nytt med eksponentiell backoff.",
+    "deliveriesEmpty": "Ingen leveringer ennå. Send en testhendelse for å prøve.",
+    "attempt": "Forsøk {n}/{max}",
+    "nextRetry": "Neste forsøk {when}",
+    "retryQueued": "Nytt forsøk i kø",
+    "retryFailed": "Nytt forsøk mislyktes",
+    "saveFailed": "Lagring mislyktes",
+    "deleteFailed": "Sletting mislyktes",
+    "toggleFailed": "Veksling mislyktes",
+    "rotateFailed": "Fornying mislyktes",
+    "created": "Webhook opprettet",
+    "updated": "Webhook oppdatert",
+    "deleted": "Webhook slettet",
+    "emptyTitle": "Ingen webhooks ennå",
+    "emptyDescription": "Legg til en webhook for å sende hendelser til dine egne systemer — regnskapssynk, egendefinerte dashbord, Zapier, alt som snakker HTTP.",
+    "helpTitle": "Verifisere webhook-signaturer",
+    "helpIntro": "Hver levering inkluderer en HMAC-SHA256-signatur så du kan bekrefte at forespørselen kom fra Torqvoice.",
+    "helpHeadersTitle": "Headers sendt med hver forespørsel",
+    "helpVerify": "Bygg opp den signerte strengen som `[timestamp].[body]` og sammenlign med tidssikker likhet:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Hendelsesreferanse",
+    "autoDisabled": "Auto-pauset",
+    "autoDisabledBanner": "Denne webhooken ble automatisk pauset etter 20 påfølgende feil. Aktiver den igjen for å gjenoppta leveringer.",
+    "reEnable": "Aktiver igjen",
+    "resumed": "Webhook gjenopptatt",
+    "copyUrl": "Kopier URL",
+    "refresh": "Oppdater",
+    "noMatches": "Ingen leveringer matcher dette filteret.",
+    "retry": "Prøv igjen",
+    "viewPayload": "Vis payload",
+    "payloadCopied": "Payload kopiert",
+    "loadingPayload": "Laster payload…",
+    "requestPayload": "Forespørsels-payload",
+    "response": "Svar",
+    "close": "Lukk",
+    "samplePayload": "Eksempel-konvolutt",
+    "referenceTitle": "Hendelsesreferanse",
+    "referenceIntro": "Hver levering følger denne konvolutt-formen. Klikk på en hendelse for å se en eksempel-payload som endepunktet ditt vil motta.",
+    "filter": {
+      "all": "Alle",
+      "success": "Vellykket",
+      "failed": "Mislyktes",
+      "retrying": "Prøver på nytt",
+      "pending": "Venter"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/nl/settings.json

@@ -7,6 +7,7 @@
       "billing": "Facturatie en documenten",
       "billing": "Facturatie en documenten",
       "communications": "Communicatie",
       "communications": "Communicatie",
       "workshop": "Werkplaats",
       "workshop": "Werkplaats",
+      "integrations": "Integraties",
       "system": "Systeem"
       "system": "Systeem"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Rapportplanning",
         "title": "Rapportplanning",
         "description": "Automatische rapportlevering"
         "description": "Automatische rapportlevering"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Realtime gebeurtenismeldingen"
+      },
       "appearance": {
       "appearance": {
         "title": "Weergave",
         "title": "Weergave",
         "description": "Thema en weergave"
         "description": "Thema en weergave"
@@ -1201,5 +1206,103 @@
       "retention": "Klantbehoud",
       "retention": "Klantbehoud",
       "inventory": "Inventaris"
       "inventory": "Inventaris"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Stuur realtime gebeurtenismeldingen naar je eigen systemen via HTTPS. Elke gebeurtenis wordt ondertekend met HMAC-SHA256 zodat je de authenticiteit kunt verifiëren.",
+    "create": "Webhook toevoegen",
+    "createTitle": "Webhook toevoegen",
+    "editTitle": "Webhook bewerken",
+    "createDescription": "Webhooks worden als JSON via POST verzonden. Mislukte leveringen worden tot 5 keer opnieuw geprobeerd met exponentiële backoff.",
+    "name": "Naam",
+    "namePlaceholder": "bijv. Boekhouding-sync",
+    "url": "Endpoint-URL",
+    "urlHint": "Moet beginnen met http:// of https://. We raden HTTPS sterk aan.",
+    "description": "Beschrijving (optioneel)",
+    "events": "Gebeurtenissen",
+    "eventsHint": "Kies welke gebeurtenissen deze webhook activeren.",
+    "subscribeToAll": "Alle gebeurtenissen",
+    "selectAtLeastOneEvent": "Selecteer minstens één gebeurtenis",
+    "groups": {
+      "customers": "Klanten",
+      "vehicles": "Voertuigen",
+      "services": "Werkorders",
+      "quotes": "Offertes",
+      "payments": "Betalingen",
+      "inspections": "Inspecties",
+      "inventory": "Voorraad",
+      "findings": "Bevindingen"
+    },
+    "save": "Opslaan",
+    "cancel": "Annuleren",
+    "delete": "Verwijderen",
+    "rotate": "Vernieuwen",
+    "done": "Klaar",
+    "paused": "Gepauzeerd",
+    "statusOk": "Gezond",
+    "statusFailing": "{count, plural, one {# recente fout} other {# recente fouten}}",
+    "lastDelivery": "Laatste levering {when}",
+    "totalDeliveries": "{count, plural, one {# levering} other {# leveringen}}",
+    "toggleAria": "Deze webhook in- of uitschakelen",
+    "deliveries": "Leveringen",
+    "sendTest": "Test sturen",
+    "testSent": "Testgebeurtenis verzonden",
+    "testFailed": "Verzenden van testgebeurtenis mislukt",
+    "rotateSecret": "Ondertekeningssleutel vernieuwen",
+    "confirmRotateTitle": "Ondertekeningssleutel vernieuwen?",
+    "confirmRotateDescription": "De huidige sleutel werkt direct niet meer. Werk je endpoint bij met de nieuwe sleutel.",
+    "secretRotated": "Sleutel vernieuwd",
+    "secretRotatedDescription": "Sla deze sleutel nu op — hij kan later niet worden hersteld.",
+    "confirmDeleteTitle": "Webhook verwijderen?",
+    "confirmDeleteDescription": "„{name}“ stopt direct met het ontvangen van gebeurtenissen. Dit kan niet ongedaan worden gemaakt.",
+    "secretShownOnceTitle": "Webhook „{name}“ aangemaakt",
+    "secretShownOnceDescription": "Kopieer deze ondertekeningssleutel nu. Hij wordt niet opnieuw getoond.",
+    "secretCopyHint": "Gebruik deze sleutel om de X-Torqvoice-Signature-header bij elke aanvraag te verifiëren.",
+    "deliveriesTitle": "Leveringen — {name}",
+    "deliveriesDescription": "Laatste 50 pogingen. Mislukte gebeurtenissen worden automatisch opnieuw geprobeerd met exponentiële backoff.",
+    "deliveriesEmpty": "Nog geen leveringen. Stuur een testgebeurtenis om het te proberen.",
+    "attempt": "Poging {n}/{max}",
+    "nextRetry": "Volgende poging {when}",
+    "retryQueued": "Nieuwe poging in wachtrij",
+    "retryFailed": "Nieuwe poging mislukt",
+    "saveFailed": "Opslaan mislukt",
+    "deleteFailed": "Verwijderen mislukt",
+    "toggleFailed": "Schakelen mislukt",
+    "rotateFailed": "Vernieuwen mislukt",
+    "created": "Webhook aangemaakt",
+    "updated": "Webhook bijgewerkt",
+    "deleted": "Webhook verwijderd",
+    "emptyTitle": "Nog geen webhooks",
+    "emptyDescription": "Voeg een webhook toe om gebeurtenissen naar je eigen systemen te sturen — boekhouding-sync, eigen dashboards, Zapier, alles wat HTTP spreekt.",
+    "helpTitle": "Webhook-handtekeningen verifiëren",
+    "helpIntro": "Elke levering bevat een HMAC-SHA256-handtekening zodat je kunt bevestigen dat de aanvraag van Torqvoice komt.",
+    "helpHeadersTitle": "Headers die met elke aanvraag worden verzonden",
+    "helpVerify": "Stel de ondertekende string samen als `[timestamp].[body]` en vergelijk met tijdsveilige gelijkheid:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Gebeurtenisreferentie",
+    "autoDisabled": "Automatisch gepauzeerd",
+    "autoDisabledBanner": "Deze webhook is automatisch gepauzeerd na 20 opeenvolgende fouten. Schakel hem opnieuw in om leveringen te hervatten.",
+    "reEnable": "Opnieuw inschakelen",
+    "resumed": "Webhook hervat",
+    "copyUrl": "URL kopiëren",
+    "refresh": "Vernieuwen",
+    "noMatches": "Geen leveringen komen overeen met dit filter.",
+    "retry": "Opnieuw proberen",
+    "viewPayload": "Payload bekijken",
+    "payloadCopied": "Payload gekopieerd",
+    "loadingPayload": "Payload laden…",
+    "requestPayload": "Aanvraag-payload",
+    "response": "Antwoord",
+    "close": "Sluiten",
+    "samplePayload": "Voorbeeld-envelop",
+    "referenceTitle": "Gebeurtenisreferentie",
+    "referenceIntro": "Elke levering volgt deze envelop-vorm. Klik op een gebeurtenis om een voorbeeld-payload te zien die je endpoint ontvangt.",
+    "filter": {
+      "all": "Alle",
+      "success": "Geslaagd",
+      "failed": "Mislukt",
+      "retrying": "Opnieuw proberen",
+      "pending": "In afwachting"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/pl/settings.json

@@ -7,6 +7,7 @@
       "billing": "Fakturowanie i dokumenty",
       "billing": "Fakturowanie i dokumenty",
       "communications": "Komunikacja",
       "communications": "Komunikacja",
       "workshop": "Warsztat",
       "workshop": "Warsztat",
+      "integrations": "Integracje",
       "system": "System"
       "system": "System"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Harmonogram raportów",
         "title": "Harmonogram raportów",
         "description": "Automatyczne wysyłanie raportów"
         "description": "Automatyczne wysyłanie raportów"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Powiadomienia o zdarzeniach w czasie rzeczywistym"
+      },
       "appearance": {
       "appearance": {
         "title": "Wyglad",
         "title": "Wyglad",
         "description": "Motyw i wyswietlanie"
         "description": "Motyw i wyswietlanie"
@@ -1201,5 +1206,103 @@
       "retention": "Lojalność klientów",
       "retention": "Lojalność klientów",
       "inventory": "Magazyn"
       "inventory": "Magazyn"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Wysyłaj powiadomienia o zdarzeniach w czasie rzeczywistym do własnych systemów przez HTTPS. Każde zdarzenie jest podpisywane HMAC-SHA256, dzięki czemu możesz zweryfikować autentyczność.",
+    "create": "Dodaj webhook",
+    "createTitle": "Dodaj webhook",
+    "editTitle": "Edytuj webhook",
+    "createDescription": "Webhooki są wysyłane jako JSON przez POST. Nieudane dostarczenia są ponawiane z wykładniczym opóźnieniem do 5 razy.",
+    "name": "Nazwa",
+    "namePlaceholder": "np. Synchronizacja księgowości",
+    "url": "URL punktu końcowego",
+    "urlHint": "Musi zaczynać się od http:// lub https://. Zdecydowanie zalecamy HTTPS.",
+    "description": "Opis (opcjonalnie)",
+    "events": "Zdarzenia",
+    "eventsHint": "Wybierz, które zdarzenia uruchomią ten webhook.",
+    "subscribeToAll": "Wszystkie zdarzenia",
+    "selectAtLeastOneEvent": "Wybierz co najmniej jedno zdarzenie",
+    "groups": {
+      "customers": "Klienci",
+      "vehicles": "Pojazdy",
+      "services": "Zlecenia serwisowe",
+      "quotes": "Oferty",
+      "payments": "Płatności",
+      "inspections": "Inspekcje",
+      "inventory": "Magazyn",
+      "findings": "Uwagi"
+    },
+    "save": "Zapisz",
+    "cancel": "Anuluj",
+    "delete": "Usuń",
+    "rotate": "Odnów",
+    "done": "Gotowe",
+    "paused": "Wstrzymany",
+    "statusOk": "OK",
+    "statusFailing": "{count, plural, one {# niedawny błąd} few {# niedawne błędy} many {# niedawnych błędów} other {# niedawnych błędów}}",
+    "lastDelivery": "Ostatnie dostarczenie {when}",
+    "totalDeliveries": "{count, plural, one {# dostarczenie} few {# dostarczenia} many {# dostarczeń} other {# dostarczenia}}",
+    "toggleAria": "Włącz lub wyłącz ten webhook",
+    "deliveries": "Dostarczenia",
+    "sendTest": "Wyślij test",
+    "testSent": "Zdarzenie testowe wysłane",
+    "testFailed": "Nie udało się wysłać zdarzenia testowego",
+    "rotateSecret": "Odnów klucz podpisu",
+    "confirmRotateTitle": "Odnowić klucz podpisu?",
+    "confirmRotateDescription": "Obecny klucz natychmiast przestanie działać. Zaktualizuj swój punkt końcowy nowym kluczem.",
+    "secretRotated": "Klucz odnowiony",
+    "secretRotatedDescription": "Zapisz ten klucz teraz — później nie będzie można go odzyskać.",
+    "confirmDeleteTitle": "Usunąć webhook?",
+    "confirmDeleteDescription": "„{name}“ natychmiast przestanie odbierać zdarzenia. Tego nie można cofnąć.",
+    "secretShownOnceTitle": "Utworzono webhook „{name}“",
+    "secretShownOnceDescription": "Skopiuj ten klucz podpisu teraz. Nie zostanie wyświetlony ponownie.",
+    "secretCopyHint": "Użyj tego klucza, aby zweryfikować nagłówek X-Torqvoice-Signature przy każdym żądaniu.",
+    "deliveriesTitle": "Dostarczenia — {name}",
+    "deliveriesDescription": "Ostatnie 50 prób. Nieudane zdarzenia są automatycznie ponawiane z wykładniczym opóźnieniem.",
+    "deliveriesEmpty": "Brak dostarczeń. Wyślij zdarzenie testowe, aby wypróbować.",
+    "attempt": "Próba {n}/{max}",
+    "nextRetry": "Następna próba {when}",
+    "retryQueued": "Próba w kolejce",
+    "retryFailed": "Próba nie powiodła się",
+    "saveFailed": "Zapis nie powiódł się",
+    "deleteFailed": "Usuwanie nie powiodło się",
+    "toggleFailed": "Przełączanie nie powiodło się",
+    "rotateFailed": "Odnowienie nie powiodło się",
+    "created": "Webhook utworzony",
+    "updated": "Webhook zaktualizowany",
+    "deleted": "Webhook usunięty",
+    "emptyTitle": "Brak webhooków",
+    "emptyDescription": "Dodaj webhook, aby wysyłać zdarzenia do własnych systemów — synchronizacja księgowości, niestandardowe pulpity, Zapier, cokolwiek, co mówi HTTP.",
+    "helpTitle": "Weryfikacja podpisów webhook",
+    "helpIntro": "Każde dostarczenie zawiera podpis HMAC-SHA256, dzięki czemu możesz potwierdzić, że żądanie pochodzi z Torqvoice.",
+    "helpHeadersTitle": "Nagłówki wysyłane z każdym żądaniem",
+    "helpVerify": "Zbuduj podpisany ciąg jako `[timestamp].[body]` i porównaj z bezpiecznym czasowo równaniem:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Dokumentacja zdarzeń",
+    "autoDisabled": "Automatycznie wstrzymany",
+    "autoDisabledBanner": "Ten webhook został automatycznie wstrzymany po 20 kolejnych niepowodzeniach. Włącz go ponownie, aby wznowić dostarczanie.",
+    "reEnable": "Włącz ponownie",
+    "resumed": "Webhook wznowiony",
+    "copyUrl": "Kopiuj URL",
+    "refresh": "Odśwież",
+    "noMatches": "Żadne dostarczenia nie pasują do tego filtra.",
+    "retry": "Ponów",
+    "viewPayload": "Pokaż payload",
+    "payloadCopied": "Payload skopiowany",
+    "loadingPayload": "Ładowanie payloadu…",
+    "requestPayload": "Payload żądania",
+    "response": "Odpowiedź",
+    "close": "Zamknij",
+    "samplePayload": "Przykładowa koperta",
+    "referenceTitle": "Dokumentacja zdarzeń",
+    "referenceIntro": "Każde dostarczenie ma kształt tej koperty. Kliknij zdarzenie, aby zobaczyć przykładowy payload, który otrzyma Twój punkt końcowy.",
+    "filter": {
+      "all": "Wszystkie",
+      "success": "Sukces",
+      "failed": "Nieudane",
+      "retrying": "Ponawianie",
+      "pending": "Oczekujące"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/pt-BR/settings.json

@@ -7,6 +7,7 @@
       "billing": "Faturamento e documentos",
       "billing": "Faturamento e documentos",
       "communications": "Comunicacoes",
       "communications": "Comunicacoes",
       "workshop": "Oficina",
       "workshop": "Oficina",
+      "integrations": "Integrações",
       "system": "Sistema"
       "system": "Sistema"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Agendamento de relatórios",
         "title": "Agendamento de relatórios",
         "description": "Envio automático de relatórios"
         "description": "Envio automático de relatórios"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Notificações de eventos em tempo real"
+      },
       "appearance": {
       "appearance": {
         "title": "Aparencia",
         "title": "Aparencia",
         "description": "Tema e exibicao"
         "description": "Tema e exibicao"
@@ -1201,5 +1206,103 @@
       "retention": "Retenção de clientes",
       "retention": "Retenção de clientes",
       "inventory": "Estoque"
       "inventory": "Estoque"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Envie notificações de eventos em tempo real para seus próprios sistemas via HTTPS. Cada evento é assinado com HMAC-SHA256 para que você possa verificar a autenticidade.",
+    "create": "Adicionar webhook",
+    "createTitle": "Adicionar webhook",
+    "editTitle": "Editar webhook",
+    "createDescription": "Os webhooks são enviados como JSON via POST. Entregas com falha são tentadas novamente com backoff exponencial até 5 vezes.",
+    "name": "Nome",
+    "namePlaceholder": "ex.: Sincronização contábil",
+    "url": "URL do endpoint",
+    "urlHint": "Deve começar com http:// ou https://. Recomendamos fortemente HTTPS.",
+    "description": "Descrição (opcional)",
+    "events": "Eventos",
+    "eventsHint": "Escolha quais eventos disparam este webhook.",
+    "subscribeToAll": "Todos os eventos",
+    "selectAtLeastOneEvent": "Selecione pelo menos um evento",
+    "groups": {
+      "customers": "Clientes",
+      "vehicles": "Veículos",
+      "services": "Ordens de serviço",
+      "quotes": "Orçamentos",
+      "payments": "Pagamentos",
+      "inspections": "Inspeções",
+      "inventory": "Estoque",
+      "findings": "Apontamentos"
+    },
+    "save": "Salvar",
+    "cancel": "Cancelar",
+    "delete": "Excluir",
+    "rotate": "Renovar",
+    "done": "Pronto",
+    "paused": "Pausado",
+    "statusOk": "OK",
+    "statusFailing": "{count, plural, one {# falha recente} other {# falhas recentes}}",
+    "lastDelivery": "Última entrega {when}",
+    "totalDeliveries": "{count, plural, one {# entrega} other {# entregas}}",
+    "toggleAria": "Ativar ou desativar este webhook",
+    "deliveries": "Entregas",
+    "sendTest": "Enviar teste",
+    "testSent": "Evento de teste enviado",
+    "testFailed": "Falha ao enviar evento de teste",
+    "rotateSecret": "Renovar chave de assinatura",
+    "confirmRotateTitle": "Renovar chave de assinatura?",
+    "confirmRotateDescription": "A chave atual deixará de funcionar imediatamente. Atualize seu endpoint com a nova chave.",
+    "secretRotated": "Chave renovada",
+    "secretRotatedDescription": "Salve esta chave agora — ela não poderá ser recuperada depois.",
+    "confirmDeleteTitle": "Excluir webhook?",
+    "confirmDeleteDescription": "“{name}” deixará de receber eventos imediatamente. Isso não pode ser desfeito.",
+    "secretShownOnceTitle": "Webhook “{name}” criado",
+    "secretShownOnceDescription": "Copie esta chave de assinatura agora. Ela não será exibida novamente.",
+    "secretCopyHint": "Use esta chave para verificar o cabeçalho X-Torqvoice-Signature em cada requisição.",
+    "deliveriesTitle": "Entregas — {name}",
+    "deliveriesDescription": "Últimas 50 tentativas. Eventos com falha são tentados automaticamente com backoff exponencial.",
+    "deliveriesEmpty": "Nenhuma entrega ainda. Envie um evento de teste para experimentar.",
+    "attempt": "Tentativa {n}/{max}",
+    "nextRetry": "Próxima tentativa {when}",
+    "retryQueued": "Nova tentativa na fila",
+    "retryFailed": "Nova tentativa falhou",
+    "saveFailed": "Falha ao salvar",
+    "deleteFailed": "Falha ao excluir",
+    "toggleFailed": "Falha ao alternar",
+    "rotateFailed": "Falha ao renovar",
+    "created": "Webhook criado",
+    "updated": "Webhook atualizado",
+    "deleted": "Webhook excluído",
+    "emptyTitle": "Nenhum webhook ainda",
+    "emptyDescription": "Adicione um webhook para enviar eventos aos seus próprios sistemas — sincronização contábil, painéis personalizados, Zapier, qualquer coisa que fale HTTP.",
+    "helpTitle": "Verificar assinaturas de webhook",
+    "helpIntro": "Cada entrega inclui uma assinatura HMAC-SHA256 para que você possa confirmar que a requisição veio do Torqvoice.",
+    "helpHeadersTitle": "Cabeçalhos enviados em cada requisição",
+    "helpVerify": "Reconstrua a string assinada como `[timestamp].[body]` e compare com igualdade de tempo seguro:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Referência de eventos",
+    "autoDisabled": "Pausado automaticamente",
+    "autoDisabledBanner": "Este webhook foi pausado automaticamente após 20 falhas consecutivas. Reative para retomar as entregas.",
+    "reEnable": "Reativar",
+    "resumed": "Webhook retomado",
+    "copyUrl": "Copiar URL",
+    "refresh": "Atualizar",
+    "noMatches": "Nenhuma entrega corresponde a este filtro.",
+    "retry": "Tentar novamente",
+    "viewPayload": "Ver payload",
+    "payloadCopied": "Payload copiado",
+    "loadingPayload": "Carregando payload…",
+    "requestPayload": "Payload da requisição",
+    "response": "Resposta",
+    "close": "Fechar",
+    "samplePayload": "Envelope de exemplo",
+    "referenceTitle": "Referência de eventos",
+    "referenceIntro": "Toda entrega segue esta forma de envelope. Clique em um evento para ver um payload de exemplo que seu endpoint receberá.",
+    "filter": {
+      "all": "Todas",
+      "success": "Sucesso",
+      "failed": "Falhou",
+      "retrying": "Tentando novamente",
+      "pending": "Pendente"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/ru/settings.json

@@ -7,6 +7,7 @@
       "billing": "Счета и документы",
       "billing": "Счета и документы",
       "communications": "Коммуникации",
       "communications": "Коммуникации",
       "workshop": "Мастерская",
       "workshop": "Мастерская",
+      "integrations": "Интеграции",
       "system": "Система"
       "system": "Система"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Расписание отчётов",
         "title": "Расписание отчётов",
         "description": "Автоматическая отправка отчётов"
         "description": "Автоматическая отправка отчётов"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Уведомления о событиях в реальном времени"
+      },
       "appearance": {
       "appearance": {
         "title": "Внешний вид",
         "title": "Внешний вид",
         "description": "Тема и отображение"
         "description": "Тема и отображение"
@@ -1201,5 +1206,103 @@
       "retention": "Удержание клиентов",
       "retention": "Удержание клиентов",
       "inventory": "Склад"
       "inventory": "Склад"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "Отправляйте уведомления о событиях в реальном времени в ваши собственные системы по HTTPS. Каждое событие подписывается HMAC-SHA256, чтобы вы могли проверить подлинность.",
+    "create": "Добавить webhook",
+    "createTitle": "Добавить webhook",
+    "editTitle": "Изменить webhook",
+    "createDescription": "Webhooks отправляются как JSON методом POST. Неудачные доставки повторяются с экспоненциальной задержкой до 5 раз.",
+    "name": "Название",
+    "namePlaceholder": "напр. Синхронизация бухгалтерии",
+    "url": "URL конечной точки",
+    "urlHint": "Должен начинаться с http:// или https://. Настоятельно рекомендуем HTTPS.",
+    "description": "Описание (необязательно)",
+    "events": "События",
+    "eventsHint": "Выберите, какие события активируют этот webhook.",
+    "subscribeToAll": "Все события",
+    "selectAtLeastOneEvent": "Выберите хотя бы одно событие",
+    "groups": {
+      "customers": "Клиенты",
+      "vehicles": "Автомобили",
+      "services": "Наряды",
+      "quotes": "Сметы",
+      "payments": "Платежи",
+      "inspections": "Осмотры",
+      "inventory": "Склад",
+      "findings": "Замечания"
+    },
+    "save": "Сохранить",
+    "cancel": "Отмена",
+    "delete": "Удалить",
+    "rotate": "Обновить",
+    "done": "Готово",
+    "paused": "Приостановлен",
+    "statusOk": "OK",
+    "statusFailing": "{count, plural, one {# недавняя ошибка} few {# недавние ошибки} many {# недавних ошибок} other {# недавних ошибок}}",
+    "lastDelivery": "Последняя доставка {when}",
+    "totalDeliveries": "{count, plural, one {# доставка} few {# доставки} many {# доставок} other {# доставок}}",
+    "toggleAria": "Включить или отключить этот webhook",
+    "deliveries": "Доставки",
+    "sendTest": "Отправить тест",
+    "testSent": "Тестовое событие отправлено",
+    "testFailed": "Не удалось отправить тестовое событие",
+    "rotateSecret": "Обновить ключ подписи",
+    "confirmRotateTitle": "Обновить ключ подписи?",
+    "confirmRotateDescription": "Текущий ключ перестанет работать немедленно. Обновите вашу конечную точку новым ключом.",
+    "secretRotated": "Ключ обновлён",
+    "secretRotatedDescription": "Сохраните этот ключ сейчас — его нельзя будет восстановить позже.",
+    "confirmDeleteTitle": "Удалить webhook?",
+    "confirmDeleteDescription": "«{name}» немедленно перестанет получать события. Это нельзя отменить.",
+    "secretShownOnceTitle": "Webhook «{name}» создан",
+    "secretShownOnceDescription": "Скопируйте этот ключ подписи сейчас. Он не будет показан снова.",
+    "secretCopyHint": "Используйте этот ключ для проверки заголовка X-Torqvoice-Signature в каждом запросе.",
+    "deliveriesTitle": "Доставки — {name}",
+    "deliveriesDescription": "Последние 50 попыток. Неудачные события автоматически повторяются с экспоненциальной задержкой.",
+    "deliveriesEmpty": "Доставок ещё нет. Отправьте тестовое событие, чтобы попробовать.",
+    "attempt": "Попытка {n}/{max}",
+    "nextRetry": "Следующая попытка {when}",
+    "retryQueued": "Повтор в очереди",
+    "retryFailed": "Повтор не удался",
+    "saveFailed": "Не удалось сохранить",
+    "deleteFailed": "Не удалось удалить",
+    "toggleFailed": "Не удалось переключить",
+    "rotateFailed": "Не удалось обновить",
+    "created": "Webhook создан",
+    "updated": "Webhook обновлён",
+    "deleted": "Webhook удалён",
+    "emptyTitle": "Webhooks пока нет",
+    "emptyDescription": "Добавьте webhook, чтобы отправлять события в ваши системы — синхронизация бухгалтерии, кастомные панели, Zapier, всё, что говорит на HTTP.",
+    "helpTitle": "Проверка подписей webhook",
+    "helpIntro": "Каждая доставка содержит подпись HMAC-SHA256, чтобы вы могли подтвердить, что запрос пришёл от Torqvoice.",
+    "helpHeadersTitle": "Заголовки, отправляемые с каждым запросом",
+    "helpVerify": "Соберите подписанную строку как `[timestamp].[body]` и сравните с безопасной по времени проверкой:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Справочник событий",
+    "autoDisabled": "Автоприостановлен",
+    "autoDisabledBanner": "Этот webhook был автоматически приостановлен после 20 последовательных ошибок. Включите его снова, чтобы возобновить доставку.",
+    "reEnable": "Включить снова",
+    "resumed": "Webhook возобновлён",
+    "copyUrl": "Скопировать URL",
+    "refresh": "Обновить",
+    "noMatches": "Нет доставок, соответствующих этому фильтру.",
+    "retry": "Повторить",
+    "viewPayload": "Просмотреть payload",
+    "payloadCopied": "Payload скопирован",
+    "loadingPayload": "Загрузка payload…",
+    "requestPayload": "Payload запроса",
+    "response": "Ответ",
+    "close": "Закрыть",
+    "samplePayload": "Пример конверта",
+    "referenceTitle": "Справочник событий",
+    "referenceIntro": "Каждая доставка следует этой форме конверта. Нажмите на событие, чтобы увидеть пример payload, который получит ваша конечная точка.",
+    "filter": {
+      "all": "Все",
+      "success": "Успешно",
+      "failed": "Не удалось",
+      "retrying": "Повтор",
+      "pending": "Ожидает"
+    }
   }
   }
 }
 }

+ 103 - 0
messages/tr/settings.json

@@ -7,6 +7,7 @@
       "billing": "Fatura & Belgeler",
       "billing": "Fatura & Belgeler",
       "communications": "İletişim",
       "communications": "İletişim",
       "workshop": "Atölye",
       "workshop": "Atölye",
+      "integrations": "Entegrasyonlar",
       "system": "Sistem"
       "system": "Sistem"
     },
     },
     "items": {
     "items": {
@@ -90,6 +91,10 @@
         "title": "Rapor zamanlayıcı",
         "title": "Rapor zamanlayıcı",
         "description": "Otomatik rapor gönderimi"
         "description": "Otomatik rapor gönderimi"
       },
       },
+      "webhooks": {
+        "title": "Webhooks",
+        "description": "Gerçek zamanlı olay bildirimleri"
+      },
       "appearance": {
       "appearance": {
         "title": "Görünüm",
         "title": "Görünüm",
         "description": "Tema & görüntüleme"
         "description": "Tema & görüntüleme"
@@ -1201,5 +1206,103 @@
       "retention": "Müşteri sadakati",
       "retention": "Müşteri sadakati",
       "inventory": "Envanter"
       "inventory": "Envanter"
     }
     }
+  },
+  "webhooks": {
+    "title": "Webhooks",
+    "subtitle": "HTTPS üzerinden kendi sistemlerinize gerçek zamanlı olay bildirimleri gönderin. Her olay HMAC-SHA256 ile imzalanır, böylece gerçekliği doğrulayabilirsiniz.",
+    "create": "Webhook ekle",
+    "createTitle": "Webhook ekle",
+    "editTitle": "Webhook düzenle",
+    "createDescription": "Webhook'lar JSON olarak POST ile gönderilir. Başarısız teslimatlar üstel geri çekilme ile 5 kez yeniden denenir.",
+    "name": "Ad",
+    "namePlaceholder": "örn. Muhasebe senkronu",
+    "url": "Endpoint URL'si",
+    "urlHint": "http:// veya https:// ile başlamalı. HTTPS şiddetle önerilir.",
+    "description": "Açıklama (isteğe bağlı)",
+    "events": "Olaylar",
+    "eventsHint": "Bu webhook'u tetikleyecek olayları seçin.",
+    "subscribeToAll": "Tüm olaylar",
+    "selectAtLeastOneEvent": "En az bir olay seçin",
+    "groups": {
+      "customers": "Müşteriler",
+      "vehicles": "Araçlar",
+      "services": "İş emirleri",
+      "quotes": "Teklifler",
+      "payments": "Ödemeler",
+      "inspections": "Muayeneler",
+      "inventory": "Envanter",
+      "findings": "Bulgular"
+    },
+    "save": "Kaydet",
+    "cancel": "İptal",
+    "delete": "Sil",
+    "rotate": "Yenile",
+    "done": "Bitti",
+    "paused": "Duraklatıldı",
+    "statusOk": "Sağlıklı",
+    "statusFailing": "{count, plural, other {# yakın zamanda hata}}",
+    "lastDelivery": "Son teslimat {when}",
+    "totalDeliveries": "{count, plural, other {# teslimat}}",
+    "toggleAria": "Bu webhook'u etkinleştir veya devre dışı bırak",
+    "deliveries": "Teslimatlar",
+    "sendTest": "Test gönder",
+    "testSent": "Test olayı gönderildi",
+    "testFailed": "Test olayı gönderilemedi",
+    "rotateSecret": "İmzalama anahtarını yenile",
+    "confirmRotateTitle": "İmzalama anahtarı yenilensin mi?",
+    "confirmRotateDescription": "Mevcut anahtar derhal çalışmayı durduracak. Endpoint'inizi yeni anahtarla güncelleyin.",
+    "secretRotated": "Anahtar yenilendi",
+    "secretRotatedDescription": "Bu anahtarı şimdi kaydedin — daha sonra kurtarılamaz.",
+    "confirmDeleteTitle": "Webhook silinsin mi?",
+    "confirmDeleteDescription": "“{name}” derhal olay almayı durduracak. Bu işlem geri alınamaz.",
+    "secretShownOnceTitle": "“{name}” webhook'u oluşturuldu",
+    "secretShownOnceDescription": "Bu imzalama anahtarını şimdi kopyalayın. Tekrar gösterilmeyecek.",
+    "secretCopyHint": "Her isteğin X-Torqvoice-Signature başlığını doğrulamak için bu anahtarı kullanın.",
+    "deliveriesTitle": "Teslimatlar — {name}",
+    "deliveriesDescription": "Son 50 deneme. Başarısız olaylar üstel geri çekilme ile otomatik olarak yeniden denenir.",
+    "deliveriesEmpty": "Henüz teslimat yok. Denemek için bir test olayı gönderin.",
+    "attempt": "Deneme {n}/{max}",
+    "nextRetry": "Sonraki deneme {when}",
+    "retryQueued": "Yeniden deneme kuyrukta",
+    "retryFailed": "Yeniden deneme başarısız",
+    "saveFailed": "Kaydetme başarısız",
+    "deleteFailed": "Silme başarısız",
+    "toggleFailed": "Geçiş başarısız",
+    "rotateFailed": "Yenileme başarısız",
+    "created": "Webhook oluşturuldu",
+    "updated": "Webhook güncellendi",
+    "deleted": "Webhook silindi",
+    "emptyTitle": "Henüz webhook yok",
+    "emptyDescription": "Olayları kendi sistemlerinize göndermek için webhook ekleyin — muhasebe senkronu, özel panolar, Zapier, HTTP konuşan her şey.",
+    "helpTitle": "Webhook imzalarını doğrulama",
+    "helpIntro": "Her teslimat HMAC-SHA256 imzası içerir, böylece isteğin Torqvoice'tan geldiğini onaylayabilirsiniz.",
+    "helpHeadersTitle": "Her istekle gönderilen başlıklar",
+    "helpVerify": "İmzalanmış dizeyi `[timestamp].[body]` olarak yeniden oluşturun ve zamana karşı güvenli eşitlikle karşılaştırın:",
+    "tabWebhooks": "Webhooks",
+    "tabReference": "Olay referansı",
+    "autoDisabled": "Otomatik duraklatıldı",
+    "autoDisabledBanner": "Bu webhook 20 ardışık başarısızlıktan sonra otomatik olarak duraklatıldı. Teslimatları sürdürmek için yeniden etkinleştirin.",
+    "reEnable": "Yeniden etkinleştir",
+    "resumed": "Webhook sürdürüldü",
+    "copyUrl": "URL'yi kopyala",
+    "refresh": "Yenile",
+    "noMatches": "Bu filtreye uyan teslimat yok.",
+    "retry": "Yeniden dene",
+    "viewPayload": "Payload'u görüntüle",
+    "payloadCopied": "Payload kopyalandı",
+    "loadingPayload": "Payload yükleniyor…",
+    "requestPayload": "İstek payload’u",
+    "response": "Yanıt",
+    "close": "Kapat",
+    "samplePayload": "Örnek zarf",
+    "referenceTitle": "Olay referansı",
+    "referenceIntro": "Her teslimat bu zarf biçimini takip eder. Endpoint’inizin alacağı örnek bir payload görmek için bir olaya tıklayın.",
+    "filter": {
+      "all": "Tümü",
+      "success": "Başarılı",
+      "failed": "Başarısız",
+      "retrying": "Yeniden deneniyor",
+      "pending": "Beklemede"
+    }
   }
   }
 }
 }

+ 59 - 0
prisma/migrations/20260501152451_webhooks/migration.sql

@@ -0,0 +1,59 @@
+-- CreateTable
+CREATE TABLE "webhooks" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "url" TEXT NOT NULL,
+    "secret" TEXT NOT NULL,
+    "events" TEXT NOT NULL,
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "description" TEXT,
+    "lastTriggeredAt" TIMESTAMP(3),
+    "lastSuccessAt" TIMESTAMP(3),
+    "lastFailureAt" TIMESTAMP(3),
+    "failureCount" INTEGER NOT NULL DEFAULT 0,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdById" TEXT NOT NULL,
+
+    CONSTRAINT "webhooks_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "webhook_deliveries" (
+    "id" TEXT NOT NULL,
+    "event" TEXT NOT NULL,
+    "payload" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'pending',
+    "statusCode" INTEGER,
+    "responseBody" TEXT,
+    "errorMessage" TEXT,
+    "attempt" INTEGER NOT NULL DEFAULT 0,
+    "maxAttempts" INTEGER NOT NULL DEFAULT 5,
+    "nextRetryAt" TIMESTAMP(3),
+    "deliveredAt" TIMESTAMP(3),
+    "durationMs" INTEGER,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "webhookId" TEXT NOT NULL,
+
+    CONSTRAINT "webhook_deliveries_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "webhooks_organizationId_idx" ON "webhooks"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "webhooks_organizationId_isActive_idx" ON "webhooks"("organizationId", "isActive");
+
+-- CreateIndex
+CREATE INDEX "webhook_deliveries_webhookId_createdAt_idx" ON "webhook_deliveries"("webhookId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "webhook_deliveries_status_nextRetryAt_idx" ON "webhook_deliveries"("status", "nextRetryAt");
+
+-- AddForeignKey
+ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "webhooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 52 - 0
prisma/schema.prisma

@@ -673,6 +673,7 @@ model Organization {
   auditLogs              AuditLog[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
   reportSchedules        ReportSchedule[]
+  webhooks               Webhook[]
 
 
   @@map("organizations")
   @@map("organizations")
 }
 }
@@ -1236,3 +1237,54 @@ model ReportSchedule {
   @@index([nextRunDate, isActive])
   @@index([nextRunDate, isActive])
   @@map("report_schedules")
   @@map("report_schedules")
 }
 }
+
+model Webhook {
+  id              String    @id @default(cuid())
+  name            String
+  url             String
+  secret          String
+  events          String // JSON array of event names; "*" matches all
+  isActive        Boolean   @default(true)
+  description     String?
+  lastTriggeredAt DateTime?
+  lastSuccessAt   DateTime?
+  lastFailureAt   DateTime?
+  failureCount    Int       @default(0)
+  createdAt       DateTime  @default(now())
+  updatedAt       DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdById String
+
+  deliveries WebhookDelivery[]
+
+  @@index([organizationId])
+  @@index([organizationId, isActive])
+  @@map("webhooks")
+}
+
+model WebhookDelivery {
+  id           String    @id @default(cuid())
+  event        String
+  payload      String // JSON-stringified event payload as sent
+  status       String    @default("pending") // pending | success | failed | retrying
+  statusCode   Int?
+  responseBody String?
+  errorMessage String?
+  attempt      Int       @default(0)
+  maxAttempts  Int       @default(5)
+  nextRetryAt  DateTime?
+  deliveredAt  DateTime?
+  durationMs   Int?
+  createdAt    DateTime  @default(now())
+  updatedAt    DateTime  @updatedAt
+
+  webhookId String
+  webhook   Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
+
+  @@index([webhookId, createdAt(sort: Desc)])
+  @@index([status, nextRetryAt])
+  @@map("webhook_deliveries")
+}

+ 58 - 0
src/__tests__/features/webhooks/sign.test.ts

@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import {
+  signPayload,
+  verifySignature,
+  generateWebhookSecret,
+} from "@/features/webhooks/Lib/sign";
+
+describe("webhook signing", () => {
+  it("generates secrets in the documented format", () => {
+    const s = generateWebhookSecret();
+    expect(s).toMatch(/^whsec_[a-f0-9]{48}$/);
+  });
+
+  it("verifies a freshly-signed payload", () => {
+    const secret = generateWebhookSecret();
+    const body = JSON.stringify({ event: "customer.create", id: "cus_x" });
+    const header = signPayload(secret, body);
+    expect(verifySignature(secret, body, header)).toBe(true);
+  });
+
+  it("rejects a tampered body", () => {
+    const secret = generateWebhookSecret();
+    const body = JSON.stringify({ event: "customer.create", id: "cus_x" });
+    const header = signPayload(secret, body);
+    expect(verifySignature(secret, body + " ", header)).toBe(false);
+  });
+
+  it("rejects a wrong secret", () => {
+    const secret = generateWebhookSecret();
+    const wrong = generateWebhookSecret();
+    const body = "{}";
+    const header = signPayload(secret, body);
+    expect(verifySignature(wrong, body, header)).toBe(false);
+  });
+
+  it("rejects an old timestamp (replay window)", () => {
+    const secret = generateWebhookSecret();
+    const body = "{}";
+    const oldTs = Date.now() - 10 * 60 * 1000; // 10 minutes ago
+    const header = signPayload(secret, body, oldTs);
+    expect(verifySignature(secret, body, header)).toBe(false);
+  });
+
+  it("rejects malformed signature headers", () => {
+    const secret = generateWebhookSecret();
+    expect(verifySignature(secret, "{}", "")).toBe(false);
+    expect(verifySignature(secret, "{}", "garbage")).toBe(false);
+    expect(verifySignature(secret, "{}", "t=abc,v1=def")).toBe(false);
+  });
+
+  it("constant-time-rejects a signature of the same length but different bits", () => {
+    const secret = generateWebhookSecret();
+    const body = "{}";
+    const real = signPayload(secret, body);
+    const flipped = real.replace(/v1=([0-9a-f])/, (_, c) => `v1=${c === "0" ? "1" : "0"}`);
+    expect(verifySignature(secret, body, flipped)).toBe(false);
+  });
+});

+ 119 - 0
src/__tests__/features/webhooks/ssrf.test.ts

@@ -0,0 +1,119 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { isPrivateAddress, checkWebhookUrl } from "@/features/webhooks/Lib/ssrf";
+
+describe("isPrivateAddress", () => {
+  it.each([
+    ["10.0.0.1", true],
+    ["10.255.255.255", true],
+    ["172.16.0.1", true],
+    ["172.31.255.255", true],
+    ["172.32.0.1", false],
+    ["192.168.1.1", true],
+    ["127.0.0.1", true],
+    ["169.254.169.254", true],
+    ["100.64.0.1", true],
+    ["8.8.8.8", false],
+    ["1.1.1.1", false],
+    ["::1", true],
+    ["fc00::1", true],
+    ["fd12:3456::1", true],
+    ["fe80::1", true],
+    ["2001:4860:4860::8888", false],
+  ])("classifies %s correctly", (addr, expected) => {
+    expect(isPrivateAddress(addr)).toBe(expected);
+  });
+});
+
+describe("checkWebhookUrl", () => {
+  const resolver = vi.fn();
+
+  beforeEach(() => {
+    delete process.env.WEBHOOKS_ALLOW_PRIVATE_TARGETS;
+    resolver.mockReset();
+  });
+
+  afterEach(() => {
+    delete process.env.WEBHOOKS_ALLOW_PRIVATE_TARGETS;
+  });
+
+  it("rejects non-http schemes", async () => {
+    const r = await checkWebhookUrl("file:///etc/passwd", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("scheme_not_http");
+  });
+
+  it("rejects loopback hostnames", async () => {
+    const r = await checkWebhookUrl("http://localhost/hook", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("loopback_host");
+  });
+
+  it("rejects literal private IPv4 without DNS lookup", async () => {
+    const r = await checkWebhookUrl("https://192.168.1.1/hook", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("private_ip");
+    expect(resolver).not.toHaveBeenCalled();
+  });
+
+  it("rejects AWS metadata IP", async () => {
+    const r = await checkWebhookUrl(
+      "http://169.254.169.254/latest/meta-data/",
+      resolver,
+    );
+    expect(r.ok).toBe(false);
+  });
+
+  it("rejects GCP metadata host", async () => {
+    const r = await checkWebhookUrl("http://metadata.google.internal/", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("metadata_host");
+  });
+
+  it("rejects hosts that resolve to private addresses", async () => {
+    resolver.mockResolvedValue([{ address: "10.0.0.5", family: 4 }]);
+    const r = await checkWebhookUrl("https://internal.example.com/hook", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("resolves_private");
+  });
+
+  it("accepts public hosts that resolve to public addresses", async () => {
+    resolver.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
+    const r = await checkWebhookUrl("https://hooks.example.com/torqvoice", resolver);
+    expect(r.ok).toBe(true);
+  });
+
+  it("rejects when one of multiple records is private (mixed)", async () => {
+    resolver.mockResolvedValue([
+      { address: "8.8.8.8", family: 4 },
+      { address: "10.0.0.1", family: 4 },
+    ]);
+    const r = await checkWebhookUrl("https://attacker.example.com/", resolver);
+    expect(r.ok).toBe(false);
+  });
+
+  it("rejects when DNS resolution fails", async () => {
+    resolver.mockRejectedValue(new Error("ENOTFOUND"));
+    const r = await checkWebhookUrl("https://nonexistent.example.invalid/", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("dns_resolution_failed");
+  });
+
+  it("rejects when DNS returns no records", async () => {
+    resolver.mockResolvedValue([]);
+    const r = await checkWebhookUrl("https://empty.example.com/", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("dns_no_records");
+  });
+
+  it("respects WEBHOOKS_ALLOW_PRIVATE_TARGETS escape hatch", async () => {
+    process.env.WEBHOOKS_ALLOW_PRIVATE_TARGETS = "true";
+    const r = await checkWebhookUrl("http://192.168.1.1/hook", resolver);
+    expect(r.ok).toBe(true);
+  });
+
+  it("rejects garbage URLs", async () => {
+    const r = await checkWebhookUrl("not a url", resolver);
+    expect(r.ok).toBe(false);
+    if (!r.ok) expect(r.reason).toBe("invalid_url");
+  });
+});

+ 7 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -31,6 +31,7 @@ import {
   Sparkles,
   Sparkles,
   UserCog,
   UserCog,
   UsersRound,
   UsersRound,
+  Webhook,
   Wrench,
   Wrench,
 } from 'lucide-react'
 } from 'lucide-react'
 
 
@@ -91,6 +92,12 @@ const settingsCategories: SettingsCategory[] = [
       { key: 'reportSchedule', href: '/settings/report-schedule', icon: CalendarClock, gate: 'reports' },
       { key: 'reportSchedule', href: '/settings/report-schedule', icon: CalendarClock, gate: 'reports' },
     ],
     ],
   },
   },
+  {
+    key: 'integrations',
+    items: [
+      { key: 'webhooks', href: '/settings/webhooks', icon: Webhook, gate: 'api' },
+    ],
+  },
   {
   {
     key: 'system',
     key: 'system',
     items: [
     items: [

+ 28 - 0
src/app/(authenticated)/settings/webhooks/page.tsx

@@ -0,0 +1,28 @@
+import { redirect } from "next/navigation";
+import { getLayoutData } from "@/lib/get-layout-data";
+import { getFeatures, isCloudMode } from "@/lib/features";
+import { FeatureLockedMessage } from "../feature-locked-message";
+import { getWebhooks } from "@/features/webhooks/Actions/webhookActions";
+import { WebhooksSettings } from "./webhooks-settings";
+
+export default async function WebhooksPage() {
+  const data = await getLayoutData();
+  if (data.status === "unauthenticated") redirect("/auth/sign-in");
+  if (data.status === "no-organization") redirect("/onboarding");
+
+  const features = await getFeatures(data.organizationId);
+  if (!features.api) {
+    return (
+      <FeatureLockedMessage
+        feature="Webhooks"
+        description="Push real-time event notifications to your own systems via HTTPS."
+        isCloud={isCloudMode()}
+      />
+    );
+  }
+
+  const result = await getWebhooks();
+  const webhooks = result.success && result.data ? result.data : [];
+
+  return <WebhooksSettings webhooks={webhooks} />;
+}

+ 1219 - 0
src/app/(authenticated)/settings/webhooks/webhooks-settings.tsx

@@ -0,0 +1,1219 @@
+"use client";
+
+import { useEffect, useMemo, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {
+  Sheet,
+  SheetContent,
+  SheetDescription,
+  SheetHeader,
+  SheetTitle,
+} from "@/components/ui/sheet";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  Loader2,
+  Plus,
+  Pencil,
+  Trash2,
+  Send,
+  Webhook as WebhookIcon,
+  Copy,
+  Check,
+  RefreshCw,
+  History,
+  ShieldCheck,
+  AlertCircle,
+  PauseCircle,
+  Eye,
+  BookOpen,
+  PlayCircle,
+} from "lucide-react";
+import {
+  createWebhook,
+  updateWebhook,
+  toggleWebhook,
+  deleteWebhook,
+  rotateWebhookSecret,
+  sendTestWebhook,
+  getWebhookDeliveries,
+  retryWebhookDelivery,
+  getDeliveryPayload,
+} from "@/features/webhooks/Actions/webhookActions";
+import { WEBHOOK_EVENT_GROUPS } from "@/features/webhooks/Schema/webhookSchema";
+import {
+  SAMPLE_PAYLOADS,
+  buildSampleEnvelope,
+} from "@/features/webhooks/Lib/samples";
+import { useSettingsPermission } from "../settings-permission-context";
+
+type Webhook = {
+  id: string;
+  name: string;
+  url: string;
+  description: string | null;
+  events: string[];
+  isActive: boolean;
+  autoDisabled: boolean;
+  lastTriggeredAt: Date | null;
+  lastSuccessAt: Date | null;
+  lastFailureAt: Date | null;
+  failureCount: number;
+  deliveryCount: number;
+  createdAt: Date;
+};
+
+type Delivery = {
+  id: string;
+  event: string;
+  status: string;
+  statusCode: number | null;
+  attempt: number;
+  maxAttempts: number;
+  errorMessage: string | null;
+  durationMs: number | null;
+  createdAt: Date;
+  deliveredAt: Date | null;
+  nextRetryAt: Date | null;
+};
+
+interface Props {
+  webhooks: Webhook[];
+}
+
+export function WebhooksSettings({ webhooks }: Props) {
+  const t = useTranslations("settings.webhooks");
+  const router = useRouter();
+  const { canEdit } = useSettingsPermission();
+
+  const [tab, setTab] = useState<"webhooks" | "reference">("webhooks");
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [editing, setEditing] = useState<Webhook | null>(null);
+  const [createdSecret, setCreatedSecret] = useState<{
+    name: string;
+    secret: string;
+  } | null>(null);
+  const [deliveriesFor, setDeliveriesFor] = useState<Webhook | null>(null);
+
+  const openCreate = () => {
+    setEditing(null);
+    setDialogOpen(true);
+  };
+
+  const openEdit = (w: Webhook) => {
+    setEditing(w);
+    setDialogOpen(true);
+  };
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
+          <p className="mt-1 max-w-2xl text-sm text-muted-foreground">
+            {t("subtitle")}
+          </p>
+        </div>
+        {canEdit && tab === "webhooks" && (
+          <Button onClick={openCreate}>
+            <Plus className="mr-2 h-4 w-4" />
+            {t("create")}
+          </Button>
+        )}
+      </div>
+
+      <Tabs value={tab} onValueChange={(v) => setTab(v as "webhooks" | "reference")}>
+        <TabsList>
+          <TabsTrigger value="webhooks">
+            <WebhookIcon className="mr-2 h-4 w-4" />
+            {t("tabWebhooks")}
+            {webhooks.length > 0 && (
+              <Badge variant="secondary" className="ml-2">{webhooks.length}</Badge>
+            )}
+          </TabsTrigger>
+          <TabsTrigger value="reference">
+            <BookOpen className="mr-2 h-4 w-4" />
+            {t("tabReference")}
+          </TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="webhooks" className="mt-4 space-y-4">
+          {webhooks.length === 0 ? (
+            <Card>
+              <CardContent className="flex flex-col items-center justify-center py-12 text-center">
+                <div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
+                  <WebhookIcon className="h-6 w-6 text-muted-foreground" />
+                </div>
+                <h3 className="mt-4 text-base font-semibold">{t("emptyTitle")}</h3>
+                <p className="mt-1 max-w-md text-sm text-muted-foreground">
+                  {t("emptyDescription")}
+                </p>
+                {canEdit && (
+                  <Button className="mt-4" onClick={openCreate}>
+                    <Plus className="mr-2 h-4 w-4" />
+                    {t("create")}
+                  </Button>
+                )}
+              </CardContent>
+            </Card>
+          ) : (
+            <div className="grid gap-3">
+              {webhooks.map((w) => (
+                <WebhookRow
+                  key={w.id}
+                  webhook={w}
+                  canEdit={canEdit}
+                  onEdit={() => openEdit(w)}
+                  onShowDeliveries={() => setDeliveriesFor(w)}
+                  onRefresh={() => router.refresh()}
+                />
+              ))}
+            </div>
+          )}
+
+          <SignatureHelpCard />
+        </TabsContent>
+
+        <TabsContent value="reference" className="mt-4">
+          <EventsReference />
+        </TabsContent>
+      </Tabs>
+
+      {dialogOpen && (
+        <WebhookFormDialog
+          open={dialogOpen}
+          onOpenChange={setDialogOpen}
+          editing={editing}
+          onCreated={(payload) => {
+            setCreatedSecret({ name: payload.name, secret: payload.secret });
+            setDialogOpen(false);
+            router.refresh();
+          }}
+          onUpdated={() => {
+            setDialogOpen(false);
+            router.refresh();
+          }}
+        />
+      )}
+
+      {createdSecret && (
+        <SecretRevealDialog
+          name={createdSecret.name}
+          secret={createdSecret.secret}
+          onClose={() => setCreatedSecret(null)}
+        />
+      )}
+
+      {deliveriesFor && (
+        <DeliveriesSheet
+          webhook={deliveriesFor}
+          onClose={() => setDeliveriesFor(null)}
+        />
+      )}
+    </div>
+  );
+}
+
+// --------------- Row ---------------
+
+function WebhookRow({
+  webhook,
+  canEdit,
+  onEdit,
+  onShowDeliveries,
+  onRefresh,
+}: {
+  webhook: Webhook;
+  canEdit: boolean;
+  onEdit: () => void;
+  onShowDeliveries: () => void;
+  onRefresh: () => void;
+}) {
+  const t = useTranslations("settings.webhooks");
+  const [isPending, startTransition] = useTransition();
+  const [confirmDelete, setConfirmDelete] = useState(false);
+  const [confirmRotate, setConfirmRotate] = useState(false);
+  const [rotated, setRotated] = useState<string | null>(null);
+  const [urlCopied, setUrlCopied] = useState(false);
+
+  const copyUrl = async () => {
+    await navigator.clipboard.writeText(webhook.url);
+    setUrlCopied(true);
+    setTimeout(() => setUrlCopied(false), 1500);
+  };
+
+  const handleToggle = () =>
+    startTransition(async () => {
+      const r = await toggleWebhook(webhook.id);
+      if (r.success) {
+        toast.success(r.data?.isActive ? t("resumed") : t("paused"));
+        onRefresh();
+      } else {
+        toast.error(r.error || t("toggleFailed"));
+      }
+    });
+
+  const handleTest = () =>
+    startTransition(async () => {
+      const r = await sendTestWebhook(webhook.id);
+      if (r.success) {
+        toast.success(t("testSent"));
+        onRefresh();
+      } else {
+        toast.error(r.error || t("testFailed"));
+      }
+    });
+
+  const handleDelete = () =>
+    startTransition(async () => {
+      const r = await deleteWebhook(webhook.id);
+      if (r.success) {
+        toast.success(t("deleted"));
+        setConfirmDelete(false);
+        onRefresh();
+      } else {
+        toast.error(r.error || t("deleteFailed"));
+      }
+    });
+
+  const handleRotate = () =>
+    startTransition(async () => {
+      const r = await rotateWebhookSecret(webhook.id);
+      if (r.success && r.data) {
+        setRotated(r.data.secret);
+        setConfirmRotate(false);
+      } else {
+        toast.error(r.error || t("rotateFailed"));
+      }
+    });
+
+  const status =
+    webhook.autoDisabled
+      ? "auto_disabled"
+      : !webhook.isActive
+        ? "paused"
+        : webhook.lastFailureAt && webhook.failureCount > 0
+          ? "failing"
+          : webhook.lastSuccessAt
+            ? "ok"
+            : "new";
+
+  return (
+    <Card>
+      <CardContent className="space-y-4 p-4">
+        {webhook.autoDisabled && (
+          <div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
+            <div className="flex items-center gap-2">
+              <PauseCircle className="h-4 w-4 shrink-0" />
+              <span>{t("autoDisabledBanner")}</span>
+            </div>
+            {canEdit && (
+              <Button size="sm" variant="outline" onClick={handleToggle} disabled={isPending}>
+                <PlayCircle className="mr-2 h-3.5 w-3.5" />
+                {t("reEnable")}
+              </Button>
+            )}
+          </div>
+        )}
+
+        <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+          <div className="min-w-0 flex-1 space-y-2">
+            <div className="flex flex-wrap items-center gap-2">
+              <p className="font-medium truncate">{webhook.name}</p>
+              {status === "ok" && (
+                <Badge variant="outline" className="border-emerald-500/30 text-emerald-600">
+                  <ShieldCheck className="mr-1 h-3 w-3" />
+                  {t("statusOk")}
+                </Badge>
+              )}
+              {status === "failing" && (
+                <Badge variant="outline" className="border-red-500/30 text-red-600">
+                  <AlertCircle className="mr-1 h-3 w-3" />
+                  {t("statusFailing", { count: webhook.failureCount })}
+                </Badge>
+              )}
+              {status === "paused" && !webhook.autoDisabled && (
+                <Badge variant="secondary">{t("paused")}</Badge>
+              )}
+              {status === "auto_disabled" && (
+                <Badge variant="outline" className="border-amber-500/30 text-amber-600">
+                  <PauseCircle className="mr-1 h-3 w-3" />
+                  {t("autoDisabled")}
+                </Badge>
+              )}
+            </div>
+
+            <div className="flex items-center gap-1">
+              <code className="truncate font-mono text-xs text-muted-foreground">
+                {webhook.url}
+              </code>
+              <Button
+                size="sm"
+                variant="ghost"
+                onClick={copyUrl}
+                className="h-6 w-6 p-0"
+                title={t("copyUrl")}
+              >
+                {urlCopied ? (
+                  <Check className="h-3 w-3 text-emerald-600" />
+                ) : (
+                  <Copy className="h-3 w-3" />
+                )}
+              </Button>
+            </div>
+
+            {webhook.description && (
+              <p className="text-xs text-muted-foreground">{webhook.description}</p>
+            )}
+
+            <div className="flex flex-wrap gap-1.5">
+              {webhook.events.includes("*") ? (
+                <Badge variant="secondary" className="font-mono text-[10px]">
+                  {t("subscribeToAll")}
+                </Badge>
+              ) : (
+                <>
+                  {webhook.events.slice(0, 6).map((e) => (
+                    <Badge key={e} variant="secondary" className="font-mono text-[10px]">
+                      {e}
+                    </Badge>
+                  ))}
+                  {webhook.events.length > 6 && (
+                    <Badge variant="outline" className="text-[10px]">
+                      +{webhook.events.length - 6}
+                    </Badge>
+                  )}
+                </>
+              )}
+            </div>
+
+            {webhook.lastTriggeredAt && (
+              <p className="text-xs text-muted-foreground">
+                {t("lastDelivery", {
+                  when: new Date(webhook.lastTriggeredAt).toLocaleString(),
+                })}{" "}
+                · {t("totalDeliveries", { count: webhook.deliveryCount })}
+              </p>
+            )}
+          </div>
+
+          <div className="flex flex-wrap items-center gap-2">
+            <Switch
+              checked={webhook.isActive}
+              onCheckedChange={handleToggle}
+              disabled={!canEdit || isPending}
+              aria-label={t("toggleAria")}
+            />
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={onShowDeliveries}
+              disabled={isPending}
+            >
+              <History className="mr-2 h-4 w-4" />
+              {t("deliveries")}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={handleTest}
+              disabled={!canEdit || isPending}
+            >
+              <Send className="mr-2 h-4 w-4" />
+              {t("sendTest")}
+            </Button>
+            {canEdit && (
+              <>
+                <Button variant="outline" size="sm" onClick={onEdit} disabled={isPending}>
+                  <Pencil className="h-4 w-4" />
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setConfirmRotate(true)}
+                  disabled={isPending}
+                  title={t("rotateSecret")}
+                >
+                  <RefreshCw className="h-4 w-4" />
+                </Button>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setConfirmDelete(true)}
+                  disabled={isPending}
+                  className="text-red-600 hover:text-red-700"
+                >
+                  <Trash2 className="h-4 w-4" />
+                </Button>
+              </>
+            )}
+          </div>
+        </div>
+      </CardContent>
+
+      <Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>{t("confirmDeleteTitle")}</DialogTitle>
+            <DialogDescription>
+              {t("confirmDeleteDescription", { name: webhook.name })}
+            </DialogDescription>
+          </DialogHeader>
+          <DialogFooter>
+            <Button
+              variant="outline"
+              onClick={() => setConfirmDelete(false)}
+              disabled={isPending}
+            >
+              {t("cancel")}
+            </Button>
+            <Button variant="destructive" onClick={handleDelete} disabled={isPending}>
+              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t("delete")}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      <Dialog
+        open={confirmRotate || !!rotated}
+        onOpenChange={(open) => {
+          if (!open) {
+            setConfirmRotate(false);
+            if (rotated) {
+              setRotated(null);
+              onRefresh();
+            }
+          }
+        }}
+      >
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>{rotated ? t("secretRotated") : t("confirmRotateTitle")}</DialogTitle>
+            <DialogDescription>
+              {rotated ? t("secretRotatedDescription") : t("confirmRotateDescription")}
+            </DialogDescription>
+          </DialogHeader>
+          {rotated && <SecretBlock value={rotated} />}
+          <DialogFooter>
+            {rotated ? (
+              <Button onClick={() => { setRotated(null); onRefresh(); }}>{t("done")}</Button>
+            ) : (
+              <>
+                <Button
+                  variant="outline"
+                  onClick={() => setConfirmRotate(false)}
+                  disabled={isPending}
+                >
+                  {t("cancel")}
+                </Button>
+                <Button onClick={handleRotate} disabled={isPending}>
+                  {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  {t("rotate")}
+                </Button>
+              </>
+            )}
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </Card>
+  );
+}
+
+// --------------- Form Dialog ---------------
+
+function WebhookFormDialog({
+  open,
+  onOpenChange,
+  editing,
+  onCreated,
+  onUpdated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  editing: Webhook | null;
+  onCreated: (payload: { name: string; secret: string }) => void;
+  onUpdated: () => void;
+}) {
+  const t = useTranslations("settings.webhooks");
+  const [isPending, startTransition] = useTransition();
+
+  const [name, setName] = useState(editing?.name || "");
+  const [url, setUrl] = useState(editing?.url || "");
+  const [description, setDescription] = useState(editing?.description || "");
+  const [events, setEvents] = useState<string[]>(
+    editing?.events && !editing.events.includes("*") ? editing.events : [],
+  );
+  const [allEvents, setAllEvents] = useState(editing?.events.includes("*") ?? false);
+
+  const toggleEvent = (e: string) => {
+    setEvents((prev) =>
+      prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e],
+    );
+  };
+
+  const toggleGroup = (group: readonly string[]) => {
+    const allSelected = group.every((e) => events.includes(e));
+    setEvents((prev) =>
+      allSelected
+        ? prev.filter((e) => !group.includes(e))
+        : Array.from(new Set([...prev, ...group])),
+    );
+  };
+
+  const submit = () => {
+    const finalEvents = allEvents ? ["*"] : events;
+    if (finalEvents.length === 0) {
+      toast.error(t("selectAtLeastOneEvent"));
+      return;
+    }
+    startTransition(async () => {
+      const payload = {
+        name: name.trim(),
+        url: url.trim(),
+        description: description.trim() || null,
+        events: finalEvents,
+      };
+      if (editing) {
+        const r = await updateWebhook({ ...payload, id: editing.id });
+        if (r.success) {
+          toast.success(t("updated"));
+          onUpdated();
+        } else {
+          toast.error(r.error || t("saveFailed"));
+        }
+      } else {
+        const r = await createWebhook(payload);
+        if (r.success && r.data) {
+          toast.success(t("created"));
+          onCreated({ name: r.data.name, secret: r.data.secret });
+        } else {
+          toast.error(r.error || t("saveFailed"));
+        }
+      }
+    });
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>{editing ? t("editTitle") : t("createTitle")}</DialogTitle>
+          <DialogDescription>{t("createDescription")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="max-h-[65vh] space-y-5 overflow-y-auto pr-1">
+          <div className="space-y-2">
+            <Label htmlFor="webhook-name">{t("name")}</Label>
+            <Input
+              id="webhook-name"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder={t("namePlaceholder")}
+              maxLength={100}
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="webhook-url">{t("url")}</Label>
+            <Input
+              id="webhook-url"
+              type="url"
+              value={url}
+              onChange={(e) => setUrl(e.target.value)}
+              placeholder="https://example.com/hooks/torqvoice"
+              maxLength={2048}
+            />
+            <p className="text-xs text-muted-foreground">{t("urlHint")}</p>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="webhook-description">{t("description")}</Label>
+            <Textarea
+              id="webhook-description"
+              value={description}
+              onChange={(e) => setDescription(e.target.value)}
+              rows={2}
+              maxLength={500}
+            />
+          </div>
+
+          <Separator />
+
+          <div className="space-y-3">
+            <div className="flex flex-wrap items-center justify-between gap-2">
+              <div>
+                <Label>{t("events")}</Label>
+                <p className="text-xs text-muted-foreground">{t("eventsHint")}</p>
+              </div>
+              <div className="flex items-center gap-2">
+                <Label htmlFor="all-events" className="text-sm font-normal">
+                  {t("subscribeToAll")}
+                </Label>
+                <Switch
+                  id="all-events"
+                  checked={allEvents}
+                  onCheckedChange={(v) => {
+                    setAllEvents(v);
+                    if (v) setEvents([]);
+                  }}
+                />
+              </div>
+            </div>
+
+            {!allEvents && (
+              <div className="space-y-4 rounded-lg border p-3">
+                {WEBHOOK_EVENT_GROUPS.map((g) => {
+                  const allSelected = g.events.every((e) =>
+                    events.includes(e as string),
+                  );
+                  return (
+                    <div key={g.key} className="space-y-2">
+                      <button
+                        type="button"
+                        onClick={() => toggleGroup(g.events as readonly string[])}
+                        className="text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
+                      >
+                        {t(`groups.${g.key}`)} {allSelected ? "✓" : ""}
+                      </button>
+                      <div className="grid grid-cols-2 gap-2 md:grid-cols-3">
+                        {g.events.map((e) => (
+                          <label
+                            key={e}
+                            className="flex cursor-pointer items-center gap-2 rounded p-1 text-sm hover:bg-muted/50"
+                          >
+                            <Checkbox
+                              checked={events.includes(e as string)}
+                              onCheckedChange={() => toggleEvent(e as string)}
+                            />
+                            <span className="font-mono text-xs">{e}</span>
+                          </label>
+                        ))}
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button
+            variant="outline"
+            onClick={() => onOpenChange(false)}
+            disabled={isPending}
+          >
+            {t("cancel")}
+          </Button>
+          <Button onClick={submit} disabled={isPending}>
+            {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            {editing ? t("save") : t("create")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// --------------- Secret Reveal ---------------
+
+function SecretRevealDialog({
+  name,
+  secret,
+  onClose,
+}: {
+  name: string;
+  secret: string;
+  onClose: () => void;
+}) {
+  const t = useTranslations("settings.webhooks");
+  return (
+    <Dialog open={true} onOpenChange={(o) => { if (!o) onClose(); }}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>{t("secretShownOnceTitle", { name })}</DialogTitle>
+          <DialogDescription>{t("secretShownOnceDescription")}</DialogDescription>
+        </DialogHeader>
+        <SecretBlock value={secret} />
+        <DialogFooter>
+          <Button onClick={onClose}>{t("done")}</Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+function SecretBlock({ value }: { value: string }) {
+  const [copied, setCopied] = useState(false);
+  const t = useTranslations("settings.webhooks");
+  const copy = async () => {
+    await navigator.clipboard.writeText(value);
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  };
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center gap-2 rounded-md border bg-muted/40 p-3">
+        <code className="flex-1 break-all font-mono text-xs">{value}</code>
+        <Button size="sm" variant="ghost" onClick={copy}>
+          {copied ? <Check className="h-4 w-4 text-emerald-600" /> : <Copy className="h-4 w-4" />}
+        </Button>
+      </div>
+      <p className="text-xs text-muted-foreground">{t("secretCopyHint")}</p>
+    </div>
+  );
+}
+
+// --------------- Deliveries ---------------
+
+const STATUS_FILTERS = ["all", "success", "failed", "retrying", "pending"] as const;
+type StatusFilter = (typeof STATUS_FILTERS)[number];
+
+function DeliveriesSheet({
+  webhook,
+  onClose,
+}: {
+  webhook: Webhook;
+  onClose: () => void;
+}) {
+  const t = useTranslations("settings.webhooks");
+  const [deliveries, setDeliveries] = useState<Delivery[] | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [filter, setFilter] = useState<StatusFilter>("all");
+  const [previewId, setPreviewId] = useState<string | null>(null);
+  const [, startTransition] = useTransition();
+
+  const load = async () => {
+    setLoading(true);
+    const r = await getWebhookDeliveries(webhook.id, 50);
+    if (r.success && r.data) {
+      setDeliveries(r.data);
+    } else {
+      setDeliveries([]);
+      if (r.error) toast.error(r.error);
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    load();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [webhook.id]);
+
+  const handleRetry = (id: string) =>
+    startTransition(async () => {
+      const r = await retryWebhookDelivery(id);
+      if (r.success) {
+        toast.success(t("retryQueued"));
+        load();
+      } else {
+        toast.error(r.error || t("retryFailed"));
+      }
+    });
+
+  const filtered = useMemo(() => {
+    if (!deliveries) return [];
+    if (filter === "all") return deliveries;
+    if (filter === "retrying") {
+      return deliveries.filter((d) => d.status === "retrying" || d.status === "inflight");
+    }
+    return deliveries.filter((d) => d.status === filter);
+  }, [deliveries, filter]);
+
+  const counts = useMemo(() => {
+    const c = { all: 0, success: 0, failed: 0, retrying: 0, pending: 0 } as Record<StatusFilter, number>;
+    for (const d of deliveries ?? []) {
+      c.all++;
+      const k = d.status === "inflight" ? "retrying" : (d.status as StatusFilter);
+      if (k in c) c[k]++;
+    }
+    return c;
+  }, [deliveries]);
+
+  return (
+    <>
+      <Sheet open={true} onOpenChange={(o) => { if (!o) onClose(); }}>
+        <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
+          <SheetHeader>
+            <SheetTitle>{t("deliveriesTitle", { name: webhook.name })}</SheetTitle>
+            <SheetDescription>{t("deliveriesDescription")}</SheetDescription>
+          </SheetHeader>
+
+          <div className="mt-4 flex flex-wrap items-center gap-2">
+            <Select value={filter} onValueChange={(v) => setFilter(v as StatusFilter)}>
+              <SelectTrigger className="w-44">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                {STATUS_FILTERS.map((s) => (
+                  <SelectItem key={s} value={s}>
+                    {t(`filter.${s}`)} ({counts[s]})
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            <Button variant="outline" size="sm" onClick={load} disabled={loading}>
+              <RefreshCw className={`mr-2 h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
+              {t("refresh")}
+            </Button>
+          </div>
+
+          <div className="mt-4 space-y-2">
+            {loading && (
+              <div className="flex items-center justify-center py-12">
+                <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+              </div>
+            )}
+            {!loading && filtered.length === 0 && (
+              <p className="py-12 text-center text-sm text-muted-foreground">
+                {filter === "all" ? t("deliveriesEmpty") : t("noMatches")}
+              </p>
+            )}
+            {!loading &&
+              filtered.map((d) => (
+                <div
+                  key={d.id}
+                  className="rounded-md border p-3 text-sm"
+                >
+                  <div className="flex flex-wrap items-center justify-between gap-2">
+                    <div className="flex items-center gap-2">
+                      <Badge
+                        variant={
+                          d.status === "success"
+                            ? "default"
+                            : d.status === "failed"
+                              ? "destructive"
+                              : "secondary"
+                        }
+                      >
+                        {d.status}
+                      </Badge>
+                      <code className="font-mono text-xs">{d.event}</code>
+                      {d.statusCode != null && (
+                        <span className="text-xs text-muted-foreground">
+                          HTTP {d.statusCode}
+                        </span>
+                      )}
+                    </div>
+                    <div className="flex items-center gap-1">
+                      <span className="text-xs text-muted-foreground">
+                        {new Date(d.createdAt).toLocaleString()}
+                      </span>
+                      <Button
+                        size="sm"
+                        variant="ghost"
+                        onClick={() => setPreviewId(d.id)}
+                        title={t("viewPayload")}
+                      >
+                        <Eye className="h-3 w-3" />
+                      </Button>
+                      {(d.status === "failed" || d.status === "retrying") && (
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => handleRetry(d.id)}
+                          title={t("retry")}
+                        >
+                          <RefreshCw className="h-3 w-3" />
+                        </Button>
+                      )}
+                    </div>
+                  </div>
+                  <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
+                    <span>{t("attempt", { n: d.attempt, max: d.maxAttempts })}</span>
+                    {d.durationMs != null && <span>{d.durationMs}ms</span>}
+                    {d.nextRetryAt && d.status === "retrying" && (
+                      <span>
+                        {t("nextRetry", {
+                          when: new Date(d.nextRetryAt).toLocaleString(),
+                        })}
+                      </span>
+                    )}
+                  </div>
+                  {d.errorMessage && (
+                    <p className="mt-1 break-all font-mono text-xs text-red-600">
+                      {d.errorMessage}
+                    </p>
+                  )}
+                </div>
+              ))}
+          </div>
+        </SheetContent>
+      </Sheet>
+
+      {previewId && (
+        <DeliveryPayloadDialog
+          deliveryId={previewId}
+          onClose={() => setPreviewId(null)}
+        />
+      )}
+    </>
+  );
+}
+
+function DeliveryPayloadDialog({
+  deliveryId,
+  onClose,
+}: {
+  deliveryId: string;
+  onClose: () => void;
+}) {
+  const t = useTranslations("settings.webhooks");
+  const [data, setData] = useState<Awaited<ReturnType<typeof getDeliveryPayload>>["data"] | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [copied, setCopied] = useState(false);
+
+  useEffect(() => {
+    let alive = true;
+    getDeliveryPayload(deliveryId).then((r) => {
+      if (!alive) return;
+      if (r.success && r.data) setData(r.data);
+      else if (r.error) toast.error(r.error);
+      setLoading(false);
+    });
+    return () => {
+      alive = false;
+    };
+  }, [deliveryId]);
+
+  const formattedPayload = useMemo(() => {
+    if (!data?.payload) return "";
+    try {
+      return JSON.stringify(JSON.parse(data.payload), null, 2);
+    } catch {
+      return data.payload;
+    }
+  }, [data]);
+
+  const formattedResponse = useMemo(() => {
+    if (!data?.responseBody) return "";
+    try {
+      return JSON.stringify(JSON.parse(data.responseBody), null, 2);
+    } catch {
+      return data.responseBody;
+    }
+  }, [data]);
+
+  const copyPayload = async () => {
+    if (!formattedPayload) return;
+    await navigator.clipboard.writeText(formattedPayload);
+    setCopied(true);
+    setTimeout(() => setCopied(false), 1500);
+    toast.success(t("payloadCopied"));
+  };
+
+  return (
+    <Dialog open={true} onOpenChange={(o) => { if (!o) onClose(); }}>
+      <DialogContent className="max-w-3xl">
+        <DialogHeader>
+          <DialogTitle>
+            {data ? `${data.event} · ${data.status}` : t("loadingPayload")}
+          </DialogTitle>
+          <DialogDescription>
+            {data
+              ? t("attempt", { n: data.attempt, max: data.maxAttempts })
+              : ""}
+          </DialogDescription>
+        </DialogHeader>
+
+        {loading && (
+          <div className="flex items-center justify-center py-12">
+            <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+          </div>
+        )}
+
+        {data && (
+          <div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
+            <div>
+              <div className="mb-1 flex items-center justify-between">
+                <Label className="text-xs uppercase tracking-wider text-muted-foreground">
+                  {t("requestPayload")}
+                </Label>
+                <Button size="sm" variant="ghost" onClick={copyPayload}>
+                  {copied ? (
+                    <Check className="h-3 w-3 text-emerald-600" />
+                  ) : (
+                    <Copy className="h-3 w-3" />
+                  )}
+                </Button>
+              </div>
+              <pre className="overflow-x-auto rounded-md bg-muted/40 p-3 text-[11px] leading-relaxed">
+                {formattedPayload}
+              </pre>
+            </div>
+
+            {(data.statusCode != null || formattedResponse || data.errorMessage) && (
+              <div>
+                <Label className="text-xs uppercase tracking-wider text-muted-foreground">
+                  {t("response")}
+                </Label>
+                <div className="mt-1 space-y-2">
+                  {data.statusCode != null && (
+                    <p className="text-xs font-mono text-muted-foreground">
+                      HTTP {data.statusCode}
+                      {data.durationMs != null ? ` · ${data.durationMs}ms` : ""}
+                    </p>
+                  )}
+                  {data.errorMessage && (
+                    <p className="break-all font-mono text-xs text-red-600">
+                      {data.errorMessage}
+                    </p>
+                  )}
+                  {formattedResponse && (
+                    <pre className="overflow-x-auto rounded-md bg-muted/40 p-3 text-[11px] leading-relaxed">
+                      {formattedResponse}
+                    </pre>
+                  )}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+
+        <DialogFooter>
+          <Button onClick={onClose}>{t("close")}</Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// --------------- Events Reference ---------------
+
+function EventsReference() {
+  const t = useTranslations("settings.webhooks");
+  const [openIdx, setOpenIdx] = useState<number | null>(0);
+  const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
+
+  const copy = async (idx: number, text: string) => {
+    await navigator.clipboard.writeText(text);
+    setCopiedIdx(idx);
+    setTimeout(() => setCopiedIdx(null), 1500);
+  };
+
+  return (
+    <div className="space-y-4">
+      <Card>
+        <CardContent className="space-y-2 p-4 text-sm">
+          <h2 className="font-semibold">{t("referenceTitle")}</h2>
+          <p className="text-muted-foreground">{t("referenceIntro")}</p>
+          <div className="rounded-md bg-muted/40 p-3 font-mono text-[11px]">
+            <p>POST [your-endpoint]</p>
+            <p>Content-Type: application/json</p>
+            <p>X-Torqvoice-Event: customer.create</p>
+            <p>X-Torqvoice-Delivery: cmh3...</p>
+            <p>X-Torqvoice-Attempt: 1</p>
+            <p>X-Torqvoice-Signature: t=1714397253123,v1=[hex]</p>
+          </div>
+        </CardContent>
+      </Card>
+
+      <div className="space-y-2">
+        {SAMPLE_PAYLOADS.map((s, i) => {
+          const envelope = JSON.stringify(buildSampleEnvelope(s), null, 2);
+          const open = openIdx === i;
+          return (
+            <Card key={s.event}>
+              <CardContent className="p-0">
+                <button
+                  type="button"
+                  onClick={() => setOpenIdx(open ? null : i)}
+                  className="flex w-full items-center justify-between gap-2 p-4 text-left transition-colors hover:bg-muted/40"
+                >
+                  <div className="space-y-1">
+                    <code className="font-mono text-xs font-semibold">{s.event}</code>
+                    <p className="text-xs text-muted-foreground">{s.description}</p>
+                  </div>
+                  <Badge variant="outline">{open ? "−" : "+"}</Badge>
+                </button>
+                {open && (
+                  <div className="border-t p-4">
+                    <div className="mb-2 flex items-center justify-between">
+                      <Label className="text-xs uppercase tracking-wider text-muted-foreground">
+                        {t("samplePayload")}
+                      </Label>
+                      <Button size="sm" variant="ghost" onClick={() => copy(i, envelope)}>
+                        {copiedIdx === i ? (
+                          <Check className="h-3 w-3 text-emerald-600" />
+                        ) : (
+                          <Copy className="h-3 w-3" />
+                        )}
+                      </Button>
+                    </div>
+                    <pre className="overflow-x-auto rounded-md bg-muted/40 p-3 text-[11px] leading-relaxed">
+                      {envelope}
+                    </pre>
+                  </div>
+                )}
+              </CardContent>
+            </Card>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// --------------- Help ---------------
+
+function SignatureHelpCard() {
+  const t = useTranslations("settings.webhooks");
+  return (
+    <Card>
+      <CardContent className="space-y-3 p-4 text-sm">
+        <h2 className="font-semibold">{t("helpTitle")}</h2>
+        <p className="text-muted-foreground">{t("helpIntro")}</p>
+        <div className="space-y-1">
+          <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
+            {t("helpHeadersTitle")}
+          </p>
+          <ul className="ml-4 list-disc space-y-1 font-mono text-xs">
+            <li>X-Torqvoice-Event</li>
+            <li>X-Torqvoice-Delivery</li>
+            <li>X-Torqvoice-Attempt</li>
+            <li>X-Torqvoice-Signature</li>
+          </ul>
+        </div>
+        <p className="text-xs text-muted-foreground">{t("helpVerify")}</p>
+        <pre className="overflow-x-auto rounded-md bg-muted/50 p-3 text-[11px] leading-relaxed">{`// Node.js example
+import { createHmac, timingSafeEqual } from 'node:crypto'
+
+function verify(secret, body, header) {
+  const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
+  const expected = createHmac('sha256', secret)
+    .update(\`\${parts.t}.\${body}\`).digest('hex')
+  return timingSafeEqual(
+    Buffer.from(expected, 'hex'),
+    Buffer.from(parts.v1, 'hex'),
+  )
+}`}</pre>
+      </CardContent>
+    </Card>
+  );
+}

+ 1 - 0
src/cronTasks.ts

@@ -4,3 +4,4 @@ export { processRecurringInvoices } from './lib/cron/recurring-invoices'
 export { cleanupPortalSessions } from './lib/cron/cleanup-portal-sessions'
 export { cleanupPortalSessions } from './lib/cron/cleanup-portal-sessions'
 export { cleanupAuditLogs } from './lib/cron/cleanup-audit-logs'
 export { cleanupAuditLogs } from './lib/cron/cleanup-audit-logs'
 export { processReportSchedules } from './lib/cron/report-schedules'
 export { processReportSchedules } from './lib/cron/report-schedules'
+export { processWebhookDeliveries, cleanupWebhookDeliveries } from './lib/cron/webhook-deliveries'

+ 386 - 0
src/features/webhooks/Actions/webhookActions.ts

@@ -0,0 +1,386 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import {
+  createWebhookSchema,
+  updateWebhookSchema,
+  WEBHOOK_EVENTS,
+} from "../Schema/webhookSchema";
+import { generateWebhookSecret, signPayload } from "../Lib/sign";
+import { deliverOnce } from "../Lib/deliver";
+import { checkWebhookUrl } from "../Lib/ssrf";
+
+const SSRF_REASONS: Record<string, string> = {
+  invalid_url: "URL is not valid",
+  scheme_not_http: "Only http:// and https:// URLs are allowed",
+  missing_host: "URL is missing a host",
+  metadata_host: "Cloud metadata endpoints are not allowed",
+  loopback_host: "Loopback hostnames are not allowed",
+  private_ip: "Private IP addresses are not allowed",
+  resolves_private: "URL resolves to a private network address",
+  dns_resolution_failed: "Could not resolve URL host",
+  dns_no_records: "No DNS records found for URL host",
+};
+
+function ssrfMessage(reason: string): string {
+  return SSRF_REASONS[reason] ?? `URL was rejected: ${reason}`;
+}
+
+export async function getWebhooks() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const rows = await db.webhook.findMany({
+        where: { organizationId },
+        orderBy: { createdAt: "desc" },
+        include: {
+          _count: { select: { deliveries: true } },
+        },
+      });
+      return rows.map((w) => ({
+        id: w.id,
+        name: w.name,
+        url: w.url,
+        description: w.description,
+        events: safeParseEvents(w.events),
+        isActive: w.isActive,
+        lastTriggeredAt: w.lastTriggeredAt,
+        lastSuccessAt: w.lastSuccessAt,
+        lastFailureAt: w.lastFailureAt,
+        failureCount: w.failureCount,
+        autoDisabled: !w.isActive && w.failureCount >= 20,
+        deliveryCount: w._count.deliveries,
+        createdAt: w.createdAt,
+      }));
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function createWebhook(input: unknown) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const data = createWebhookSchema.parse(input);
+      const safety = await checkWebhookUrl(data.url);
+      if (!safety.ok) throw new Error(ssrfMessage(safety.reason));
+      const secret = generateWebhookSecret();
+
+      const webhook = await db.webhook.create({
+        data: {
+          name: data.name,
+          url: data.url,
+          description: data.description ?? null,
+          events: JSON.stringify(data.events),
+          isActive: data.isActive ?? true,
+          secret,
+          organizationId,
+          createdById: userId,
+        },
+      });
+
+      revalidatePath("/settings/webhooks");
+      // Returned ONCE on creation; the secret never appears in subsequent reads.
+      return {
+        id: webhook.id,
+        name: webhook.name,
+        url: webhook.url,
+        secret,
+      };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: "webhook.create",
+        entity: "webhook",
+        entityId: result.id,
+        message: `Created webhook ${result.name}`,
+      }),
+    },
+  );
+}
+
+export async function updateWebhook(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = updateWebhookSchema.parse(input);
+      const existing = await db.webhook.findFirst({
+        where: { id: data.id, organizationId },
+      });
+      if (!existing) throw new Error("Webhook not found");
+
+      if (data.url !== existing.url) {
+        const safety = await checkWebhookUrl(data.url);
+        if (!safety.ok) throw new Error(ssrfMessage(safety.reason));
+      }
+
+      const reEnabling = data.isActive === true && existing.isActive === false;
+
+      const updated = await db.webhook.update({
+        where: { id: data.id },
+        data: {
+          name: data.name,
+          url: data.url,
+          description: data.description ?? null,
+          events: JSON.stringify(data.events),
+          isActive: data.isActive ?? existing.isActive,
+          ...(reEnabling ? { failureCount: 0 } : {}),
+        },
+      });
+
+      revalidatePath("/settings/webhooks");
+      return { id: updated.id };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: "webhook.update",
+        entity: "webhook",
+        entityId: result.id,
+      }),
+    },
+  );
+}
+
+export async function toggleWebhook(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.webhook.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) throw new Error("Webhook not found");
+      const reEnabling = !existing.isActive;
+      const updated = await db.webhook.update({
+        where: { id },
+        data: {
+          isActive: reEnabling,
+          ...(reEnabling ? { failureCount: 0 } : {}),
+        },
+      });
+      revalidatePath("/settings/webhooks");
+      return { id: updated.id, isActive: updated.isActive };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function deleteWebhook(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.webhook.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) throw new Error("Webhook not found");
+      await db.webhook.delete({ where: { id } });
+      revalidatePath("/settings/webhooks");
+      return { id };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: "webhook.delete",
+        entity: "webhook",
+        entityId: result.id,
+      }),
+    },
+  );
+}
+
+export async function rotateWebhookSecret(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.webhook.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) throw new Error("Webhook not found");
+      const secret = generateWebhookSecret();
+      await db.webhook.update({
+        where: { id },
+        data: { secret },
+      });
+      revalidatePath("/settings/webhooks");
+      return { id, secret };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+      audit: ({ result }) => ({
+        action: "webhook.rotateSecret",
+        entity: "webhook",
+        entityId: result.id,
+      }),
+    },
+  );
+}
+
+export async function sendTestWebhook(id: string) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const webhook = await db.webhook.findFirst({
+        where: { id, organizationId },
+      });
+      if (!webhook) throw new Error("Webhook not found");
+
+      const payload = JSON.stringify({
+        id: `evt_test_${Date.now().toString(36)}`,
+        event: "ping.test",
+        createdAt: new Date().toISOString(),
+        organizationId,
+        userId,
+        data: { test: true },
+      });
+
+      const delivery = await db.webhookDelivery.create({
+        data: {
+          webhookId: webhook.id,
+          event: "ping.test",
+          payload,
+          status: "pending",
+        },
+        select: { id: true },
+      });
+
+      await deliverOnce(delivery.id);
+      revalidatePath("/settings/webhooks");
+      return { deliveryId: delivery.id };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function getWebhookDeliveries(webhookId: string, limit: number = 25) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const webhook = await db.webhook.findFirst({
+        where: { id: webhookId, organizationId },
+        select: { id: true },
+      });
+      if (!webhook) throw new Error("Webhook not found");
+
+      const deliveries = await db.webhookDelivery.findMany({
+        where: { webhookId },
+        orderBy: { createdAt: "desc" },
+        take: Math.min(Math.max(limit, 1), 100),
+        select: {
+          id: true,
+          event: true,
+          status: true,
+          statusCode: true,
+          attempt: true,
+          maxAttempts: true,
+          errorMessage: true,
+          durationMs: true,
+          createdAt: true,
+          deliveredAt: true,
+          nextRetryAt: true,
+        },
+      });
+      return deliveries;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function retryWebhookDelivery(deliveryId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const delivery = await db.webhookDelivery.findFirst({
+        where: { id: deliveryId, webhook: { organizationId } },
+        select: { id: true, status: true },
+      });
+      if (!delivery) throw new Error("Delivery not found");
+
+      // Reset to pending so deliverOnce will pick it up
+      await db.webhookDelivery.update({
+        where: { id: deliveryId },
+        data: { status: "pending", nextRetryAt: null },
+      });
+      await deliverOnce(deliveryId);
+      revalidatePath("/settings/webhooks");
+      return { id: deliveryId };
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function getAvailableEvents() {
+  return withAuth(
+    async () => ({ events: [...WEBHOOK_EVENTS] }),
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+export async function getDeliveryPayload(deliveryId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const delivery = await db.webhookDelivery.findFirst({
+        where: { id: deliveryId, webhook: { organizationId } },
+        select: {
+          id: true,
+          event: true,
+          payload: true,
+          status: true,
+          statusCode: true,
+          responseBody: true,
+          errorMessage: true,
+          attempt: true,
+          maxAttempts: true,
+          durationMs: true,
+          createdAt: true,
+          deliveredAt: true,
+        },
+      });
+      if (!delivery) throw new Error("Delivery not found");
+      return delivery;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.SETTINGS },
+      ],
+    },
+  );
+}
+
+function safeParseEvents(raw: string): string[] {
+  try {
+    const v = JSON.parse(raw);
+    return Array.isArray(v) ? v : [];
+  } catch {
+    return [];
+  }
+}
+
+// Re-export for use in unit tests / route handlers
+export { signPayload };

+ 237 - 0
src/features/webhooks/Lib/deliver.ts

@@ -0,0 +1,237 @@
+import { db } from "@/lib/db";
+import { signPayload } from "./sign";
+import { checkWebhookUrl } from "./ssrf";
+
+const DELIVERY_TIMEOUT_MS = 10_000;
+const RESPONSE_TRUNCATE_BYTES = 4096;
+const MAX_PAYLOAD_BYTES = 256 * 1024;
+const USER_AGENT = "Torqvoice-Webhooks/1.0";
+const AUTO_DISABLE_THRESHOLD = 20;
+const STUCK_INFLIGHT_MS = 5 * 60 * 1000;
+
+/**
+ * attempt 1 → ~1m, 2 → ~5m, 3 → ~30m, 4 → ~2h, 5 → ~6h.
+ */
+function nextBackoff(attempt: number): Date {
+  const base = [60, 300, 1800, 7200, 21_600][Math.min(attempt - 1, 4)] ?? 21_600;
+  const jitter = Math.floor(Math.random() * Math.min(base * 0.2, 60));
+  return new Date(Date.now() + (base + jitter) * 1000);
+}
+
+/**
+ * Atomic claim: flip status from pending/retrying → inflight only if no other
+ * worker grabbed it. Returns false if another process already owns this row.
+ */
+async function claimDelivery(deliveryId: string): Promise<boolean> {
+  const r = await db.webhookDelivery.updateMany({
+    where: { id: deliveryId, status: { in: ["pending", "retrying"] } },
+    data: { status: "inflight" },
+  });
+  return r.count === 1;
+}
+
+/**
+ * Recover deliveries left in "inflight" by a crashed/killed worker. Anything
+ * older than STUCK_INFLIGHT_MS gets flipped back to "retrying" so the next
+ * cron tick will pick it up.
+ */
+export async function recoverStuckDeliveries(): Promise<number> {
+  const cutoff = new Date(Date.now() - STUCK_INFLIGHT_MS);
+  const r = await db.webhookDelivery.updateMany({
+    where: { status: "inflight", updatedAt: { lt: cutoff } },
+    data: { status: "retrying", nextRetryAt: new Date() },
+  });
+  return r.count;
+}
+
+/**
+ * Attempt one HTTP delivery. Atomically claims the row first; updates the
+ * row in place with the outcome. Never throws.
+ */
+export async function deliverOnce(deliveryId: string): Promise<void> {
+  const claimed = await claimDelivery(deliveryId);
+  if (!claimed) return;
+
+  const delivery = await db.webhookDelivery.findUnique({
+    where: { id: deliveryId },
+    include: { webhook: true },
+  });
+  if (!delivery || !delivery.webhook) return;
+
+  if (!delivery.webhook.isActive) {
+    await db.webhookDelivery.update({
+      where: { id: deliveryId },
+      data: { status: "failed", errorMessage: "webhook is disabled" },
+    });
+    return;
+  }
+
+  const attempt = delivery.attempt + 1;
+  const startedAt = Date.now();
+  const body = delivery.payload;
+
+  if (Buffer.byteLength(body, "utf8") > MAX_PAYLOAD_BYTES) {
+    await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
+      ok: false,
+      statusCode: null,
+      responseBody: null,
+      errorMessage: "payload exceeds maximum allowed size",
+      durationMs: 0,
+      forceFinal: true,
+    });
+    return;
+  }
+
+  const safety = await checkWebhookUrl(delivery.webhook.url);
+  if (!safety.ok) {
+    await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
+      ok: false,
+      statusCode: null,
+      responseBody: null,
+      errorMessage: `target rejected: ${safety.reason}`,
+      durationMs: 0,
+      forceFinal: true,
+    });
+    return;
+  }
+
+  const signature = signPayload(delivery.webhook.secret, body);
+  let statusCode: number | null = null;
+  let responseBody: string | null = null;
+  let errorMessage: string | null = null;
+  let ok = false;
+
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS);
+
+  try {
+    const res = await fetch(delivery.webhook.url, {
+      method: "POST",
+      signal: controller.signal,
+      redirect: "manual",
+      headers: {
+        "content-type": "application/json",
+        "user-agent": USER_AGENT,
+        "x-torqvoice-event": delivery.event,
+        "x-torqvoice-delivery": delivery.id,
+        "x-torqvoice-signature": signature,
+        "x-torqvoice-attempt": String(attempt),
+      },
+      body,
+    });
+    statusCode = res.status;
+    if (statusCode >= 300 && statusCode < 400) {
+      errorMessage = "redirects are not followed for security";
+      ok = false;
+    } else {
+      const text = await res.text().catch(() => "");
+      responseBody = text.slice(0, RESPONSE_TRUNCATE_BYTES);
+      ok = res.ok;
+    }
+  } catch (err) {
+    if (err instanceof Error && err.name === "AbortError") {
+      errorMessage = `timeout after ${DELIVERY_TIMEOUT_MS}ms`;
+    } else {
+      errorMessage = err instanceof Error ? err.message : String(err);
+    }
+  } finally {
+    clearTimeout(timeout);
+  }
+
+  await finalize(delivery.id, delivery.webhookId, attempt, delivery.maxAttempts, {
+    ok,
+    statusCode,
+    responseBody,
+    errorMessage,
+    durationMs: Date.now() - startedAt,
+  });
+}
+
+async function finalize(
+  deliveryId: string,
+  webhookId: string,
+  attempt: number,
+  maxAttempts: number,
+  outcome: {
+    ok: boolean;
+    statusCode: number | null;
+    responseBody: string | null;
+    errorMessage: string | null;
+    durationMs: number;
+    forceFinal?: boolean;
+  },
+): Promise<void> {
+  const finalAttempt = outcome.forceFinal || attempt >= maxAttempts;
+  const status = outcome.ok ? "success" : finalAttempt ? "failed" : "retrying";
+  const nextRetryAt = outcome.ok || finalAttempt ? null : nextBackoff(attempt);
+
+  await db.webhookDelivery.update({
+    where: { id: deliveryId },
+    data: {
+      attempt,
+      status,
+      statusCode: outcome.statusCode,
+      responseBody: outcome.responseBody,
+      errorMessage: outcome.errorMessage,
+      durationMs: outcome.durationMs,
+      nextRetryAt,
+      deliveredAt: outcome.ok ? new Date() : undefined,
+    },
+  });
+
+  if (outcome.ok) {
+    await db.webhook.update({
+      where: { id: webhookId },
+      data: {
+        lastTriggeredAt: new Date(),
+        lastSuccessAt: new Date(),
+        failureCount: 0,
+      },
+    });
+    return;
+  }
+
+  if (!finalAttempt) {
+    await db.webhook.update({
+      where: { id: webhookId },
+      data: { lastTriggeredAt: new Date() },
+    });
+    return;
+  }
+
+  // Permanent failure — bump counter and auto-disable past the threshold so
+  // we stop hammering a clearly-broken endpoint.
+  const updated = await db.webhook.update({
+    where: { id: webhookId },
+    data: {
+      lastTriggeredAt: new Date(),
+      lastFailureAt: new Date(),
+      failureCount: { increment: 1 },
+    },
+    select: { failureCount: true, isActive: true },
+  });
+
+  if (updated.isActive && updated.failureCount >= AUTO_DISABLE_THRESHOLD) {
+    await db.webhook.update({
+      where: { id: webhookId },
+      data: { isActive: false },
+    });
+  }
+}
+
+export async function processDueDeliveries(limit: number = 100): Promise<number> {
+  const due = await db.webhookDelivery.findMany({
+    where: {
+      status: { in: ["pending", "retrying"] },
+      OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
+    },
+    orderBy: { createdAt: "asc" },
+    take: limit,
+    select: { id: true },
+  });
+
+  await Promise.allSettled(due.map((d) => deliverOnce(d.id)));
+  return due.length;
+}
+
+export const __TEST__ = { nextBackoff, AUTO_DISABLE_THRESHOLD, MAX_PAYLOAD_BYTES };

+ 97 - 0
src/features/webhooks/Lib/dispatcher.ts

@@ -0,0 +1,97 @@
+import { db } from "@/lib/db";
+import { WEBHOOK_EVENTS } from "../Schema/webhookSchema";
+import { deliverOnce } from "./deliver";
+
+const KNOWN = new Set<string>([...WEBHOOK_EVENTS, "*"]);
+
+export type DispatchInput = {
+  event: string;
+  organizationId: string;
+  entity?: string | null;
+  entityId?: string | null;
+  message?: string | null;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  data?: Record<string, any> | null;
+  userId?: string | null;
+};
+
+/**
+ * Dispatch an event to all webhooks in the org subscribed to it. Creates one
+ * WebhookDelivery row per matching webhook, then attempts immediate delivery
+ * in the background (fire-and-forget). Failed attempts get retried by the cron.
+ *
+ * No-op for unknown events so we don't generate deliveries for noise like
+ * `auth.permissionDenied` or future audit-only actions.
+ */
+export async function dispatchWebhookEvent(input: DispatchInput): Promise<void> {
+  if (!input.event || !input.organizationId) return;
+  if (!KNOWN.has(input.event)) return;
+
+  let webhooks: { id: string; events: string }[] = [];
+  try {
+    webhooks = await db.webhook.findMany({
+      where: { organizationId: input.organizationId, isActive: true },
+      select: { id: true, events: true },
+    });
+  } catch (err) {
+    console.error("[webhooks] failed to load subscribers:", err);
+    return;
+  }
+  if (webhooks.length === 0) return;
+
+  const matching = webhooks.filter((w) => {
+    let subscribed: string[] = [];
+    try {
+      subscribed = JSON.parse(w.events) as string[];
+    } catch {
+      return false;
+    }
+    return subscribed.includes("*") || subscribed.includes(input.event);
+  });
+  if (matching.length === 0) return;
+
+  const payload = JSON.stringify({
+    id: cryptoRandomId(),
+    event: input.event,
+    createdAt: new Date().toISOString(),
+    organizationId: input.organizationId,
+    entity: input.entity ?? null,
+    entityId: input.entityId ?? null,
+    message: input.message ?? null,
+    userId: input.userId ?? null,
+    data: input.data ?? null,
+  });
+
+  const created = await Promise.all(
+    matching.map((w) =>
+      db.webhookDelivery
+        .create({
+          data: {
+            webhookId: w.id,
+            event: input.event,
+            payload,
+            status: "pending",
+          },
+          select: { id: true },
+        })
+        .catch((err) => {
+          console.error("[webhooks] failed to enqueue delivery:", err);
+          return null;
+        }),
+    ),
+  );
+
+  // Fire-and-forget initial delivery. Failures are picked up by the retry cron.
+  for (const row of created) {
+    if (!row) continue;
+    setImmediate(() => {
+      deliverOnce(row.id).catch((err) => {
+        console.error("[webhooks] delivery failed:", err);
+      });
+    });
+  }
+}
+
+function cryptoRandomId(): string {
+  return `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
+}

+ 63 - 0
src/features/webhooks/Lib/samples.ts

@@ -0,0 +1,63 @@
+/**
+ * Example payloads shown in the events reference panel. These mirror the
+ * shape produced by `dispatchWebhookEvent` so integrators can stub their
+ * receivers against realistic input.
+ */
+
+export type SamplePayload = {
+  event: string;
+  description: string;
+  data: Record<string, unknown>;
+};
+
+export const SAMPLE_PAYLOADS: SamplePayload[] = [
+  {
+    event: "customer.create",
+    description: "A new customer was added to your workshop.",
+    data: { id: "cus_1aB2cD3eF4", name: "Acme Logistics" },
+  },
+  {
+    event: "vehicle.create",
+    description: "A vehicle was registered against a customer.",
+    data: { id: "veh_8K9L0", licensePlate: "AB12345", make: "Volvo", model: "FH16" },
+  },
+  {
+    event: "service.status",
+    description: "A work-order status changed (e.g. → completed, ready).",
+    data: { id: "svc_R8c9", status: "completed", previousStatus: "in_progress" },
+  },
+  {
+    event: "payment.create",
+    description: "A payment was recorded against an invoice.",
+    data: { id: "pmt_zX1y", invoiceId: "svc_R8c9", amount: 12500, currency: "NOK", method: "card" },
+  },
+  {
+    event: "quote.status",
+    description: "A quote moved between draft / sent / accepted / declined.",
+    data: { id: "quo_44A", status: "accepted" },
+  },
+  {
+    event: "inspection.complete",
+    description: "A digital inspection was submitted for a vehicle.",
+    data: { id: "ins_77B", vehicleId: "veh_8K9L0", findings: 2 },
+  },
+  {
+    event: "ping.test",
+    description: "Sent by the “Send test” button in the settings page.",
+    data: { test: true },
+  },
+];
+
+export function buildSampleEnvelope(sample: SamplePayload) {
+  return {
+    id: "evt_2k0r6h6f7g4z",
+    event: sample.event,
+    createdAt: "2026-04-29T10:00:00.000Z",
+    organizationId: "org_4nFgYkM",
+    entity: sample.event.split(".")[0],
+    entityId: sample.data.id ?? null,
+    message: null,
+    userId: "usr_3K8qP1",
+    data: sample.data,
+  };
+}

+ 51 - 0
src/features/webhooks/Lib/sign.ts

@@ -0,0 +1,51 @@
+import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
+
+/**
+ * Generate a cryptographically random secret. Format: `whsec_<48 hex chars>`.
+ * Shown to the user once on webhook creation, stored verbatim.
+ */
+export function generateWebhookSecret(): string {
+  return `whsec_${randomBytes(24).toString("hex")}`;
+}
+
+/**
+ * Sign a payload with HMAC-SHA256, including a timestamp to prevent replay.
+ * Format follows Stripe's pattern: `t=<unix>,v1=<hex>`.
+ */
+export function signPayload(secret: string, body: string, timestamp: number = Date.now()): string {
+  const signedPayload = `${timestamp}.${body}`;
+  const sig = createHmac("sha256", secret).update(signedPayload).digest("hex");
+  return `t=${timestamp},v1=${sig}`;
+}
+
+/**
+ * Verify an inbound signature header against a body + secret. Constant-time
+ * comparison; returns false on any malformed input. Tolerance defaults to 5min.
+ */
+export function verifySignature(
+  secret: string,
+  body: string,
+  header: string,
+  toleranceMs: number = 5 * 60 * 1000,
+): boolean {
+  const parts = Object.fromEntries(
+    header.split(",").map((kv) => {
+      const [k, v] = kv.split("=");
+      return [k, v];
+    }),
+  );
+  const t = Number(parts.t);
+  const v1 = parts.v1;
+  if (!Number.isFinite(t) || !v1) return false;
+  if (Math.abs(Date.now() - t) > toleranceMs) return false;
+
+  const expected = createHmac("sha256", secret)
+    .update(`${t}.${body}`)
+    .digest("hex");
+  if (expected.length !== v1.length) return false;
+  try {
+    return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
+  } catch {
+    return false;
+  }
+}

+ 96 - 0
src/features/webhooks/Lib/ssrf.ts

@@ -0,0 +1,96 @@
+import { lookup as dnsLookup } from "node:dns/promises";
+import { isIP } from "node:net";
+
+/**
+ * Block webhook URLs that resolve to private, loopback, link-local, or
+ * cloud metadata addresses to prevent SSRF. Operators can opt-out for
+ * dev/testing by setting WEBHOOKS_ALLOW_PRIVATE_TARGETS=true.
+ */
+
+const PRIVATE_V4 = [
+  /^10\./,
+  /^127\./,
+  /^169\.254\./,
+  /^192\.168\./,
+  /^172\.(1[6-9]|2\d|3[0-1])\./,
+  /^0\./,
+  /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT
+];
+
+const PRIVATE_V6_PREFIXES = ["::1", "fc", "fd", "fe80:", "::ffff:"];
+
+const BLOCKED_HOSTS = new Set([
+  "metadata.google.internal",
+  "metadata.goog",
+]);
+
+export type SsrfResult = { ok: true } | { ok: false; reason: string };
+export type DnsResolver = (host: string) => Promise<{ address: string; family: number }[]>;
+
+export function isPrivateAddress(addr: string): boolean {
+  const family = isIP(addr);
+  if (family === 4) {
+    return PRIVATE_V4.some((rx) => rx.test(addr));
+  }
+  if (family === 6) {
+    const lower = addr.toLowerCase();
+    if (lower === "::" || lower === "::1") return true;
+    return PRIVATE_V6_PREFIXES.some((p) => lower.startsWith(p));
+  }
+  return false;
+}
+
+const defaultResolver: DnsResolver = (host) => dnsLookup(host, { all: true });
+
+export async function checkWebhookUrl(
+  rawUrl: string,
+  resolver: DnsResolver = defaultResolver,
+): Promise<SsrfResult> {
+  if (process.env.WEBHOOKS_ALLOW_PRIVATE_TARGETS === "true") {
+    return { ok: true };
+  }
+
+  let url: URL;
+  try {
+    url = new URL(rawUrl);
+  } catch {
+    return { ok: false, reason: "invalid_url" };
+  }
+
+  if (url.protocol !== "http:" && url.protocol !== "https:") {
+    return { ok: false, reason: "scheme_not_http" };
+  }
+
+  const host = url.hostname.toLowerCase();
+  if (!host) return { ok: false, reason: "missing_host" };
+
+  if (BLOCKED_HOSTS.has(host)) {
+    return { ok: false, reason: "metadata_host" };
+  }
+
+  if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
+    return { ok: false, reason: "loopback_host" };
+  }
+
+  if (isIP(host)) {
+    return isPrivateAddress(host)
+      ? { ok: false, reason: "private_ip" }
+      : { ok: true };
+  }
+
+  let addrs: { address: string; family: number }[] = [];
+  try {
+    addrs = await resolver(host);
+  } catch {
+    return { ok: false, reason: "dns_resolution_failed" };
+  }
+  if (addrs.length === 0) {
+    return { ok: false, reason: "dns_no_records" };
+  }
+  for (const a of addrs) {
+    if (isPrivateAddress(a.address)) {
+      return { ok: false, reason: "resolves_private" };
+    }
+  }
+  return { ok: true };
+}

+ 106 - 0
src/features/webhooks/Schema/webhookSchema.ts

@@ -0,0 +1,106 @@
+import { z } from "zod";
+
+/**
+ * Catalog of webhook events. Each entry maps to an audit-log action name —
+ * `logAudit` fans these out to subscribed webhooks.
+ *
+ * "*" is a special wildcard that matches all events.
+ */
+export const WEBHOOK_EVENTS = [
+  // Customer
+  "customer.create",
+  "customer.update",
+  "customer.delete",
+  // Vehicle
+  "vehicle.create",
+  "vehicle.update",
+  "vehicle.delete",
+  "vehicle.archive",
+  "vehicle.unarchive",
+  // Service / work order
+  "service.create",
+  "service.update",
+  "service.delete",
+  "service.status",
+  // Quote
+  "quote.create",
+  "quote.update",
+  "quote.delete",
+  "quote.status",
+  "quote.convert",
+  // Payment
+  "payment.create",
+  "payment.delete",
+  // Inspection
+  "inspection.create",
+  "inspection.complete",
+  "inspection.delete",
+  // Inventory
+  "inventory.create",
+  "inventory.update",
+  "inventory.delete",
+  // Finding
+  "finding.create",
+  "finding.update",
+  "finding.resolve",
+  "finding.delete",
+  // Diagnostic
+  "ping.test",
+] as const;
+
+export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] | "*";
+
+const URL_RX = /^https?:\/\//i;
+
+export const createWebhookSchema = z.object({
+  name: z.string().trim().min(1, "Name is required").max(100),
+  url: z
+    .string()
+    .trim()
+    .min(1, "URL is required")
+    .max(2048)
+    .refine((v) => URL_RX.test(v), "URL must start with http:// or https://"),
+  description: z.string().trim().max(500).optional().nullable(),
+  events: z
+    .array(z.string())
+    .min(1, "Select at least one event")
+    .refine(
+      (arr) => arr.every((e) => e === "*" || (WEBHOOK_EVENTS as readonly string[]).includes(e)),
+      "Unknown event",
+    ),
+  isActive: z.boolean().optional(),
+});
+
+export const updateWebhookSchema = createWebhookSchema.extend({
+  id: z.string().min(1),
+});
+
+export type CreateWebhookInput = z.infer<typeof createWebhookSchema>;
+export type UpdateWebhookInput = z.infer<typeof updateWebhookSchema>;
+
+/** Group events for the settings UI checkbox grid. */
+export const WEBHOOK_EVENT_GROUPS: Array<{ key: string; events: readonly WebhookEvent[] }> = [
+  { key: "customers", events: ["customer.create", "customer.update", "customer.delete"] },
+  {
+    key: "vehicles",
+    events: [
+      "vehicle.create",
+      "vehicle.update",
+      "vehicle.delete",
+      "vehicle.archive",
+      "vehicle.unarchive",
+    ],
+  },
+  {
+    key: "services",
+    events: ["service.create", "service.update", "service.status", "service.delete"],
+  },
+  {
+    key: "quotes",
+    events: ["quote.create", "quote.update", "quote.status", "quote.convert", "quote.delete"],
+  },
+  { key: "payments", events: ["payment.create", "payment.delete"] },
+  { key: "inspections", events: ["inspection.create", "inspection.complete", "inspection.delete"] },
+  { key: "inventory", events: ["inventory.create", "inventory.update", "inventory.delete"] },
+  { key: "findings", events: ["finding.create", "finding.update", "finding.resolve", "finding.delete"] },
+];

+ 12 - 1
src/instrumentation.ts

@@ -1,11 +1,22 @@
 export async function register() {
 export async function register() {
   if (process.env.NEXT_RUNTIME === 'nodejs') {
   if (process.env.NEXT_RUNTIME === 'nodejs') {
-    const { checkLicenses, checkSubscriptions, processRecurringInvoices, cleanupPortalSessions, cleanupAuditLogs, processReportSchedules } = await import('./cronTasks')
+    const {
+      checkLicenses,
+      checkSubscriptions,
+      processRecurringInvoices,
+      cleanupPortalSessions,
+      cleanupAuditLogs,
+      processReportSchedules,
+      processWebhookDeliveries,
+      cleanupWebhookDeliveries,
+    } = await import('./cronTasks')
     checkLicenses()
     checkLicenses()
     checkSubscriptions()
     checkSubscriptions()
     processRecurringInvoices()
     processRecurringInvoices()
     cleanupPortalSessions()
     cleanupPortalSessions()
     cleanupAuditLogs()
     cleanupAuditLogs()
     processReportSchedules()
     processReportSchedules()
+    processWebhookDeliveries()
+    cleanupWebhookDeliveries()
   }
   }
 }
 }

+ 21 - 1
src/lib/audit.ts

@@ -33,5 +33,25 @@ export async function logAudit(
     // Don't block core flows due to logging failure
     // Don't block core flows due to logging failure
     console.error("[audit] failed to write log:", err);
     console.error("[audit] failed to write log:", err);
   }
   }
-}
 
 
+  // Fan out to webhooks. Lazy-imported to avoid pulling Prisma webhook code
+  // into modules that only need audit logging (and to break a potential
+  // circular import if webhooks ever logs audits of its own).
+  if (ctx.organizationId && event.action) {
+    import("@/features/webhooks/Lib/dispatcher")
+      .then(({ dispatchWebhookEvent }) =>
+        dispatchWebhookEvent({
+          event: event.action,
+          organizationId: ctx.organizationId,
+          entity: event.entity ?? null,
+          entityId: event.entityId ?? null,
+          message: event.message ?? null,
+          data: event.metadata ?? null,
+          userId: ctx.userId || null,
+        }),
+      )
+      .catch((err) => {
+        console.error("[webhooks] dispatch failed:", err);
+      });
+  }
+}

+ 56 - 0
src/lib/cron/webhook-deliveries.ts

@@ -0,0 +1,56 @@
+import { CronJob } from "cron";
+import { db } from "@/lib/db";
+import {
+  processDueDeliveries,
+  recoverStuckDeliveries,
+} from "@/features/webhooks/Lib/deliver";
+
+/**
+ * Webhook delivery retry cron — runs every minute. First sweeps any
+ * "inflight" rows that look stuck (a worker crashed mid-delivery), then
+ * picks up due retries and re-attempts them with HMAC signing.
+ */
+export function processWebhookDeliveries() {
+  const job = new CronJob("* * * * *", async () => {
+    try {
+      const recovered = await recoverStuckDeliveries();
+      if (recovered > 0) {
+        console.warn(`[cron] Recovered ${recovered} stuck webhook deliveries`);
+      }
+      const count = await processDueDeliveries(100);
+      if (count > 0) {
+        console.warn(`[cron] Webhook deliveries processed: ${count}`);
+      }
+    } catch (err) {
+      console.error("[cron] Webhook delivery processor failed:", err);
+    }
+  });
+  job.start();
+  console.warn("[cron] Webhook delivery processor started (every minute)");
+}
+
+/**
+ * Webhook delivery retention cleanup — daily at 03:30 UTC. Drops delivery
+ * rows older than WEBHOOK_DELIVERY_RETENTION_DAYS (default 30).
+ */
+export function cleanupWebhookDeliveries() {
+  const job = new CronJob("30 3 * * *", async () => {
+    try {
+      const parsed = parseInt(process.env.WEBHOOK_DELIVERY_RETENTION_DAYS || "30", 10);
+      const days = Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
+      const cutoff = new Date();
+      cutoff.setDate(cutoff.getDate() - days);
+      const result = await db.webhookDelivery.deleteMany({
+        where: { createdAt: { lt: cutoff } },
+      });
+      if (result.count > 0) {
+        console.warn(
+          `[cron] Webhook delivery cleanup: deleted ${result.count} rows older than ${days} days`,
+        );
+      }
+    } catch (err) {
+      console.error("[cron] Webhook delivery cleanup failed:", err);
+    }
+  });
+  job.start();
+}