Links, QR and chat bubble

Bring conversations in with wa.me links, printable QR codes and a chat bubble, and see how many came from each source.

A conversation that starts from a sign at the gate, a link on an invoice or a button on your website should say so. Sendo builds a WhatsApp click-to-chat link whose prefilled message ends with a short visible code, like [#garita]. When a contact sends that message, the new conversation is tagged with the source, and you can count conversations per source.

Create a link#

sendo.links.create({ account?, tenant?, text?, source }) returns { url, code, phone, text }. It picks the number the same way send.* does (an explicit account, else the tenant’s default number), reads it, and writes nothing.

ts
// The tenant's default number (or pass `account`: its id or Meta phone number id).
const link = await sendo.links.create({
  tenant: tenantId,
  text: "Hola, quiero información",
  source: "garita",
});
// link.url   → "https://wa.me/50255551234?text=Hola%2C%20quiero%20informaci%C3%B3n%20%5B%23garita%5D"
// link.text  → "Hola, quiero información [#garita]"
// link.code  → "[#garita]"
// link.phone → "+50255551234"
  • source must match ^[a-z0-9-]{1,32}$ (lowercase letters, digits and -). Anything else throws INVALID_PAYLOAD.
  • The URL is https://wa.me/<digits>?text=<text + " [#source]">, percent-encoded. Without text the message is just the code.
  • The contact can edit the message before sending it. If they delete the code, the conversation is simply “direct”.

When you already know the number, buildWhatsAppLink builds the same link with no lookup:

ts
// No lookup at all when you already know the number.
const direct = buildWhatsAppLink({ phone: "+502 5555 1234", text: "Hola", source: "factura" });

How attribution works#

  • The pipeline reads a trailing [#code] from the first inbound message of a new or reopened conversation only. It sets conversation.source and message.metadata.source. A code in a later message is ignored.
  • The stored message keeps the full text. The AI agent gets the text without the code, and the inbox shows it without the code plus an “Origin: garita” chip (“Origen: garita” in Spanish).
  • A Click-to-WhatsApp ad sets source = "ad:<ad id>" from Meta’s referral. With neither, source is null (direct).

Count conversations per source#

ts
// Admin only (`numbers.manage`); scoped to the actor's tenant. `source: null` = direct.
const sources = await sendo.links.sources({ actor: admin, from: new Date("2026-10-01") });
// [{ source: "garita", conversations: 42 }, { source: null, conversations: 17 }, ...]

Sorted by count, most first, ties by name, null (direct) last among equals. It needs an actor with numbers.manage (admins, see the roles table in the inbox docs) and throws FORBIDDEN otherwise. Pass account to count a single number, and from / to to limit by when the conversation was created.

The widgets registry item#

shell
npx shadcn add @sendo/widgets

It adds:

  • The links screen at /inbox/links (app/(protected)/inbox/links): pick a number, write the message and the source, then copy the link, download the QR as PNG or SVG, and copy the chat bubble snippet. Below it, the sources table with conversation counts. Admins only.
  • <SendoQrCode locale value label /> (components/sendo/widgets/qr-code.tsx): error correction H (Q when the message makes the code too dense, never lower), a 4-module quiet zone, always black on white so phones read it in dark mode too. The PNG and SVG are generated in the browser; no external QR service sees your links. It uses qrcode-generator, a dependency of the item, not of @quami/sendo.
  • <SendoChatBubble locale /> (components/sendo/widgets/chat-bubble.tsx) for your public site.

Before using the screen, implement requireUser() in app/(protected)/inbox/links/links-config.ts (it throws until you do) and configure resolveRole in createSendo.

The screen renders in English or Spanish: the page passes locale={sendoLocale} from lib/sendo-ui.ts, and counts and shares follow the optional formatLocale. Override single texts with the strings prop or edit components/sendo/widgets/strings.ts; see Languages.

Chat bubble#

A floating button that opens WhatsApp with the message ready. It’s a plain link, so it works in Server and Client Components and needs no client JavaScript. It has hover and press states, and with prefers-reduced-motion the press doesn’t scale.

app/layout.tsx
import { SendoChatBubble } from "@/components/sendo/widgets/chat-bubble";

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es">
      <body>
        {children}
        <SendoChatBubble
          locale="es"
          phone="+502 5555 1234"
          text="Hola, quiero información"
          source="web"
        />
      </body>
    </html>
  );
}
PropTypeNotes
locale"en" | "es"Your public site's language: the accessible name and tooltip. Required.
phonestringAny format; only the digits are used. Renders nothing when it isn’t 7–15 digits.
textstring?Prefilled message.
sourcestring?Appended as [#source].
hrefstring?A link from sendo.links.create (overrides phone, text, source).
position"bottom-right" | "bottom-left"Default bottom-right.
labelstring?Accessible name and tooltip. Default “Message us on WhatsApp” (en).

To pick the number by account or tenant instead of by phone, build the link on the server and pass href={link.url}.

The bubble is a convenience. Any site, email or PDF can use the plain link, and attribution works the same:

html
<a href="https://wa.me/50255551234?text=Hola%2C%20quiero%20informaci%C3%B3n%20%5B%23web%5D">
  Escríbenos por WhatsApp
</a>

Encode the whole message with encodeURIComponent ([ is %5B, # is %23, ] is %5D). There is no non-React embed script for the bubble.

Why not Instagram or Messenger links yet#

Instagram (ig.me) and Messenger (m.me) have their own click-to-chat links, and Sendo’s schema already has a channel column for them. But only WhatsApp messages are parsed and answered today. A link to a channel whose messages never reach your inbox would be a broken promise, so the link builder is WhatsApp-only and will add ig.me / m.me when those channels ship.