@bigbinary/neeto-whatsapp-frontend
v1.2.1
Published
This is the frontend package for the neeto-whatsapp-nano project.
Readme
neeto-whatsapp-nano
The neeto-whatsapp-nano manages WhatsApp messaging within neeto applications.
The nano exports the @bigbinary/neeto-whatsapp-frontend NPM package and
neeto-whatsapp-engine Rails engine for development.
It also meters WhatsApp messages per workspace. Each workspace gets free messages every month, and admins can buy prepaid message packs from NeetoAuth Billing. See WhatsApp message packs.
Contents
Development with Host Application
Engine
The engine handles WhatsApp Business API integration, including sending and receiving messages, processing webhooks for message status updates, and managing WhatsApp message templates.
Installation
Add this line to your application's Gemfile:
source "NEETO_GEM_SERVER_URL" do # ..existing gems gem 'neeto-whatsapp-engine' # Use this for live development: # gem 'neeto-whatsapp-engine', path: "../neeto-whatsapp-nano" endAnd then execute:
bundle installAdd this line to your application's
config/routes.rbfile:mount NeetoWhatsappEngine::Engine, at: "/neeto_whatsapp"Run the following command to copy the migrations from the engine to the host application:
bundle exec rails neeto_whatsapp_engine:install:migrationsAdd the migrations to the database:
bundle exec rails db:migrateAdd the following environment variables:
WHATSAPP_NANO_WEBHOOK_VERIFY_TOKEN=abc123
WHATSAPP_WEBHOOK_APP_SECRET=abc123
WHATSAPP_ENABLED_ORGANIZATIONS="spinkart"
WHATSAPP_DISABLE_INBOUND_MESSAGES=true <--- To disable processing incoming messages. Defaults to false.- Add the following association to the entity to which you want to associate the whatsapp messages
has_many :whatsapp_messages, class_name: "NeetoWhatsappEngine::Message", as: :entity, dependent: :destroy- Override the fetch_entity! method for NeetoWhatsappEngine::Api::V1::MessagesController
# app/overrides/controllers/neeto_whatsapp_engine/api/v1/messages_controller_override.rb
# frozen_string_literal: true
NeetoWhatsappEngine::Api::V1::MessagesController.class_eval do
private
def fetch_entity!
# Fetch entity here
# example:
# @entity = @organization.bookings.find_by!(id: params[:entity_id])
end
end- The backend exposes 2 services for sending whatsapp messages:
- For sending template messages
NeetoWhatsappEngine::TemplateMessageService.new(account).process(
recipient_phone:,
template_name:,
language_code:,
entity: booking,
# Required. The workspace the message is metered against. See "WhatsApp message packs".
organization: booking.organization,
# Optional. Shown as the recipient in WhatsApp logs.
recipient_name: booking.name,
template_params: [
# This is an example on how to send params.
# Check the params required based on the template(created on meta's dashboard) which you are going to use.
{ name: "client_name", type: "text", text: booking.name },
{ name: "meeting_name", type: "text", text: booking.meeting.name },
{ name: "host_meeting_time", type: "text", text: meeting_time_in_host_time_zone },
{ name: "attendee_list", type: "text", text: attendee_list },
{ name: "meeting_location", type: "text", text: meeting_location },
{ name: "booking_id", type: "text", text: booking.sid }
],
# Optional. Only needed if the template has buttons with dynamic values.
# See the "Template buttons" section below for the accepted shape.
template_buttons: [
{ index: 0, sub_type: "url", text: booking.sid },
{ index: 1, sub_type: "quick_reply", text: "unsubscribe" },
{ index: 2, sub_type: "copy_code", text: "25OFF" }
]
)- For sending free-form text message, you can use the frontend component
WhatsAppMessageswhich handles it internally. But if you want to call it manually, then
NeetoWhatsappEngine::TextMessageService.new(account).process(
recipient_phone:,
entity:,
message_text: "Hello",
organization:, # Required. The workspace the message is metered against.
recipient_name: # Optional. Shown as the recipient in WhatsApp logs.
)Template buttons
If a template (created on meta's dashboard) has buttons with dynamic values,
pass them via template_buttons. This is separate from template_params:
params fill the body text, buttons fill the button components. Each entry fills
one button, matched by index to the button's position in the template.
template_buttons: [
{ index: 0, sub_type: "url", text: booking.sid },
{ index: 1, sub_type: "quick_reply", text: "unsubscribe" },
{ index: 2, sub_type: "copy_code", text: "25OFF" }
]| Key | Required | Description |
| --- | --- | --- |
| index | No | 0-based position of the button in the template. Defaults to the entry's position in the array. |
| sub_type | No | "url" (default), "quick_reply", or "copy_code". |
| text | Yes | The dynamic value. Meaning depends on sub_type (see below). |
| name | No | For url buttons whose template uses a named variable (e.g. {{promo_code}} instead of {{1}}). Omit for positional variables. |
text maps to different things per button type:
url— the value substituted into the template URL's{{1}}variable. The base URL lives in the template; you only pass the variable value here (e.g. a booking id). Percent-encode any special characters.quick_reply— the payload returned in the webhook when the user taps the button.copy_code— the code copied to the user's clipboard when tapped.
Note: template_buttons is stored under a reserved "template_buttons" key in
the message's metadata. If you also pass that key in metadata, it is
overwritten.
WhatsApp message packs
How metering works
- Each workspace gets 100 free messages a month (UTC). Unused free messages don't carry over.
- After the free messages run out, sends use prepaid messages. Admins buy these as packs from NeetoAuth (Billing › WhatsApp packs), and they never expire.
- A message is reserved when it's sent, charged when WhatsApp reports
it delivered or read, and released if it fails.
NeetoWhatsappEngine::ReleaseStaleReservationsJobreleases any message that is still pending after 30 days. - Once both free and prepaid messages have run out,
TemplateMessageServiceandTextMessageServiceraiseNeetoWhatsappEngine::Errors::BalanceExhaustedErrorinstead of sending. The message is logged with the status "Not sent". - Both services take a required
organization:, the workspace the message is metered against and shown under in WhatsApp logs.
Architecture
Components and network calls
flowchart TB
subgraph Host["Host application, e.g. NeetoCal"]
Caller["Host code, e.g. a booking confirmation"]
subgraph Engine["neeto-whatsapp-engine"]
Services["TemplateMessageService<br/>TextMessageService"]
Billing["Billing::ReserveService<br/>Billing::ChargeService<br/>Billing::ReleaseService"]
Webhooks["WebhooksController<br/>ProcessWebhookPayloadJob"]
ServerAPI["Server API<br/>POST credits, GET balances"]
Jobs["ThresholdCheckJob<br/>ReleaseStaleReservationsJob"]
DB[("messages<br/>balances<br/>credits")]
end
Pages["WhatsApp logs and Usage pages"]
end
Meta["Meta WhatsApp Cloud API"]
Auth["NeetoAuth Billing"]
LS["Lemon Squeezy"]
Admins(["Workspace admins"])
Caller -->|"process(organization:, ...)"| Services
Services -->|"reserve"| Billing
Services -->|"1. send message"| Meta
Meta -->|"2. status webhook: delivered, read, failed"| Webhooks
Webhooks -->|"charge or release"| Billing
Billing --> DB
Billing -->|"low or ran out"| Jobs
Jobs -->|"low-balance and ran-out emails"| Admins
Admins -->|"Buy messages"| Auth
Auth -->|"3. hosted checkout"| LS
LS -->|"4. order_created webhook"| Auth
Auth -->|"5. POST credits and GET balances, server token"| ServerAPI
ServerAPI --> DB
Pages --> DBSending a message
sequenceDiagram
autonumber
participant Host as Host code
participant Svc as Message service
participant Bal as Balance (row lock)
participant Meta as Meta Cloud API
participant Job as ProcessWebhookPayloadJob
Host->>Svc: process(organization:, recipient_phone:, ...)
Svc->>Bal: ReserveService: roll the month if needed
alt free messages left this month
Bal-->>Svc: free_reserved += 1, billed_from: free
else prepaid messages left
Bal-->>Svc: prepaid_available -= 1, prepaid_reserved += 1, billed_from: prepaid
else nothing left
Bal-->>Svc: BalanceExhaustedError
Svc->>Svc: log the message as "Not sent"
Svc-->>Host: raise BalanceExhaustedError
Note over Svc: ThresholdCheckJob sends the ran-out email once
end
Svc->>Meta: send the message
alt Meta accepts it
Svc->>Svc: create the message, billing_state: reserved
else Meta returns an error
Svc->>Svc: create a failed message, then ReleaseService
end
Meta-->>Job: status webhook, processed in the background
alt delivered or read
Job->>Bal: ChargeService
Note over Job: ThresholdCheckJob sends the low-balance email once total_remaining drops to low_balance_threshold, 50 by default
else failed
Job->>Bal: ReleaseService
endReleaseStaleReservationsJob runs daily in the host (for example at 04:04 UTC
in NeetoCal) and releases messages still reserved after 30 days, when Meta
never sent a final status.
Buying a pack
sequenceDiagram
autonumber
actor Admin as Workspace admin
participant Auth as NeetoAuth
participant LS as Lemon Squeezy
participant Eng as Engine server API
Admin->>Auth: open Buy messages, from an email or the Usage page
Auth->>Eng: GET /neeto_whatsapp/api/v1/server/balances
Eng-->>Auth: free and prepaid balances, usage_url
Admin->>Auth: choose a pack and buy
Auth->>LS: create a checkout tagged kind: whatsapp_pack
Auth-->>Admin: redirect to the hosted checkout
Admin->>LS: pay
LS-->>Admin: redirect back to the buy page
LS->>Auth: order_created webhook
Auth->>Auth: record WhatsappPackPurchase, unique on the order id
Auth->>Eng: POST /neeto_whatsapp/api/v1/server/credits (CreditJob, retried until it succeeds)
Eng->>Eng: create Credit, unique on purchase_identifier, then prepaid_available += messages
Eng-->>Auth: 200, the same credit again on a repeat
Auth-->>Admin: the buy page polls for the purchase, then shows the new balanceNeetoAuth takes the message count from its own pack configuration, matched on the Lemon Squeezy variant, never from the checkout data.
Balance counters
Each workspace has one neeto_whatsapp_engine_balances row. Every write locks
it, so concurrent sends can't spend the same message twice.
| Counter | Meaning |
| --- | --- |
| free_used | Free messages charged this month |
| free_reserved | Free messages sent this month and not yet delivered or failed |
| prepaid_available | Prepaid messages left to spend |
| prepaid_reserved | Prepaid messages sent and not yet delivered or failed |
| period_starts_on | The month the free counters belong to |
free_remaining = max(free_monthly_allowance - free_used - free_reserved, 0)
(the full allowance when period_starts_on is an earlier month)
total_remaining = free_remaining + prepaid_availableHow each step moves the counters:
| Step | Free message | Prepaid message |
| --- | --- | --- |
| Reserve, on send | free_reserved + 1 | prepaid_available - 1, prepaid_reserved + 1 |
| Charge, on delivered or read | free_reserved - 1, free_used + 1 | prepaid_reserved - 1 |
| Release, on failure or after 30 days | free_reserved - 1 | prepaid_reserved - 1, prepaid_available + 1 |
| Credit, on a pack purchase | n/a | prepaid_available + messages |
- Month rollover: the first write in a new month resets
free_used,free_reservedand both email flags. A free message reserved last month and delivered this month moves no counter, because last month's counters are already gone. Prepaid messages are never reset. - Emails:
low_balance_notified_atandexhausted_notified_atmake each email go out once per crossing. A credit re-arms the ran-out email, and the low-balance email once the balance is back above the threshold.
stateDiagram-v2
[*] --> reserved: sent
[*] --> not_metered: no messages left, logged as Not sent
reserved --> charged: delivered or read
reserved --> released: failed, or still reserved after 30 days
charged --> [*]
released --> [*]
not_metered --> [*]billing_state on a message. A message refused for having no messages left has
no billing state and reserves nothing.
Each move out of reserved happens once: the service claims the message with a
conditional update on billing_state = 'reserved', so duplicate or out-of-order
webhooks don't charge twice.
Integrating message packs in a host application
<entity> below is the record your WhatsApp messages belong to, for example a
booking.
Run the metering migration. Copy and run the engine's migrations (see Installation). They add balances, credits, and billing fields on messages.
Pass the workspace when sending, and handle a refused send.
organization:is required. Also passrecipient_name:so WhatsApp logs show who the message went to.def send_whatsapp_message(entity:, recipient_phone:, recipient_name:, template_name:, language_code:, template_params:) NeetoWhatsappEngine::TemplateMessageService.new(account).process( recipient_phone:, recipient_name:, organization: entity.organization, template_name:, language_code:, entity:, template_params: ) rescue NeetoWhatsappEngine::Errors::BalanceExhaustedError # No WhatsApp messages left. The message is already logged as "Not sent" and admins are emailed. Rails.logger.info("[WhatsApp] Skipped #{template_name}: no WhatsApp messages left") endDecide who can see WhatsApp logs and usage. The admin APIs call
authorize_whatsapp_admin!, which raises until the host implements it:# app/overrides/controllers/neeto_whatsapp_engine/api/v1/admin_base_controller_override.rb NeetoWhatsappEngine::Api::V1::AdminBaseController.class_eval do private def authorize_whatsapp_admin! authorize(:settings, :can_view_whatsapp?) # use the host's own policy and permission end endLink messages back to their entity (optional). The message details pane in WhatsApp logs shows a link when the entity responds to
whatsapp_log_link:# app/models/<entity>.rb def whatsapp_log_link { label: I18n.t("<entity>.whatsapp_log_link_label"), value: sid, url: show_url } endName your templates. The Usage report and the logs show each template by its label under
neeto_whatsapp_engine.template_labels, falling back to the humanized template name:# config/locales/en.yml en: neeto_whatsapp_engine: template_labels: <template_name>: "Readable name of the message"Tell the engine where the Usage page is. Set
usage_pathin every environment'sconfig/constants.yml. The emails link to it, and NeetoAuth links back to it:neeto_whatsapp: usage_path: "<path of the host's WhatsApp usage page>"Schedule the cleanup job in
config/scheduled_jobs.yml:whatsapp_release_stale_reservations: cron: "4 4 * * *" class: "NeetoWhatsappEngine::ReleaseStaleReservationsJob" queue: "low" description: "Runs daily at 04:04 UTC. Releases WhatsApp message reservations still unresolved after 30 days"Add the WhatsApp logs and usage pages to the host's admin panel with the
WhatsAppLogsandWhatsAppUsagecomponents. Put the usage page atusage_path, and guard both pages with the same permission as step 3.Enable the product in NeetoAuth. Packs are sold from
<auth_app_url>/admin/billing/whatsapp-packs/<product slug>. The slug is the host'sapplication_namelowercased (NeetoCal becomesneetocal), and it must match the product'sClientApplication#slugin NeetoAuth. Then add the product'sClientApplicationname towhatsapp_packs.client_applicationsin NeetoAuth'sconfig/constants.yml.
Configuration
Set these under neeto_whatsapp: in the host's config/constants.yml:
| Key | Default | Description |
| --- | --- | --- |
| usage_path | none | Path of the host's WhatsApp Usage page. Used in the emails, and returned to NeetoAuth as usage_url. |
| free_monthly_allowance | 100 | Free messages per workspace each month. |
| low_balance_threshold | 50 | Admins are emailed when free plus prepaid messages drop to this number. |
The engine reads the product name from the host's application_name (for
example NeetoCal), and the NeetoAuth URL from each organization's
auth_app_url.
Emails to workspace admins
Both emails go to every admin of the workspace.
- "Only 50 WhatsApp messages left" is sent when a delivered message brings
the balance to
low_balance_threshold. - "WhatsApp notifications have stopped" is sent on the first send refused for having no messages left.
Each is sent once. A credit or the monthly reset re-arms it. Both have a Buy messages button (the NeetoAuth WhatsApp packs page) and a View usage link (the host's Usage page).
Server API for NeetoAuth
NeetoAuth calls these endpoints with the server authorization token
(Authorization: Token token=<server_authorization_token>).
POST /neeto_whatsapp/api/v1/server/creditsadds prepaid messages after a pack is paid for.- Params:
organization_subdomain,purchase_identifier(the Lemon Squeezy order id),messages,amount. - It's idempotent on
purchase_identifier: a repeat returns200and adds nothing.
- Params:
GET /neeto_whatsapp/api/v1/server/balances?organization_subdomains[]=<subdomain>returns each workspace's balance:- free used, free remaining and the reset date
- prepaid available and total remaining
- messages sent this month
- the last credit
usage_url
Rake tasks for development
Run these from the host application.
# Add prepaid messages to a workspace without a purchase
bin/rails "neeto_whatsapp_engine:credit[spinkart,500]"
# Pretend WhatsApp sent a status update for the latest outbound message (development only)
bin/rails "neeto_whatsapp_engine:simulate_status[delivered]" # charges the reserved message
bin/rails "neeto_whatsapp_engine:simulate_status[failed]" # releases itFrontend package
Installation
- Install the latest
neeto-whatsapp-frontendpackage using the below command:yarn add @bigbinary/neeto-whatsapp-frontend
Instructions for development
Check the Frontend package development guide for step-by-step instructions to develop the frontend package.
Components
WhatsAppMessages (source code)
This component provides a chat interface for viewing and sending WhatsApp messages for a given entity. It displays a scrollable message list with WhatsApp-style formatting, status icons, and a sticky compose form at the bottom. Messages are polled every 10 seconds for real-time updates.
Props
entityId: The ID of the entity whose messages should be displayed.entityType: The type of the entity (e.g.,"Booking").
Usage
import React from "react";
import { WhatsAppMessages } from "@bigbinary/neeto-whatsapp-frontend";
const App = ({ bookingId }) => (
<WhatsAppMessages
entityId={bookingId}
entityType="Booking"
// TO enable/disable the compose form to send messages. Defaults to true.
disableComposeForm={false}
/>
);
export default App;WhatsAppLogs (source code)
The workspace's WhatsApp logs: every message sent, with its status, recipient
and type. Click a message to see its details, including a link back to its
entity when the host defines whatsapp_log_link.
Props
breadcrumbs: Breadcrumbs for the page header.pageTitle: The browser tab title.usagePath: Route of the host'sWhatsAppUsagepage, linked from the header.helpUrl: Optional. A help article link.
WhatsAppUsage (source code)
The workspace's WhatsApp balance:
- free and prepaid messages left, and what happens when they run out
- this month's messages by type
- a Buy messages button that opens the NeetoAuth WhatsApp packs page
Props
breadcrumbs: Breadcrumbs for the page header.pageTitle: The browser tab title.logsPath: Route of the host'sWhatsAppLogspage.helpUrl: Optional. A help article link.
Usage
import WhatsAppLogs from "@bigbinary/neeto-whatsapp-frontend/WhatsAppLogs";
import WhatsAppUsage from "@bigbinary/neeto-whatsapp-frontend/WhatsAppUsage";
// Guard both routes with the permission used in authorize_whatsapp_admin!.
<Route
path={routes.whatsappLogs}
render={() => (
<WhatsAppLogs
breadcrumbs={breadcrumbs}
pageTitle="WhatsApp logs"
usagePath={routes.whatsappUsage}
/>
)}
/>
<Route
path={routes.whatsappUsage}
render={() => (
<WhatsAppUsage
breadcrumbs={breadcrumbs}
logsPath={routes.whatsappLogs}
pageTitle="WhatsApp usage"
/>
)}
/>Instructions for Publishing
Consult the building and releasing packages guide for details on how to publish.
