Ejemplos
Implementaciones mínimas de referencia en Node.js, Cloudflare Workers, Python y cURL, copiadas del paquete público.
Estos ejemplos muestran el contrato mínimo. Antes de producción sustituye la deduplicación en memoria por almacenamiento persistente, usa un gestor de secretos, añade logs sanitizados, métricas, alertas y una cola para procesos que puedan superar 10–12 segundos.
Variables comunes
NAHUI_CONNECTION_ID=ncc_...
NAHUI_API_KEY=nhc_...
NAHUI_WEBHOOK_SECRET=nhs_...
NAHUI_API_BASE=https://api.nahui.studioNode.js · Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const port = Number(process.env.PORT || 3000);
const apiBase = process.env.NAHUI_API_BASE || 'https://api.nahui.studio';
const connectionId = process.env.NAHUI_CONNECTION_ID || '';
const apiKey = process.env.NAHUI_API_KEY || '';
const webhookSecret = process.env.NAHUI_WEBHOOK_SECRET || '';
if (!connectionId || !apiKey || !webhookSecret) {
throw new Error('Configura NAHUI_CONNECTION_ID, NAHUI_API_KEY y NAHUI_WEBHOOK_SECRET.');
}
function expectedSignature(rawBody) {
return `sha256=${crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('base64url')}`;
}
function sameSignature(received, expected) {
const left = Buffer.from(String(received || ''));
const right = Buffer.from(expected);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
// Demostración únicamente. Usa una tabla con deliveryId UNIQUE en producción.
const processedDeliveries = new Set();
// Registra esta ruta antes de cualquier express.json() global.
app.post('/webhooks/nahui', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const rawBody = req.body;
const receivedSignature = req.get('x-nahui-signature-256');
if (!Buffer.isBuffer(rawBody) || !sameSignature(receivedSignature, expectedSignature(rawBody))) {
return res.status(401).json({ error: 'Firma inválida' });
}
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).json({ error: 'JSON inválido' });
}
if (event.type !== 'message.received' || !event.deliveryId) {
return res.status(400).json({ error: 'Evento no soportado' });
}
if (processedDeliveries.has(event.deliveryId)) {
return res.status(200).json({});
}
processedDeliveries.add(event.deliveryId);
// Guarda aquí el evento en la base de datos de tu CRM.
if (event.message?.type === 'text' && event.message.text) {
return res.status(200).json({
reply: {
text: `Recibí: ${event.message.text}`.slice(0, 4000)
}
});
}
return res.status(200).json({});
});
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_req, res) => res.json({ ok: true }));
export async function sendReply({ conversationId, text, idempotencyKey = crypto.randomUUID() }) {
const response = await fetch(`${apiBase}/v1/connect/relay/${encodeURIComponent(connectionId)}/reply`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify({
conversationId,
message: { text: String(text).slice(0, 4000) }
}),
signal: AbortSignal.timeout(20_000)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(result.error || `Nahui respondió HTTP ${response.status}`);
error.status = response.status;
error.code = result.code;
throw error;
}
return result;
}
export async function sendTemplate({
conversationId,
templateName,
language = 'es_MX',
components = [],
idempotencyKey = crypto.randomUUID()
}) {
const response = await fetch(`${apiBase}/v1/connect/relay/${encodeURIComponent(connectionId)}/template`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify({ conversationId, templateName, language, components }),
signal: AbortSignal.timeout(20_000)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(result.error || `Nahui respondió HTTP ${response.status}`);
error.status = response.status;
error.code = result.code;
throw error;
}
return result;
}
app.listen(port, () => {
console.log(`Webhook listo en http://localhost:${port}/webhooks/nahui`);
});
Cloudflare Workers
const encoder = new TextEncoder();
function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function constantTimeEqual(left, right) {
const a = encoder.encode(String(left || ''));
const b = encoder.encode(String(right || ''));
if (a.length !== b.length) return false;
let difference = 0;
for (let index = 0; index < a.length; index += 1) difference |= a[index] ^ b[index];
return difference === 0;
}
async function expectedSignature(secret, rawBody) {
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(rawBody));
return `sha256=${bytesToBase64Url(new Uint8Array(signature))}`;
}
async function sendReply(env, { conversationId, text, idempotencyKey = crypto.randomUUID() }) {
const response = await fetch(
`${env.NAHUI_API_BASE || 'https://api.nahui.studio'}/v1/connect/relay/${encodeURIComponent(env.NAHUI_CONNECTION_ID)}/reply`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.NAHUI_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify({
conversationId,
message: { text: String(text).slice(0, 4000) }
})
}
);
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || `Nahui respondió HTTP ${response.status}`);
return result;
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === 'GET' && url.pathname === '/health') {
return Response.json({ ok: true });
}
if (request.method !== 'POST' || url.pathname !== '/webhooks/nahui') {
return new Response('Not found', { status: 404 });
}
const rawBody = await request.text();
const received = request.headers.get('x-nahui-signature-256') || '';
const expected = await expectedSignature(env.NAHUI_WEBHOOK_SECRET, rawBody);
if (!constantTimeEqual(received, expected)) {
return Response.json({ error: 'Firma inválida' }, { status: 401 });
}
let event;
try {
event = JSON.parse(rawBody);
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
// WEBHOOK_EVENTS es un binding KV. En sistemas críticos usa D1 o una base
// transaccional con índice único para una deduplicación fuerte.
if (await env.WEBHOOK_EVENTS.get(event.deliveryId)) {
return Response.json({});
}
await env.WEBHOOK_EVENTS.put(event.deliveryId, '1', { expirationTtl: 7 * 24 * 60 * 60 });
if (event.message?.type === 'text') {
return Response.json({
reply: { text: `Recibí: ${event.message.text}`.slice(0, 4000) }
});
}
// Ejemplo asíncrono opcional:
// ctx.waitUntil(sendReply(env, {
// conversationId: event.conversationId,
// text: 'Procesé tu mensaje.'
// }));
void ctx;
void sendReply;
return Response.json({});
}
};
Python · FastAPI
import base64
import hashlib
import hmac
import os
import uuid
import httpx
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
API_BASE = os.getenv("NAHUI_API_BASE", "https://api.nahui.studio")
CONNECTION_ID = os.environ["NAHUI_CONNECTION_ID"]
API_KEY = os.environ["NAHUI_API_KEY"]
WEBHOOK_SECRET = os.environ["NAHUI_WEBHOOK_SECRET"]
# Demostración únicamente. Usa almacenamiento persistente con índice único.
processed_deliveries: set[str] = set()
def expected_signature(raw_body: bytes) -> str:
digest = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256,
).digest()
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return f"sha256={encoded}"
@app.get("/health")
async def health():
return {"ok": True}
@app.post("/webhooks/nahui")
async def nahui_webhook(
request: Request,
x_nahui_signature_256: str | None = Header(default=None),
):
raw_body = await request.body()
expected = expected_signature(raw_body)
if not x_nahui_signature_256 or not hmac.compare_digest(x_nahui_signature_256, expected):
raise HTTPException(status_code=401, detail="Firma inválida")
try:
event = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="JSON inválido") from exc
delivery_id = event.get("deliveryId")
if not delivery_id or event.get("type") != "message.received":
raise HTTPException(status_code=400, detail="Evento no soportado")
if delivery_id in processed_deliveries:
return {}
processed_deliveries.add(delivery_id)
message = event.get("message") or {}
if message.get("type") == "text" and message.get("text"):
return {"reply": {"text": f"Recibí: {message['text']}"[:4000]}}
return {}
async def send_reply(
conversation_id: str,
text: str,
idempotency_key: str | None = None,
):
key = idempotency_key or str(uuid.uuid4())
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(
f"{API_BASE}/v1/connect/relay/{CONNECTION_ID}/reply",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": key,
},
json={
"conversationId": conversation_id,
"message": {"text": text[:4000]},
},
)
response.raise_for_status()
return response.json()
cURL
# Ejemplos con cURL
Define variables sin guardarlas en el historial compartido de tu equipo:
```bash
export NAHUI_CONNECTION_ID='ncc_...'
export NAHUI_API_KEY='nhc_...'
export CONVERSATION_ID='ncv_...'
```
## Respuesta de texto
```bash
curl --request POST \
"https://api.nahui.studio/v1/connect/relay/${NAHUI_CONNECTION_ID}/reply" \
--header "Authorization: Bearer ${NAHUI_API_KEY}" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: crm:reply:$(date +%s):001" \
--data "{\"conversationId\":\"${CONVERSATION_ID}\",\"message\":{\"text\":\"Hola desde mi CRM\"}}"
```
## Plantilla
```bash
curl --request POST \
"https://api.nahui.studio/v1/connect/relay/${NAHUI_CONNECTION_ID}/template" \
--header "Authorization: Bearer ${NAHUI_API_KEY}" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: crm:template:$(date +%s):001" \
--data "{\"conversationId\":\"${CONVERSATION_ID}\",\"templateName\":\"recordatorio_cita\",\"language\":\"es_MX\"}"
```
No agregues `to`, `phone`, `recipient`, `wa_id` ni listas de números.