npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mafesoftware/kapso-wa

v0.2.0

Published

WhatsApp por Kapso (proxy de la Cloud API de Meta). fetch inyectable.

Readme

@mafesoftware/kapso-wa

WhatsApp por Kapso (proxy de la Cloud API de Meta). fetch inyectable.

Parte de la familia de paquetes de MAFE Software: sin dependencias de framework, sin ORM, y puros salvo donde se indique. Todo lo que sale a la red acepta un fetch inyectable, así que los tests corren sin red.

bun add @mafesoftware/kapso-wa

La documentación de cada función está en src/index.ts, con el motivo de cada decisión al lado. Los tests (tests/) son la otra mitad de la documentación: cada uno dice qué bug evita.

API

Números

import { aNumeroWhatsApp } from "@mafesoftware/kapso-wa";

aNumeroWhatsApp("011 4567-8901");     // "5491145678901"
aNumeroWhatsApp("+54 9 11 4567 8901"); // "5491145678901"
aNumeroWhatsApp("no es un teléfono"); // null (nunca un número adivinado)

Envío de mensajes

Todas devuelven Resultado ({ ok: true, id } o { ok: false, categoria, error }) y nunca tiran.

import { enviarTexto, enviarPlantilla, enviarBotones, enviarLista, enviarAviso, dentroDeVentana24h } from "@mafesoftware/kapso-wa";

const cred = { apiKey: "kapso_...", phoneNumberId: "1234567890" };

await enviarTexto(cred, "5491145678901", "¡Gracias por tu reserva!"); // solo dentro de la ventana de 24h

await enviarPlantilla(cred, "5491145678901", "gf_cuota_vence", ["Juana", "$12.000"]); // fuera de la ventana

await enviarBotones(cred, "5491145678901", "¿Confirmás tu turno?", [
  { id: "reserva:confirmar", titulo: "Confirmar" },
  { id: "reserva:cancelar", titulo: "Cancelar" },
]);

await enviarLista(cred, "5491145678901", "Turnos libres:", "Ver turnos", [
  { titulo: "Cancha 1", opciones: [{ id: "turno:1", titulo: "18:00" }] },
]);

// Elige sola: texto si escribió hace menos de 24h, plantilla si no.
await enviarAviso(cred, "5491145678901", {
  ultimoMensajeEntrante: ultimaVezQueEscribio,
  texto: "Tu cuota de septiembre está vencida.",
  plantilla: "gf_cuota_vence",
  parametros: ["septiembre"],
});

dentroDeVentana24h(ultimaVezQueEscribio); // true | false

Onboarding de un club

import { crearCliente, crearSetupLink } from "@mafesoftware/kapso-wa";

const cred = { apiKey: "kapso_..." };
// Cada app pasa SU prefijo: el proyecto de Kapso es uno solo para todo MAFE
// Software, y el default "gestionflow" queda solo por compatibilidad.
const r = await crearCliente(cred, "Club Náutico", clubId, "padel360");
if (r.ok) {
  const link = await crearSetupLink(cred, r.cliente.id, { volverBienA: "https://app/ok" });
  if (link.ok) redirigirA(link.url); // el club conecta su WhatsApp ahí
}

// Si la credencial de un número ya conectado se rompió, un link atado a ESE número:
await crearSetupLink(cred, clienteId, { reconectarTelefono: "+5491145678901" });

Webhook del número

Los mensajes llegan por un webhook de cada número (el de proyecto solo trae conexiones). Se registra una vez, al conectarse el número; el secreto lo elige la app.

import { registrarWebhookNumero, listarWebhooksNumero, EVENTOS_WEBHOOK_NUMERO } from "@mafesoftware/kapso-wa";

const cred = { apiKey: "kapso_..." };
const ya = await listarWebhooksNumero(cred, phoneNumberId);
if (ya.ok && !ya.webhooks.some((w) => w.url === urlWebhook)) {
  await registrarWebhookNumero(cred, phoneNumberId, { url: urlWebhook, secreto: secretoWebhook });
  // eventos por defecto: EVENTOS_WEBHOOK_NUMERO (received, delivered, read, failed)
}

Webhook entrante

import { verificarFirmaWebhook, leerEventosWebhook, leerEventoWebhook } from "@mafesoftware/kapso-wa";

export async function POST(request: Request) {
  const crudo = await request.text(); // el cuerpo CRUDO: re-serializarlo rompe la firma
  if (!verificarFirmaWebhook(crudo, request.headers.get("x-webhook-signature"), secretoWebhook)) {
    return new Response("firma inválida", { status: 401 });
  }
  // El nombre del evento viene en la CABECERA, no en el cuerpo.
  const nombre = request.headers.get("x-webhook-event");
  // Con buffering, Kapso manda un lote ({ batch: true, data: [...] }): leerEventosWebhook lo desarma.
  for (const evento of leerEventosWebhook(JSON.parse(crudo), nombre)) {
    if (evento.tipo === "mensaje") {
      // evento.mensaje: { tipo, de, phoneNumberId, texto, payload?, media?, nombreContacto?, mensajeId, fechaHora }
    } else if (evento.tipo === "estado" && evento.estado === "failed") {
      // evento.error?: { codigo: 131047, titulo, mensaje }
    } else if (evento.tipo === "numero_conectado") {
      // evento.clienteId (Kapso), evento.idExterno ("<prefijo>:<id>")
    }
    // "ignorado": de otra app o que no se reconoce — el webhook igual contesta 200
  }
  return new Response("ok");
}

// Un evento suelto (sin buffering) también se puede leer de a uno:
const evento = leerEventoWebhook(JSON.parse(crudo), request.headers.get("x-webhook-event"));

Media entrante

import { bajarMedia } from "@mafesoftware/kapso-wa";

if (evento.tipo === "mensaje" && evento.mensaje.media) {
  // `media.url` (message.kapso.media_url) suele venir ya listo; por id:
  const r = await bajarMedia({ apiKey: "kapso_...", phoneNumberId }, evento.mensaje.media.id);
  if (r.ok) await guardar(r.bytes, r.mimeType); // Uint8Array
}

Plantillas

import { crearPlantilla, listarPlantillas } from "@mafesoftware/kapso-wa";

const cred = { apiKey: "kapso_..." };
const r = await crearPlantilla(cred, wabaId, {
  nombre: "ef_expensa_vence",
  categoria: "UTILITY",
  componentes: [{ type: "BODY", text: "Hola {{1}}, tu expensa vence el {{2}}.", example: { body_text: [["Juana", "10/11"]] } }],
});
// r.plantilla.estado === "PENDING" hasta que Meta la revisa

const lista = await listarPlantillas(cred, wabaId, { estado: "APPROVED" });
if (lista.ok) lista.plantillas.map((p) => `${p.nombre}: ${p.estado}`);

Probar

bun test