Initial commit: sap-webhook-api

API NestJS que recibe el Webhook Messenger de SAP Business One
(CloudEvents), valida HMAC opcional y guarda cada notificacion
local (data/events/) e inserta en Postgres (webhook_events).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-16 07:41:25 -05:00
commit 5e0df73477
28 files changed
+11773

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Puerto donde escucha la API (default 3000)
PORT=3000
# Postgres compartida con sap-intercompany-worker (ver docker-compose.yml en la raíz)
DATABASE_URL=postgresql://intercompany:intercompany@localhost:5432/sap_intercompany?schema=public
# Si se define, la API exige la firma X-B1-Webhook-Signature (HMAC-SHA256)
# en /webhooks/sap. Si se deja vacío/comentado, acepta todo sin firma
# (equivalente a AuthenticationType: None en la suscripción de SAP).
HMAC_SECRET_KEY=
# Datos usados solo por los scripts en scripts/ (Login + PATCH a Service Layer)
SERVICE_LAYER_URL=https://127.0.0.1:50000/b1s/v2
COMPANY_DB=SBODemoCL
SL_USER=manager
SL_PASS=manager
WEBHOOK_ID=SapWebhookApiTest
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
.env
certs/*.pem
data/events/*.json
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+66
View File
@@ -0,0 +1,66 @@
# sap-webhook-api
API en NestJS que escucha el Webhook Messenger de SAP Business One
(Service Layer) y captura cada Event Notification que llega.
## Qué hace
- Expone `POST /webhooks/sap` por **HTTPS** (certificado autofirmado en
`certs/`), pensado para responder rápido y no arriesgar el timeout de
10s que impone el Webhook Messenger de SAP.
- Soporta el payload real de SAP: un array de eventos en formato
[CloudEvents](https://cloudevents.io/).
- Guarda cada notificación como:
- un archivo `.json` en `data/events/` (uno por evento, nombrado por su
`id`), y
- una fila en Postgres (tabla `webhook_events`), para que
[`sap-intercompany-worker`](../sap-intercompany-worker) la pueda
procesar de forma asíncrona.
- Soporta autenticación `None` (default) o `HMAC` (`X-B1-Webhook-Signature`,
HMAC-SHA256), activable/desactivable con un script que sincroniza SAP y
la propia API — ver `docs/EJECUCION.md`.
- Expone también `GET /webhooks/sap` (chequeo de vida / handshake) porque
SAP lo usa para validar el endpoint antes de despachar eventos.
## Quick start
```bash
npm install
cp .env.example .env # completar con los datos reales de tu Service Layer
# Postgres compartida con sap-intercompany-worker (una sola vez, desde la raíz del repo)
cd .. && docker compose up -d && cd sap-webhook-api
npm run start
```
La API queda escuchando en `https://<tu-ip>:3000/webhooks/sap`.
## Estructura
```
src/
main.ts # bootstrap, HTTPS
webhooks/
webhooks.controller.ts # GET/POST /webhooks/sap
webhook-events.repository.ts # insert a Postgres (webhook_events)
certs/ # certificado autofirmado (key.pem / cert.pem)
data/events/ # cada notificación, como .json
scripts/ # enable/disable-hmac-auth.sh
tools/sl.http # requests de referencia contra el Service Layer
docs/ # bitácora, arquitectura, guía de ejecución
```
## Documentación
- `docs/PASOS-REALIZADOS.md` — bitácora paso a paso de cómo se armó el
proyecto y se configuró la suscripción en SAP.
- `docs/EJECUCION.md` — cómo correr la API en modo `None` y en modo `HMAC`.
- `docs/ARQUITECTURA.md` / `docs/ARQUITECTURA.pdf` — diagrama de
arquitectura (servidores, puertos, certificados).
- `tools/sl.http` — requests de ejemplo (Login, EventSubscriptions, HMAC)
para usar con la extensión REST Client / httpyac.
Para el diagnóstico del bug de SAP que bloqueaba la entrega de eventos
(`DATEFORMAT`), y el resto del contexto de la sesión, ver `CLAUDE.md` en la
raíz del repo.
+78
View File
@@ -0,0 +1,78 @@
# Del alta de una Orden al archivo JSON
Cómo viaja un evento de SAP Business One hasta quedar guardado en disco por
`sap-webhook-api`: los dos servidores involucrados, sus componentes,
puertos e IPs de ejemplo, y dónde entra en juego el certificado
autofirmado.
## Diagrama
```mermaid
flowchart LR
subgraph SAP["SAP Business One — servidor Windows · 192.168.1.205"]
direction TB
SL["Service Layer\nsap10:50000\nLogin · EventSubscriptions\ncert propio autofirmado"]
SQL["SQL Server (MSSQL)\nDB: SBODemoCL\nTablas: OEVC · OEVN · EVN1\nlogin: sa"]
WM["Webhook Messenger\npuerto 40008\nWebhookMessenger.jar\nDEFAULT_LANGUAGE=us_english (workaround)"]
SL <-->|"Login / EventSubscriptions"| SQL
SQL <-->|"SELECT/UPDATE OEVN, EVN1 (login sa)"| WM
end
subgraph MAC["Mac de desarrollo · 192.168.1.166"]
direction TB
CFG["Configuración\ncurl / sl.http"]
API["sap-webhook-api (NestJS)\n192.168.1.166:3000\nPOST /webhooks/sap\ncert CN/SAN=192.168.1.166"]
FS["data/events/*.json\n1 archivo por notificación"]
API -->|guarda| FS
end
CFG -- "HTTPS :50000\nLogin, EventSubscriptions" --> SL
WM -- "HTTPS :3000\nCloudEvents batch\nPOST /webhooks/sap" --> API
classDef warn fill:#faf0dd,stroke:#a8690b,color:#7a4c08;
class WM warn
```
Dos servidores en la misma LAN. La Mac llama al Service Layer para
configurar la suscripción (flecha superior); el Webhook Messenger — que
corre dentro del servidor SAP y lee la cola de eventos en SQL Server
autenticado como `sa` — es quien llama de vuelta a la Mac para entregar
cada evento (flecha inferior). El recuadro en ámbar marca el workaround de
`DATEFORMAT` aplicado para que esa segunda flecha funcione.
## Componentes e IPs de ejemplo
| Componente | Host : puerto | Protocolo | Notas |
|---|---|---|---|
| **Service Layer** (SAP Business One) | `sap10 : 50000` | HTTPS | Certificado propio autofirmado; nuestras llamadas lo ignoran con `curl -k`. |
| **Webhook Messenger** (servicio Windows) | `192.168.1.205 : 40008` | TCP (health) + JDBC a SQL Server | Se conecta a SQL Server como `sa`; despacha los eventos por HTTPS hacia el `WebhookURL` suscripto. |
| **SQL Server** (MSSQL) | — (interno) | TDS | DB `SBODemoCL`. Tablas `OEVC` / `OEVN` / `EVN1`. |
| **sap-webhook-api** (NestJS) | `192.168.1.166 : 3000` | HTTPS | Certificado autofirmado propio, `CN/SAN = 192.168.1.166`. |
| **data/events/** (filesystem local) | — (disco) | — | Un archivo `.json` por notificación recibida. |
## 🔐 Certificado autofirmado
Generado en la Mac para `CN/SAN = 192.168.1.166` — la IP donde corre
`sap-webhook-api`, no la del servidor SAP.
Ese nombre **tiene que coincidir** con el host que se declara como
`WebhookURL` al crear la suscripción
(`https://192.168.1.166:3000/webhooks/sap`). Como la suscripción usa
`VerifyCertificate: tNO`, SAP nunca valida este certificado — si más
adelante se activa la verificación, hay que importarlo además en el
keystore del propio Webhook Messenger.
## 🟠 Estado del bug de fechas
El Webhook Messenger construye el SQL contra `OEVN` con la fecha como
texto plano. Con el login `sa` en idioma `Español` (`DATEFORMAT dmy`),
cualquier día del mes mayor a 12 rompía la consulta.
Workaround confirmado en este ambiente de desarrollo: cambiar `sa` a
`DEFAULT_LANGUAGE = us_english` y reiniciar el servicio. Sigue pendiente
confirmar si `FP2605 HFX1` lo corrige oficialmente.
---
Detalle completo del diagnóstico, la evidencia y los pasos de instalación
en `CLAUDE.md` y `PASOS-REALIZADOS.md` de este repositorio.
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
# Cómo ejecutar la API (con y sin HMAC)
Guía rápida para levantar `sap-webhook-api` en los dos modos soportados:
sin autenticación (`AuthenticationType: None`, el modo actual de la
suscripción real en SAP) y con firma HMAC.
## 0. Una sola vez: preparar el entorno
```bash
cd sap-webhook-api
npm install
cp .env.example .env # si todavía no existe .env
```
Revisá que `.env` tenga los datos correctos de tu Service Layer
(`SERVICE_LAYER_URL`, `COMPANY_DB`, `SL_USER`, `SL_PASS`, `WEBHOOK_ID`). El
certificado HTTPS (`certs/key.pem` / `certs/cert.pem`) ya debería existir —
si no, ver `PASOS-REALIZADOS.md`, sección 9.
**Importante**: el modo con el que arranca la API (`.env` →
`HMAC_SECRET_KEY`) tiene que coincidir con el `AuthenticationType`
configurado en la suscripción real de SAP. Si no coinciden, SAP manda
notificaciones que la API rechaza (o al revés, la API acepta sin validar
nada aunque SAP crea que está firmando). Los scripts de la sección 2 se
encargan de mantenerlos sincronizados.
## 1. Modo sin autenticación (`AuthenticationType: None`)
Es el modo por defecto — no requiere ningún paso extra.
```bash
# confirmá que HMAC_SECRET_KEY esté vacío en .env
npm run start
```
La API queda escuchando en `https://<tu-ip>:3000/webhooks/sap`, acepta
cualquier notificación sin validar firma. Prueba local:
```bash
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" \
-d '[{"id":"test-1","type":"sap.b1.Orders.Created.v1","subject":"1","data":{"DocEntry":"1"}}]'
# -> 200 OK
```
## 2. Modo con HMAC (`AuthenticationType: HMAC`)
### 2.1 Activar
```bash
npm run auth:hmac:on
```
Esto:
1. Genera un `HMAC_SECRET_KEY` (si `.env` no tenía uno) y lo guarda en `.env`.
2. Hace `Login` en el Service Layer, lee la configuración actual de la
suscripción (`WEBHOOK_ID` de `.env`), la **pausa**, la **elimina** y la
**vuelve a crear** con `AuthenticationType: "HMAC"` — el Service Layer
no permite cambiar `AuthenticationType` con `PATCH` sobre una
suscripción existente, así que este es el único camino soportado
(se conserva `WebhookURL`, `Handshake` y `EventCollection` originales).
`AuthenticationCred` no es el secreto pelado: va como un string con
JSON embebido, `{"Key": "<secreto>", "Algorithm": "hmacsha256"}`.
Después, **reiniciá la API** para que tome el `HMAC_SECRET_KEY` nuevo:
```bash
npm run start
```
A partir de este momento, todo `POST /webhooks/sap` sin la firma correcta
en el header `X-B1-Webhook-Signature` (HMAC-SHA256 del body, en base64)
devuelve `401 Unauthorized`.
### 2.2 Probar manualmente (simulando lo que hace SAP)
```bash
SECRET=$(grep '^HMAC_SECRET_KEY=' .env | cut -d= -f2)
BODY='[{"id":"test-2","type":"sap.b1.Orders.Created.v1","subject":"2","data":{"DocEntry":"2"}}]'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64)
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" \
-H "X-B1-Webhook-Signature: $SIG" \
-d "$BODY"
# -> 200 OK
# sin firma o con una firma incorrecta -> 401
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" -d "$BODY"
```
### 2.3 Desactivar (volver a `None`)
```bash
npm run auth:hmac:off
npm run start # reiniciar para que la API deje de exigir la firma
```
Esto revierte la suscripción en SAP a `AuthenticationType: "None"` y vacía
`HMAC_SECRET_KEY` en `.env`, en un solo paso.
## Referencia rápida
| Acción | Comando |
|---|---|
| Instalar dependencias | `npm install` |
| Levantar la API | `npm run start` |
| Activar HMAC (API + SAP) | `npm run auth:hmac:on` |
| Desactivar HMAC (API + SAP) | `npm run auth:hmac:off` |
| Ver estado actual de la suscripción | `curl -sk -b cookies.txt "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"` (después de un `Login`) |
Detalle de la implementación (cómo se valida la firma, por qué se usa el
body crudo, etc.) en `CLAUDE.md`.
+291
View File
@@ -0,0 +1,291 @@
# Pasos realizados — API de Webhooks SAP Business One
Documento de los pasos ejecutados para levantar la API NestJS que escucha el
Event Notification del Webhook de SAP Business One Service Layer, desde el
proyecto vacío hasta tener la suscripción activa y lista para recibir
notificaciones reales. No incluye las pruebas contra SAP real (creación de
órdenes de venta) ni la investigación de errores posteriores — eso queda
documentado por separado.
## Contexto / objetivo
- API sencilla en NestJS que escucha el Webhook Messenger de SAP Business One
(Service Layer) y guarda localmente cada Event Notification recibido.
- SAP Business One corre en `sap10` (Service Layer, puerto `50000`) y en
`192.168.1.205` (Webhook Messenger, puerto `40008`), en la misma LAN que la
máquina de desarrollo (Mac, IP `192.168.1.166`).
- Compañía de pruebas: `SBODemoCL`, usuario `manager`.
## 1. Crear el proyecto NestJS
```bash
npx --yes @nestjs/cli new sap-webhook-api --package-manager npm --skip-git
```
Genera el scaffold estándar de Nest (TypeScript, npm) en `sap-webhook-api/`.
## 2. Crear el módulo de webhooks
```bash
cd sap-webhook-api
npx nest generate module webhooks --no-spec
npx nest generate controller webhooks --no-spec
```
Esto registra automáticamente `WebhooksModule` en `AppModule` y crea
`src/webhooks/webhooks.controller.ts`.
## 3. Endpoint mínimo que recibe el webhook
`src/webhooks/webhooks.controller.ts`:
```ts
import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
const EVENTS_DIR = join(process.cwd(), 'data', 'events');
interface SapEventNotification {
EventID?: string;
SourceDB?: string;
BusinessObject?: string;
Operation?: string;
Status?: string;
ReplayState?: string;
FieldNames?: string;
FieldValues?: string;
CreateDate?: string;
CreateTime?: string;
}
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
@Post('sap')
@HttpCode(200)
receiveSapEvent(@Body() body: SapEventNotification | SapEventNotification[]) {
const notifications = Array.isArray(body) ? body : [body];
this.logger.log(`Recibidas ${notifications.length} notificación(es) de SAP`);
mkdirSync(EVENTS_DIR, { recursive: true });
notifications.forEach((notification, index) => {
const id = notification?.EventID ?? `${Date.now()}-${index}`;
const fileName = `${id}.json`;
writeFileSync(join(EVENTS_DIR, fileName), JSON.stringify(notification, null, 2));
this.logger.log(`Evento guardado: ${fileName}`);
});
return { received: true, count: notifications.length };
}
}
```
SAP puede enviar varias notificaciones "bateadas" en un solo `POST` (según
`MessageBatchLimit`), como confirma el sample oficial de SAP
(`webhook-service-sample-nodejs`). Por eso el body se normaliza siempre a un
array, y se guarda **un archivo por notificación**, nombrado por su
`EventID` cuando está disponible (o timestamp + índice como respaldo).
> Nota: esta versión reemplaza a una anterior más simple que guardaba el
> `body` completo en un solo archivo por request — ver historial de cambios
> del proyecto si se necesita esa versión.
## 4. Primera prueba local (HTTP)
```bash
npm run start
curl -s -i -X POST http://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" -d '{"test":"hola"}'
```
Resultado: `200 OK`, `{"received":true}`, y log
`Evento recibido de SAP: {"test":"hola"}`.
## 5. Verificar conectividad de red en la LAN
- IP de la Mac en la LAN: `192.168.1.166` (`ipconfig getifaddr en0`).
- Confirmar que Node escucha en todas las interfaces, no solo localhost:
```bash
lsof -nP -iTCP:3000 -sTCP:LISTEN
# node ... TCP *:3000 (LISTEN)
```
- Desde el servidor SAP (Windows), verificar que puede llegar a la Mac en el
puerto `3000` (macOS no trae `telnet`, así que se usó `Test-NetConnection`):
```powershell
Test-NetConnection -ComputerName 192.168.1.166 -Port 3000
# TcpTestSucceeded : True
```
- Desde la Mac, verificar conectividad hacia el Webhook Messenger de SAP
(puerto `40008`) y hacia el Service Layer (puerto `50000`), con `nc`
(netcat, viene instalado en macOS):
```bash
nc -zv 192.168.1.205 40008 # Connection succeeded
nc -zv sap10 50000 # Connection succeeded
```
Con esto queda confirmada la conectividad en ambos sentidos.
## 6. Login en el Service Layer y estado del webhook
```bash
curl -sk -i -c cookies.txt -X POST https://sap10:50000/b1s/v2/Login \
-H "Content-Type: application/json" \
-d '{"CompanyDB":"SBODemoCL","UserName":"manager","Password":"mngr"}'
```
```bash
curl -sk -b cookies.txt https://sap10:50000/b1s/v2/CompanyService_GetAdminInfo
```
El campo `EnableWebhook` ya estaba en `"tYES"` en esta compañía, así que no
hizo falta llamar a `CompanyService_UpdateAdminInfo`.
## 7. Confirmar el evento en el catálogo
```bash
curl -sk -b cookies.txt \
"https://sap10:50000/b1s/v2/EventSubscriptionsService_GetEventCatalog"
```
Se confirmó la entrada:
```json
{"BusinessObject": "Orders", "ObjectType": "17", "TransactionTypes": "Created,Updated,Deleted,Closed,Cancelled,Reopened"}
```
## 8. Primer intento de suscripción (falló — HTTP no permitido)
```bash
curl -sk -i -b cookies.txt -X POST https://sap10:50000/b1s/v2/EventSubscriptions \
-H "Content-Type: application/json" -d '{
"WebhookID": "SapWebhookApiTest",
"WebhookURL": "http://192.168.1.166:3000/webhooks/sap",
"AuthenticationType": "None",
"Handshake": "tNO",
"EventCollection": [{"BusinessObject": "Orders", "TransactionType": "Created"}]
}'
```
Resultado: `400 Bad Request`, `-21100 Invalid webhook URL` — SAP exige que
`WebhookURL` sea HTTPS.
## 9. Certificado autofirmado y API en HTTPS
```bash
cd sap-webhook-api
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem \
-days 825 -nodes -subj "/CN=192.168.1.166" \
-addext "subjectAltName=IP:192.168.1.166"
```
`src/main.ts`:
```ts
import { readFileSync } from 'fs';
import { join } from 'path';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const httpsOptions = {
key: readFileSync(join(__dirname, '..', 'certs', 'key.pem')),
cert: readFileSync(join(__dirname, '..', 'certs', 'cert.pem')),
};
const app = await NestFactory.create(AppModule, { httpsOptions });
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
```
Prueba local tras el cambio:
```bash
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
-H "Content-Type: application/json" -d '{"test":"https-ok"}'
ls -la data/events/
```
Resultado: `200 OK` y el archivo del evento guardado correctamente en
`data/events/`.
## 10. Crear la suscripción con HTTPS (éxito)
```bash
curl -sk -i -b cookies.txt -X POST https://sap10:50000/b1s/v2/EventSubscriptions \
-H "Content-Type: application/json" -d '{
"WebhookID": "SapWebhookApiTest",
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
"AuthenticationType": "None",
"Handshake": "tNO",
"EventCollection": [{"BusinessObject": "Orders", "TransactionType": "Created"}]
}'
```
Resultado: `201 Created`, `State: "Active"` — pero con `VerifyCertificate:
"tYES"` (SAP intentaría validar nuestro certificado autofirmado y lo
rechazaría).
## 11. Desactivar la verificación de certificado
```bash
curl -sk -i -b cookies.txt -X PATCH \
"https://sap10:50000/b1s/v2/EventSubscriptions('SapWebhookApiTest')" \
-H "Content-Type: application/json" -d '{"VerifyCertificate": "tNO"}'
```
Resultado: `204 No Content`. Verificado con un `GET` posterior:
`VerifyCertificate: "tNO"`, `State: "Active"`.
## Estado al cierre de esta etapa
| Ítem | Estado |
|---|---|
| Proyecto NestJS (`sap-webhook-api`) | ✅ Creado |
| Endpoint `POST /webhooks/sap` | ✅ Responde 200 y guarda cada evento en `data/events/` |
| Conectividad LAN (Mac ↔ SAP, ambos sentidos) | ✅ Confirmada |
| Webhook habilitado en la compañía SAP | ✅ (ya estaba `tYES`) |
| Certificado HTTPS autofirmado | ✅ Generado y en uso |
| Suscripción `SapWebhookApiTest` (`Orders`/`Created`) | ✅ `Active`, `VerifyCertificate: tNO` |
A partir de aquí comienzan las pruebas con órdenes de venta reales en SAP, que
llevaron a identificar un bug en el Webhook Messenger de SAP (no cubierto en
este documento).
## Anexo: tablas internas de SAP para diagnóstico
Para diagnosticar problemas de entrega del webhook (suscripción activa pero
sin eventos llegando, errores de envío, etc.), conviene revisar directamente
estas tablas en la base de la compañía (ej. `SBODemoCL`), vía SSMS, en modo
**solo lectura**:
```sql
-- Suscripciones de webhook registradas (una fila por WebhookID: URL, estado,
-- fecha/hora de creación, último envío, último error)
SELECT * FROM OEVC
-- Cola de eventos de negocio generados (una fila por transacción que matchea
-- algún BusinessObject/TransactionType suscripto: EventID, CreateDate,
-- CreateTime, Status, BizObject, TransType). Status = 'N' significa
-- pendiente de enviar.
SELECT * FROM OEVN
-- Estado de envío de cada evento hacia cada webhook suscripto (relaciona
-- EventID + WebhookID con su resultado: Status, Retry, ReturnCode,
-- LastSentDT, LastSentTM)
SELECT * FROM EVN1
```
Estas tablas son internas del motor de SAP Business One — **no están
pensadas para escritura directa vía SQL** (cualquier cambio debería hacerse
a través de Service Layer / DI API). Sirven únicamente para inspeccionar el
estado real de la cola de eventos y de las suscripciones cuando el
comportamiento reportado por el Service Layer (`GET /EventSubscriptions`) no
alcanza para explicar por qué no llegan las notificaciones.
Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+10354
View File
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
{
"name": "sap-webhook-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"auth:hmac:on": "bash scripts/enable-hmac-auth.sh",
"auth:hmac:off": "bash scripts/disable-hmac-auth.sh"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"dotenv": "^17.4.2",
"pg": "^8.23.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.23.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Revierte la suscripción de SAP a AuthenticationType: None, y vacía
# HMAC_SECRET_KEY en .env para que la API deje de exigir la firma.
#
# El Service Layer no permite cambiar AuthenticationType con PATCH sobre
# una suscripción existente -> hay que Pause + Delete + volver a crearla
# (POST), reusando su WebhookURL/Handshake/EventCollection actuales.
set -euo pipefail
cd "$(dirname "$0")/.."
ENV_FILE=".env"
[ -f "$ENV_FILE" ] || { echo "No existe $ENV_FILE (copiá .env.example primero)"; exit 1; }
set -a
source "$ENV_FILE"
set +a
: "${SERVICE_LAYER_URL:?Falta SERVICE_LAYER_URL en .env}"
: "${COMPANY_DB:?Falta COMPANY_DB en .env}"
: "${SL_USER:?Falta SL_USER en .env}"
: "${SL_PASS:?Falta SL_PASS en .env}"
: "${WEBHOOK_ID:?Falta WEBHOOK_ID en .env}"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
echo "Login en Service Layer ($SERVICE_LAYER_URL, compañía $COMPANY_DB)..."
curl -sk -c "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X POST "$SERVICE_LAYER_URL/Login" \
-H "Content-Type: application/json" \
-d "{\"CompanyDB\":\"$COMPANY_DB\",\"UserName\":\"$SL_USER\",\"Password\":\"$SL_PASS\"}"
echo "Leyendo configuración actual de '$WEBHOOK_ID'..."
CURRENT=$(curl -sk -b "$COOKIE_JAR" "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')")
NEW_BODY=$(echo "$CURRENT" | python3 -c "
import json, sys
current = json.load(sys.stdin)
body = {
'WebhookID': current['WebhookID'],
'WebhookURL': current['WebhookURL'],
'AuthenticationType': 'None',
'Handshake': current.get('Handshake', 'tNO'),
'VerifyCertificate': current.get('VerifyCertificate', 'tNO'),
'EventCollection': [
{'BusinessObject': e['BusinessObject'], 'TransactionType': e['TransactionType']}
for e in current['EventCollection']
],
}
print(json.dumps(body))
")
echo "Pausando suscripción '$WEBHOOK_ID'..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X POST "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')/Pause"
echo "Eliminando suscripción '$WEBHOOK_ID'..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X DELETE "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"
echo "Recreando '$WEBHOOK_ID' con AuthenticationType: None..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X POST "$SERVICE_LAYER_URL/EventSubscriptions" \
-H "Content-Type: application/json" \
-d "$NEW_BODY"
# El POST de creación ignora el VerifyCertificate que mandamos y siempre
# arranca en tYES -> hace falta un PATCH aparte para volver a ponerlo en tNO.
echo "Ajustando VerifyCertificate: tNO..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X PATCH "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')" \
-H "Content-Type: application/json" \
-d '{"VerifyCertificate": "tNO"}'
if grep -q '^HMAC_SECRET_KEY=' "$ENV_FILE"; then
sed -i.bak "s|^HMAC_SECRET_KEY=.*|HMAC_SECRET_KEY=|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
echo
echo "HMAC_SECRET_KEY vaciado en $ENV_FILE"
fi
echo "Listo. Reiniciá la API (npm run start) para que deje de exigir la firma."
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Activa AuthenticationType: HMAC en la suscripción de SAP, sincronizado
# con HMAC_SECRET_KEY de .env (lo genera si todavía no existe).
#
# El Service Layer no permite cambiar AuthenticationType con PATCH sobre
# una suscripción existente -> hay que Pause + Delete + volver a crearla
# (POST), reusando su WebhookURL/Handshake/EventCollection actuales.
set -euo pipefail
cd "$(dirname "$0")/.."
ENV_FILE=".env"
[ -f "$ENV_FILE" ] || { echo "No existe $ENV_FILE (copiá .env.example primero)"; exit 1; }
set -a
source "$ENV_FILE"
set +a
if [ -z "${HMAC_SECRET_KEY:-}" ]; then
HMAC_SECRET_KEY="$(openssl rand -base64 32)"
if grep -q '^HMAC_SECRET_KEY=' "$ENV_FILE"; then
sed -i.bak "s|^HMAC_SECRET_KEY=.*|HMAC_SECRET_KEY=$HMAC_SECRET_KEY|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
echo "HMAC_SECRET_KEY=$HMAC_SECRET_KEY" >> "$ENV_FILE"
fi
echo "Se generó un HMAC_SECRET_KEY nuevo y se guardó en $ENV_FILE"
fi
: "${SERVICE_LAYER_URL:?Falta SERVICE_LAYER_URL en .env}"
: "${COMPANY_DB:?Falta COMPANY_DB en .env}"
: "${SL_USER:?Falta SL_USER en .env}"
: "${SL_PASS:?Falta SL_PASS en .env}"
: "${WEBHOOK_ID:?Falta WEBHOOK_ID en .env}"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
echo "Login en Service Layer ($SERVICE_LAYER_URL, compañía $COMPANY_DB)..."
curl -sk -c "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X POST "$SERVICE_LAYER_URL/Login" \
-H "Content-Type: application/json" \
-d "{\"CompanyDB\":\"$COMPANY_DB\",\"UserName\":\"$SL_USER\",\"Password\":\"$SL_PASS\"}"
echo "Leyendo configuración actual de '$WEBHOOK_ID'..."
CURRENT=$(curl -sk -b "$COOKIE_JAR" "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')")
NEW_BODY=$(echo "$CURRENT" | HMAC_KEY="$HMAC_SECRET_KEY" python3 -c "
import json, os, sys
current = json.load(sys.stdin)
cred = json.dumps({'Key': os.environ['HMAC_KEY'], 'Algorithm': 'hmacsha256'})
body = {
'WebhookID': current['WebhookID'],
'WebhookURL': current['WebhookURL'],
'AuthenticationType': 'HMAC',
'AuthenticationCred': cred,
'Handshake': current.get('Handshake', 'tNO'),
'VerifyCertificate': current.get('VerifyCertificate', 'tNO'),
'EventCollection': [
{'BusinessObject': e['BusinessObject'], 'TransactionType': e['TransactionType']}
for e in current['EventCollection']
],
}
print(json.dumps(body))
")
echo "Pausando suscripción '$WEBHOOK_ID'..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X POST "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')/Pause"
echo "Eliminando suscripción '$WEBHOOK_ID'..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X DELETE "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"
echo "Recreando '$WEBHOOK_ID' con AuthenticationType: HMAC..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X POST "$SERVICE_LAYER_URL/EventSubscriptions" \
-H "Content-Type: application/json" \
-d "$NEW_BODY"
# El POST de creación ignora el VerifyCertificate que mandamos y siempre
# arranca en tYES -> hace falta un PATCH aparte para volver a ponerlo en
# tNO (si no, SAP intenta validar nuestro cert autofirmado y falla con
# "SSL Certificate Untrusted", dejando la suscripción Inactive).
echo "Ajustando VerifyCertificate: tNO..."
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
-X PATCH "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')" \
-H "Content-Type: application/json" \
-d '{"VerifyCertificate": "tNO"}'
echo
echo "Listo. Reiniciá la API (npm run start) para que tome el HMAC_SECRET_KEY de .env."
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WebhooksModule } from './webhooks/webhooks.module';
@Module({
imports: [WebhooksModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'dotenv/config';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { checkDatabaseConnection } from './webhooks/webhook-events.repository';
async function bootstrap() {
const logger = new Logger('Diagnostics');
await checkDatabaseConnection();
const httpsOptions = {
key: readFileSync(join(__dirname, '..', 'certs', 'key.pem')),
cert: readFileSync(join(__dirname, '..', 'certs', 'cert.pem')),
};
const app = await NestFactory.create(AppModule, { httpsOptions, rawBody: true });
const httpServer = app.getHttpServer();
httpServer.on('connection', (socket) => {
logger.log(`TCP connection recibida desde ${socket.remoteAddress}:${socket.remotePort}`);
});
httpServer.on('tlsClientError', (err, socket) => {
logger.error(`TLS handshake fallido desde ${socket.remoteAddress}: ${err.message}`);
});
httpServer.on('secureConnection', (tlsSocket) => {
logger.log(`TLS handshake OK desde ${tlsSocket.remoteAddress}`);
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
+62
View File
@@ -0,0 +1,62 @@
import { Logger } from '@nestjs/common';
import { Pool } from 'pg';
const logger = new Logger('WebhookEventsRepository');
const pool = process.env.DATABASE_URL
? new Pool({ connectionString: process.env.DATABASE_URL })
: null;
// Extrae BusinessObject y Operation del "type" del CloudEvent, ej.
// "sap.b1.Orders.Created.v1" -> { businessObject: "Orders", operation: "Created" }
function parseType(type: string | undefined): { businessObject: string; operation: string } {
const match = type?.match(/^sap\.b1\.([^.]+)\.([^.]+)\.v\d+$/);
return {
businessObject: match?.[1] ?? 'Unknown',
operation: match?.[2] ?? 'Unknown',
};
}
// Valida al arrancar si Postgres responde, para avisar temprano en el log en
// vez de que el primer indicio sea un ECONNREFUSED al llegar el primer evento.
// No bloquea el arranque: la API sigue funcionando sin Postgres (ver abajo).
export async function checkDatabaseConnection(): Promise<void> {
if (!pool) {
logger.warn('DATABASE_URL no configurada: los eventos no se guardarán en Postgres');
return;
}
try {
await pool.query('SELECT 1');
logger.log('Conexión a Postgres OK');
} catch (err) {
logger.warn(
`No se pudo conectar a Postgres al arrancar (¿está levantada?): ${(err as Error).message}`,
);
}
}
// Inserta el evento en la bandeja de entrada compartida (webhook_events) para
// que sap-intercompany-worker lo pueda procesar. Si no hay DATABASE_URL
// configurada, no hace nada (la API sigue funcionando igual sin Postgres).
export async function saveWebhookEventToInbox(notification: {
id?: string;
type?: string;
subject?: string;
data?: unknown;
}): Promise<void> {
if (!pool || !notification.id) return;
const { businessObject, operation } = parseType(notification.type);
try {
await pool.query(
`INSERT INTO webhook_events (id, business_object, operation, subject, payload, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
ON CONFLICT (id) DO NOTHING`,
[notification.id, businessObject, operation, notification.subject ?? '', JSON.stringify(notification)],
);
} catch (err) {
logger.error(`No se pudo guardar el evento ${notification.id} en Postgres`, err as Error);
}
}
+134
View File
@@ -0,0 +1,134 @@
import {
Body,
Controller,
Get,
Head,
Headers,
HttpCode,
Logger,
NotFoundException,
Param,
Post,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'crypto';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import type { Request } from 'express';
import { saveWebhookEventToInbox } from './webhook-events.repository';
const EVENTS_DIR = join(process.cwd(), 'data', 'events');
const HMAC_SECRET_KEY = process.env.HMAC_SECRET_KEY;
interface SapEventNotification {
id?: string;
specversion?: string;
source?: string;
type?: string;
subject?: string;
time?: string;
datacontenttype?: string;
data?: Record<string, unknown>;
}
@Controller('webhooks')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
@Get('sap')
@HttpCode(200)
validateSapWebhook(@Headers('x-b1-webhook-token') token?: string) {
// SAP valida que el endpoint esté vivo con un GET antes de despachar
// eventos (y lo usa también para el handshake, si Handshake: tYES).
this.logger.log(`GET de validación/handshake recibido de SAP${token ? ` (token=${token})` : ''}`);
return token ? { Challenge: token } : { status: 'ok' };
}
@Head('sap')
@HttpCode(200)
checkSapWebhookAlive() {
// SAP también puede mandar HEAD solo para chequear que el endpoint esté vivo.
this.logger.log('HEAD de chequeo de vida recibido de SAP');
}
@Get('sap/events')
listStoredEvents() {
if (!existsSync(EVENTS_DIR)) return [];
return readdirSync(EVENTS_DIR)
.filter((file) => file.endsWith('.json'))
.map((file) => {
const notification = JSON.parse(
readFileSync(join(EVENTS_DIR, file), 'utf-8'),
) as SapEventNotification;
return {
id: notification.id ?? file.replace(/\.json$/, ''),
type: notification.type,
subject: notification.subject,
time: notification.time,
};
})
.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? ''));
}
@Get('sap/events/:id')
getStoredEvent(@Param('id') id: string) {
const filePath = join(EVENTS_DIR, `${id}.json`);
if (!filePath.startsWith(EVENTS_DIR) || !existsSync(filePath)) {
throw new NotFoundException(`Evento ${id} no encontrado`);
}
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
@Post('sap')
@HttpCode(200)
async receiveSapEvent(
@Req() req: Request,
@Body() body: SapEventNotification | SapEventNotification[],
@Headers('x-b1-webhook-signature') signature?: string,
) {
if (HMAC_SECRET_KEY) {
this.verifyHmacSignature(req, signature);
}
const notifications = Array.isArray(body) ? body : [body];
this.logger.log(`Recibidas ${notifications.length} notificación(es) de SAP`);
mkdirSync(EVENTS_DIR, { recursive: true });
for (const [index, notification] of notifications.entries()) {
const id = notification?.id ?? `${Date.now()}-${index}`;
const fileName = `${id}.json`;
writeFileSync(join(EVENTS_DIR, fileName), JSON.stringify(notification, null, 2));
await saveWebhookEventToInbox(notification);
this.logger.log(`Evento guardado: ${fileName}`);
}
return { received: true, count: notifications.length };
}
private verifyHmacSignature(req: Request, signature?: string) {
if (!signature) {
throw new UnauthorizedException('Falta la firma HMAC (X-B1-Webhook-Signature)');
}
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
if (!rawBody) {
throw new UnauthorizedException('No se pudo leer el body crudo para validar la firma');
}
const expectedSignature = createHmac('sha256', HMAC_SECRET_KEY as string)
.update(rawBody)
.digest('base64');
const received = Buffer.from(signature);
const expected = Buffer.from(expectedSignature);
const isValid =
received.length === expected.length && timingSafeEqual(received, expected);
if (!isValid) {
this.logger.warn('Firma HMAC inválida recibida en /webhooks/sap');
throw new UnauthorizedException('Firma HMAC inválida');
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
@Module({
controllers: [WebhooksController],
})
export class WebhooksModule {}
+29
View File
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+199
View File
@@ -0,0 +1,199 @@
@url=https://sap10:50000/b1s/v2
###
# @no-reject-unauthorized
POST {{url}}/Login
Content-Type : application/json
{
"CompanyDB":"SBODemoCL",
"UserName": "manager",
"Password":"mngr"
}
###
# @no-reject-unauthorized
GET {{url}}/CompanyService_GetAdminInfo
###
# @no-reject-unauthorized
POST {{url}}/CompanyService_UpdateAdminInfo
Content-Type : application/json
{
"AdminInfo":{
"EnableWebhook": "tYES"
}
}
###
# @no-reject-unauthorized
GET {{url}}/EventSubscriptionsService_GetEventCatalog
###
# @no-reject-unauthorized
POST {{url}}/EventSubscriptions
Content-Type : application/json
{
"WebhookID": "SapWebhookApiTest",
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
"AuthenticationType": "None",
"Handshake": "tNO",
"EventCollection": [
{
"BusinessObject": "Orders",
"TransactionType": "Created"
}
]
}
###
# @no-reject-unauthorized
GET {{url}}/EventSubscriptions
###
# ⚠ El Service Layer NO permite cambiar AuthenticationType con PATCH sobre
# una suscripción existente — hay que Pause + Delete (arriba/abajo) y
# volver a crearla con este POST. AuthenticationCred para HMAC no es el
# secreto pelado: es un string con JSON embebido {"Key": "...", "Algorithm": "hmacsha256"}.
# Con esta config, SAP firma cada POST con HMAC-SHA256 usando "Key" y lo
# manda en el header X-B1-Webhook-Signature.
# @no-reject-unauthorized
POST {{url}}/EventSubscriptions
Content-Type : application/json
{
"WebhookID": "SapWebhookApiTest",
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
"VerifyCertificate": "tNO",
"AuthenticationType": "HMAC",
"AuthenticationCred": "{\"Key\": \"6Umq/cD6YQ4S8LKDOT33oDB6WifYXQFRQ4oRbIGzH0c=\", \"Algorithm\": \"hmacsha256\"}",
"Handshake": "tNO",
"EventCollection": [
{
"BusinessObject": "Orders",
"TransactionType": "Created"
}
]
}
###
# @no-reject-unauthorized
DELETE {{url}}/EventSubscriptions('SapWebhookApiTest')
###
# To remove an event (such as Invoices.Updated) from the subscription,
# send a PATCH request with the updated EventCollection that excludes
# the event you want to remove. Include the special header
# B1S-ReplaceCollectionsOnPatch to indicate a full replacement of the
# collection.
# @no-reject-unauthorized
PATCH {{url}}/EventSubscriptions('SapWebhookApiTest')
B1S-ReplaceCollectionsOnPatch: true
Content-Type : application/json
{
"EventCollection": [
{
"BusinessObject": "BusinessPartners",
"TransactionType": "Created"
},
{
"BusinessObject": "BusinessPartners",
"TransactionType": "Updated"
},
{
"BusinessObject": "BusinessPartners",
"TransactionType": "Deleted"
},
{
"BusinessObject": "Orders",
"TransactionType": "Created"
},
{
"BusinessObject": "ProfitCenters",
"TransactionType": "Created"
},
{
"BusinessObject": "ProfitCenters",
"TransactionType": "Updated"
},
{
"BusinessObject": "ProfitCenters",
"TransactionType": "Deleted"
},
{
"BusinessObject": "SalesPersons",
"TransactionType": "Created"
},
{
"BusinessObject": "SalesPersons",
"TransactionType": "Updated"
},
{
"BusinessObject": "SalesPersons",
"TransactionType": "Deleted"
},
{
"BusinessObject": "ChartOfAccounts",
"TransactionType": "Created"
},
{
"BusinessObject": "ChartOfAccounts",
"TransactionType": "Updated"
},
{
"BusinessObject": "ChartOfAccounts",
"TransactionType": "Deleted"
},
{
"BusinessObject": "Items",
"TransactionType": "Created"
},
{
"BusinessObject": "Items",
"TransactionType": "Updated"
},
{
"BusinessObject": "Items",
"TransactionType": "Deleted"
},
{
"BusinessObject": "JournalEntries",
"TransactionType": "Created"
},
{
"BusinessObject": "JournalEntries",
"TransactionType": "Updated"
},
{
"BusinessObject": "JournalEntries",
"TransactionType": "Cancelled"
}
]
}
###
# If a webhook subscription is deactivated due to errors, such as
# repeated delivery failures, you can resume it by sending a POST request
# to the EventSubscriptions('{WebhookID}')/Resume endpoint.
# @no-reject-unauthorized
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Resume
###
# To pause an active event subscription, send a POST request to the EventSubscriptions('{WebhookID}')/Pause endpoint.
# @no-reject-unauthorized
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Pause
###
# If a webhook subscription requires handshake verification, you can manually initiate the handshake process by sending a POST request to the EventSubscriptions('{WebhookID}')/Handshake endpoint.
# @no-reject-unauthorized
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Handshake
###
# To replay missed event notifications for a specific webhook subscription, send a POST request to the EventSubscriptions('{WebhookID}')/Replay endpoint:
# @no-reject-unauthorized
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Replay
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}