Tutorial: WhatsApp Invoice Reminder
Build a complete add-on that lets users send invoice payment reminders via WhatsApp with one click. Perfect first add-on project.
What you'll learn
• Setting up a project from scratch
• Using the SDK provider and hooks
• Fetching invoices with filters
• Showing loading and empty states
• Using navigation and notifications
• Deploying and registering on the marketplace
Final result:
An add-on that shows unpaid invoices and opens WhatsApp with a pre-filled payment reminder message when clicked.
Step 1: Create the Project
# Create a new React + TypeScript project
npm create vite@latest whatsapp-reminder -- --template react-ts
cd whatsapp-reminder
# Install the SyncBooks SDK
npm install @syncbooks/addon-sdk
# Install Tailwind (optional but recommended)
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -pConfigure Tailwind
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { extend: {} },
plugins: [],
}@tailwind base;
@tailwind components;
@tailwind utilities;Step 2: Set Up the Entry Point
import React from 'react'
import ReactDOM from 'react-dom/client'
import { SyncBooksProvider } from '@syncbooks/addon-sdk'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<SyncBooksProvider>
<App />
</SyncBooksProvider>
</React.StrictMode>
)Why SyncBooksProvider?
This component establishes the connection to SyncBooks, receives authentication tokens, and makes all SDK hooks work. Without it, nothing functions.
Step 3: Build the App Component
This is the main component. It shows unpaid invoices and lets users send WhatsApp reminders:
import { useState } from 'react'
import { useSyncBooks, useInvoices, ui } from '@syncbooks/addon-sdk'
export default function App() {
const { ready, scopes } = useSyncBooks()
// ─── Loading State ─────────────────────────────────────────────
if (!ready) {
return (
<div className="flex items-center justify-center min-h-screen bg-white">
<div className="text-center">
<div className="h-8 w-8 mx-auto animate-spin rounded-full border-2 border-green-600 border-t-transparent" />
<p className="text-sm text-gray-500 mt-3">Connecting to SyncBooks...</p>
</div>
</div>
)
}
// ─── Permission Check ──────────────────────────────────────────
const hasAccess = scopes.includes('invoices.read') || scopes.includes('invoices.*') || scopes.includes('*')
if (!hasAccess) {
return (
<div className="p-8 text-center">
<p className="text-red-600 font-semibold">⚠️ Permission Required</p>
<p className="text-sm text-gray-500 mt-2">
This add-on needs <code className="bg-gray-100 px-1 rounded">invoices.read</code> permission.
Ask your admin to reinstall with this scope.
</p>
</div>
)
}
return <InvoiceList />
}
// ─── Invoice List Component ────────────────────────────────────────
function InvoiceList() {
// Fetch unpaid invoices (status = 'sent' means awaiting payment)
const {
data: invoices,
loading,
error,
total,
page,
totalPages,
nextPage,
prevPage,
} = useInvoices({ status: 'sent', limit: 10 })
// Track which invoices we've sent reminders for
const [sentIds, setSentIds] = useState<Set<string>>(new Set())
// ─── Send WhatsApp Reminder ──────────────────────────────────
function sendReminder(invoice: any) {
const customer = invoice.customerId
const name = customer?.name || 'Customer'
const phone = customer?.phone?.replace(/[\s-]/g, '') || ''
const amount = invoice.totalAmount?.toFixed(2) || '0.00'
const invoiceNum = invoice.invoiceNumber || 'your invoice'
const dueDate = invoice.dueDate
? new Date(invoice.dueDate).toLocaleDateString()
: 'soon'
// Build WhatsApp message
const message = encodeURIComponent(
`Hi ${name},\n\n` +
`This is a friendly reminder that ${invoiceNum} for GHS ${amount} ` +
`is due by ${dueDate}.\n\n` +
`Please make payment at your earliest convenience.\n\n` +
`Thank you! 🙏`
)
// Open WhatsApp (with phone if available, without if not)
const url = phone
? `https://wa.me/${phone}?text=${message}`
: `https://wa.me/?text=${message}`
window.open(url, '_blank')
// Mark as sent
setSentIds(prev => new Set([...prev, invoice._id]))
// Show toast in SyncBooks
ui.notify(`WhatsApp opened for ${invoiceNum}`, 'success')
}
// ─── Loading ─────────────────────────────────────────────────
if (loading) {
return (
<div className="p-6">
<Header total={0} />
<div className="space-y-3 mt-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
// ─── Error ───────────────────────────────────────────────────
if (error) {
return (
<div className="p-6">
<Header total={0} />
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">Error loading invoices: {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 text-xs text-red-600 hover:underline"
>
Try again
</button>
</div>
</div>
)
}
// ─── Render ──────────────────────────────────────────────────
return (
<div className="p-4 sm:p-6 max-w-2xl mx-auto">
<Header total={total} />
{/* Empty State */}
{invoices.length === 0 ? (
<div className="text-center py-16">
<p className="text-4xl mb-3">🎉</p>
<p className="font-semibold text-gray-700">All invoices paid!</p>
<p className="text-sm text-gray-500 mt-1">No pending invoices to remind about.</p>
</div>
) : (
<>
{/* Invoice Cards */}
<div className="space-y-3 mt-4">
{invoices.map((invoice: any) => (
<div
key={invoice._id}
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
sentIds.has(invoice._id)
? 'bg-green-50 border-green-200'
: 'bg-white border-gray-200 hover:border-gray-300'
}`}
>
{/* Invoice Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-900">
{invoice.invoiceNumber}
</span>
{sentIds.has(invoice._id) && (
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded-full font-medium">
✓ Sent
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{invoice.customerId?.name || 'Unknown'} — GHS {invoice.totalAmount?.toFixed(2)}
</p>
<p className="text-xs text-gray-400">
Due: {invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'}
</p>
</div>
{/* Send Button */}
<button
onClick={() => sendReminder(invoice)}
className="shrink-0 ml-3 flex items-center gap-1.5 px-3 py-2 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors"
>
{/* WhatsApp Icon */}
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
Send
</button>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<button
onClick={prevPage}
disabled={page <= 1}
className="text-xs px-3 py-1.5 border rounded-md disabled:opacity-40"
>
← Previous
</button>
<span className="text-xs text-gray-500">
Page {page} of {totalPages}
</span>
<button
onClick={nextPage}
disabled={page >= totalPages}
className="text-xs px-3 py-1.5 border rounded-md disabled:opacity-40"
>
Next →
</button>
</div>
)}
</>
)}
</div>
)
}
// ─── Header Component ────────────────────────────────────────────
function Header({ total }: { total: number }) {
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold text-gray-900 flex items-center gap-2">
📱 WhatsApp Reminders
</h1>
<p className="text-xs text-gray-500">Send payment reminders via WhatsApp</p>
</div>
{total > 0 && (
<span className="text-xs bg-amber-100 text-amber-700 px-2.5 py-1 rounded-full font-medium">
{total} unpaid
</span>
)}
</div>
)
}Step 4: Run Locally
npm run dev
# Open http://localhost:5173You'll see the loading state. API calls won't work until you deploy and register the add-on (or configure a test token).
Step 5: Deploy to Vercel
npm run build
npx vercel deploy --prod
# Output: https://whatsapp-reminder-abc123.vercel.appStep 6: Register on the Marketplace
Go to /developers → Marketplace Add-ons → New Addon
Fill in:
Set manifest fields
- Name: WhatsApp Invoice Reminder
- Short Description: Send payment reminders to customers via WhatsApp
- Launch URL: https://whatsapp-reminder-abc123.vercel.app
- Category: Communication
- Permissions:
invoices.read,customers.read - Placements:
navigation.item,invoice.actions - Pricing: Free
Create and test
Click Create. Go to your dashboard → Add-ons → open your addon. It should show your invoices with WhatsApp send buttons!
Submit for review
When everything works, click Submit. A SyncBooks admin will review and publish it.
How It Works
useSyncBooks()— checks connection status and granted scopesuseInvoices({ status: 'sent' })— fetches unpaid invoices with auto-paginationui.notify()— shows a toast notification inside SyncBookswindow.open()— opens WhatsApp with a pre-filled message- Local
sentIdsstate — tracks which invoices have been reminded
Next Tutorial
Ready for something more advanced? Build an Expense Receipt Scanner with file upload, OCR, and its own backend.