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.

Sailon, a chameleon in a cap, olive bomber jacket and white sneakers
speaking asan SDR number

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
What the app sees instead: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.

  1. Kill switch

    tenant switched off, number paused or banned

  2. Suppression

    global list, across every tenant

  3. Reply window

    a reply only within 24h of their last message

  4. Consent

    permission for this class of intent

  5. Business hours

    in the tenant's own timezone

  6. Spacing

    minimum gap between sends of one number

  7. 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.

Turn on reminders #A7K2M9

From a wa.me link on the salon's site, text prefilled

You're all set! You turned on: • appointment reminders • updates about your booking • offers and news To turn any of them off, reply: STOP REMINDERS · STOP OFFERS · STOP ALL

Consent stored with token, timestamp and origin

STOP

Parsed on inbound, applied at once, the app is told

Done. You won't get these messages anymore.

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)
}
Sailon, a chameleon in a cap, olive bomber jacket and white sneakers
speaking as an SDR number

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
speaking as a tenant's number

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
speaking as the ops alerts

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
speaking as the fake transport

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.

501001502001234567891011bad signal, one step back

messages per day, by day. Default curve, configurable per channel.

Ten messages, one minute.

A fixed tick is a bulk-sender fingerprint. papo waits a minimum gap, plus time for the length of the text, plus up to 30% jitter.

Bulk sender

papo

It helps a number not look like a bulk sender. It never makes an unwanted message fine: what takes numbers down is people blocking and reporting.

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.

papo, one Go binaryMy appsintent in, events outWhatsAppvia whatsmeowPostgresapipolicyqueueworkertransportwebhook outnormalizetransport inevents signed with HMAC-SHA256 and retried with backoff

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.

Ana
cancelled
reminder
Bruno
confirmed
Carla
reminder
Diego
offer

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.

Credentials sealed.

AES-256-GCM at rest, from the Go standard library. No crypto dependency to audit.

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.

built with

  • Go
  • PostgreSQL
  • Docker
  • WhatsApp

chi, pgx, whatsmeow

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
Sailon seen from the side, hands relaxed, tail curled