A project I designed and built, in Go
Appssaywhat.papo decides how.
I built papo so my SaaS never touch WhatsApp directly. They send an intent; papo handles consent, timing and delivery.

Click Sailon to change who he speaks for. Drag to spin him.
- Role
- sole developer, from PRD to tests
- Built
- Aug to Oct 2026
- Commits
- 250+
- Stack
- Go, PostgreSQL, whatsmeow
- Used by
- my own SaaS and their tenants
Every one of my products needed WhatsApp. None of them should know how it works.
Who opted in. Whether this is still a conversation. Which number is still warming up. Whether it is 1 AM where the customer lives. papo answers all of it behind one route, so no app, SDR flow or tenant has to ask.
- template_name
- window_expires_at
- session_id
- quality_rating
- tier: 10k/day
- QR re-pairing
- opt_in = ?
- run_after
- STOP / SAIR
- quiet hours, per timezone
- retry with backoff
- number got banned
- dropped session
- rate limit per chip
POST /v1/messages { "intent": "lembrete_agendamento" }The policy engine
Seven gates, in order. The first no wins.
Reject and defer are different answers. An opt-out never fixes itself, so it fails at once. Quiet hours end, so the message waits. Pick a case and watch it walk.
POST /v1/messages Idempotency-Key: 8f3c1a… { "to": { "channel": "whatsapp", "id": "5511…" }, "intent": "lembrete_agendamento", "data": { "nome": "Ana", "servico": "corte", "quando": "2026-08-14T15:00-03:00" } }
Identifiers are the real ones: the contract speaks Portuguese. Error details follow Accept-Language; error codes are never translated, so no switch breaks.
Kill switch
tenant switched off, number paused or banned
Suppression
global list, across every tenant
Reply window
a reply only within 24h of their last message
Consent
permission for this class of intent
Business hours
in the tenant's own timezone
Spacing
minimum gap between sends of one number
Volume ceiling
warm-up ramp and initiative cap
Nobody gets a message without writing first.
Permissions are born false. The only way to switch them on is an inbound message carrying an activation token. Sending to someone who never wrote is not a setting to avoid. It is impossible.
- Auditable consent
- token, timestamp and where it came from
- An open window
- the person wrote first, so the 24h clock starts honestly
- A verified number
- whoever sent it owns the phone
- No cold outreach
- the first touch was theirs, which is what keeps numbers alive
Writing once answers may I send? It does not answer is this still a conversation? So replies inside 24 hours and initiatives (reaching someone quiet since March) draw from separate budgets.
From a wa.me link on the salon's site, text prefilled
Consent stored with token, timestamp and origin
Parsed on inbound, applied at once, the app is told
Suppressed globally, across every tenant
One service, many voices.
That is why the mascot, Sailon, is a chameleon. The same papo speaks for my products, for their SDRs and for every tenant, and the apps never learn how. Underneath, the channel sits behind one small interface that nothing else in papo knows about.
type Transport interface { Send(ctx context.Context, out Outbound) (providerMsgID string, err error) }

The product's own numbers.
SDR, confirmation, support: numbers that belong to the SaaS itself, not to any customer. They are the only ones allowed to message the admins.
- tenant_id
- null
- purpose
- sdr
- tipo
- operacional
Each customer's own number.
A salon pairs its WhatsApp by QR in seconds. Hours, timezone, warm-up ramp and kill switch are its own, so one tenant going wrong never takes the others down with it.
- tenant_id
- uuid
- tipo
- cliente
- pairing
- QR scan
A line straight to the admins.
When a number drops or gets banned at 3 AM, an operational number DMs up to ten configured phones. Alerts skip business hours: that news cannot wait for 8 AM.
- route
- /v1/alertas
- recipients
- up to 10
- hours
- ignored
The whole flow, nothing leaves.
make dev runs papo with a fake transport. /debug/inbound plays the person writing back, which is the only way to switch consent on, so a new app walks the full path before touching a real number.
- command
- make dev
- inbound
- /debug/inbound
- numbers burned
- zero
A new number earns its volume.
A paired phone gets no tier and no warning from Meta. With no outside signal, climbing slowly is the only defensible volume policy. Blocks, undelivered messages or a dropped session take it one step back.
messages per day, by day. Default curve, configurable per channel.
Architecture
One binary. Postgres. Nothing else in the critical path.
Low memory in production is a requirement, not a wish. So: Go, one binary for API, worker and admin panel, the WhatsApp client linked in as a library instead of a separate server, no headless browser, and Postgres as queue, rate limiter and session store. No Redis.
In order per conversation, parallel across them.
Otherwise, at peak, Ana gets “see you tomorrow” after “your booking was cancelled” and calls the salon, annoyed.
Ordering without a global lock.
A worker claims a message only if nothing else from its conversation is in flight and it is the oldest one waiting. SKIP LOCKED settles races between workers. Failures back off exponentially with jitter, capped at an hour, then land in a dead-letter queue the admin panel can requeue.
-- the claim, simplified WHERE m.status = 'queued' AND m.run_after <= now() AND NOT EXISTS (same conversation, in flight) AND NOT EXISTS (older one, same conversation) FOR UPDATE SKIP LOCKED
The boring parts, done on purpose.
1
binary: API, worker, admin
271
test functions
19
migrations, each reversible
20k
lines of Go
0
Redis
Every write is idempotent.
Idempotency-Key is required. Same key, same answer. Same key with a different body is refused, because the request is hashed. Keys are purged after seven days.
Every change audited.
Pausing a number or requeuing dead letters in the panel is logged with author and IP.
The tenant's clock.
Hours, daily caps and “today” all run in the tenant's timezone. A 22:00 to 06:00 shift just works.
Tests talk to a real Postgres.
The queue, the policy and the claim invariants are SQL, so a mocked database would test nothing. In CI a missing database fails the run instead of quietly skipping 37 end-to-end tests. Bugs get a failing test first, then the fix.
What papo refuses to do.
Cold lists.
No opt-in, no message. Contacts come in by writing first, through activation links. That is what keeps numbers from being reported.
Faking a human.
No forged read receipts, no typing indicator on a timer. “Typing” shows only while the app is really composing.
AI inside the provider.
papo carries and stores. Deciding what to say belongs to the app, which knows its own domain.
Chatwoot.
Evaluated and dropped: gigabytes of Rails, Sidekiq and Redis, and an unswappable dependency in the critical path.
private, internal service
Designed, built and tested by me.
papo runs behind my own SaaS, their SDR numbers and their tenants. The source stays private; this page shows the decisions behind it.
About me