Skip to content

Build the Redis backend for a WhatsApp bot

12 min readWhatsAppValkeyRedisDatabases

Short version: one Valkey instance holds every piece of moving state a WhatsApp bot has. Where each conversation is, with a TTL so abandoned ones clean themselves up. A BullMQ queue for anything scheduled. A SET NX key per inbound message so a retried webhook does not charge anyone twice. A counter per recipient so you stay inside Meta's messaging limits. Create one at layerbase.com/create/valkey, point ioredis at the rediss:// string, and the code below is the backend.

A WhatsApp bot has an awkward shape. The transport is asynchronous and at-least-once. The user is a phone number that vanishes for three days and comes back mid-sentence. Everything you want to do next happens on a timer: a reminder tomorrow morning, a follow-up on Friday, a retry in an hour. And Meta will happily deliver you the same webhook twice.

Postgres is the wrong first tool for that. Not because it cannot do it, but because every one of those problems is a key with an expiry on it, and modelling expiry in SQL means a expires_at column plus a sweeper you now have to operate. Redis and Valkey have expiry as a property of the key. The bot gets shorter.

What the bot actually stores

Five things, and they are all short-lived:

  1. Conversation position. Which question the bot asked last, and what the user has answered so far.
  2. Webhook idempotency. Every inbound message id you have already processed.
  3. Scheduled work. Reminders, follow-ups, retries, nightly sweeps.
  4. Send budget. How many messages you have sent to this person recently.
  5. Session credentials, if you are running an unofficial client rather than the Cloud API.

None of that belongs in your primary database. Losing all of it costs you some in-flight conversations and nothing else, which is exactly the durability profile a key-value store is good at.

Create the database

Valkey is Redis-compatible: same RESP protocol, same commands, same clients, permissive BSD license. If you have no opinion, take Valkey. If you have an existing Redis you want to keep, Redis runs on the same plans and none of the code below changes. Redis vs Valkey has the longer argument.

Create one at layerbase.com/create/valkey and copy the connection string from Quick Connect. It looks like this:

text
rediss://default:<password>@your-host.cloud.layerbase.dev:6379

Two s characters means TLS, and the listener requires it. One detail that matters more than it looks: send the database hostname as the TLS server name. Layerbase routes the shared Valkey port by SNI, so a client that connects without a servername reaches nothing.

typescript
import IORedis from 'ioredis'

const url = process.env.VALKEY_URL!

export function createConnection() {
  return new IORedis(url, {
    tls: { servername: new URL(url).hostname },
    // BullMQ requires this on any connection it owns. Harmless elsewhere.
    maxRetriesPerRequest: null,
  })
}

export const redis = createConnection()

Conversation state, with an expiry

A bot conversation is a state machine whose transitions arrive minutes or days apart. Store the current node and the answers collected so far in a hash, and re-set the TTL on every turn so an abandoned conversation disappears on its own.

typescript
type Step =
  | 'idle'
  | 'awaiting_invoice'
  | 'awaiting_amount'
  | 'awaiting_confirmation'

const CONVERSATION_TTL_SECONDS = 60 * 30

const conversationKey = (waId: string) => `conv:${waId}`

export async function readConversation(waId: string) {
  const state = await redis.hgetall(conversationKey(waId))
  return {
    step: (state.step as Step) ?? 'idle',
    invoiceId: state.invoiceId,
    amountCents: state.amountCents ? Number(state.amountCents) : undefined,
  }
}

export async function advance(
  waId: string,
  step: Step,
  fields: Record<string, string> = {},
) {
  const key = conversationKey(waId)
  await redis
    .multi()
    .hset(key, { step, ...fields, updatedAt: new Date().toISOString() })
    .expire(key, CONVERSATION_TTL_SECONDS)
    .exec()
}

HSET on its own does not touch the key's expiry, which is why EXPIRE is in the transaction. That gives you a sliding 30-minute idle window: the conversation stays alive while the user is replying and evaporates when they stop.

Decide separately whether you want an absolute ceiling. A user who answers one question a day can keep a sliding session open forever. If that is wrong for your flow, write a startedAt into the hash on the first turn and refuse to continue past a hard deadline even when the TTL is still moving.

The other decision worth making on purpose: what happens when the state is gone. A bot that responds to a missing conversation with a generic "sorry, start again" is fine. A bot that crashes on undefined is not. Treat a missing key as idle and you get the graceful version for free, which is what the ?? 'idle' above is doing.

Idempotency, because the webhook will repeat

The Cloud API delivers messages by webhook and retries when your endpoint does not answer promptly, so your handler will eventually see the same message id more than once. If that handler charges a card, sends a message, or writes a ledger row, a duplicate is a real incident.

The fix is one atomic claim before any work happens:

typescript
const DEDUP_TTL_SECONDS = 60 * 60 * 24

export async function claimMessage(messageId: string): Promise<boolean> {
  const claimed = await redis.set(
    `wh:seen:${messageId}`,
    '1',
    'EX',
    DEDUP_TTL_SECONDS,
    'NX',
  )
  return claimed === 'OK'
}

SET ... NX either creates the key and returns OK, or finds it present and returns null. There is no read-then-write gap for two concurrent deliveries to slip through. Give the TTL a comfortable margin over Meta's retry window rather than trying to match it exactly, and let the key expire rather than keeping a permanent record of every message id you have ever seen.

The webhook handler then becomes almost nothing:

typescript
export async function POST(request: Request) {
  const body = await request.json()

  for (const message of extractMessages(body)) {
    if (!(await claimMessage(message.id))) continue
    await inbound.add('handle', { message }, { removeOnComplete: 1000 })
  }

  // Answer immediately. Everything real happens in the worker.
  return new Response('ok', { status: 200 })
}

Claim, enqueue, return 200. The handler does no work that can be slow, which means it does not time out, which means Meta does not retry it, which means the dedup key is a safety net rather than the load-bearing part. See Meta's webhook setup docs for the response requirements.

Reminders are a delayed job, not a cron

The instinct is a cron that scans a table every minute for anything due. It works until the table is large, and it gives you no natural place to put a retry policy.

BullMQ stores its queues in Redis, so the instance you already have is the scheduler. A payment reminder is a job with a delay:

typescript
import { Queue } from 'bullmq'

const connection = createConnection()

export const reminders = new Queue('reminders', {
  connection,
  prefix: 'bot:prod',
})

export async function scheduleReminder(
  invoiceId: string,
  waId: string,
  dueAt: Date,
  attempt: number,
) {
  await reminders.add(
    'payment-reminder',
    { invoiceId, waId, attempt },
    {
      delay: Math.max(0, dueAt.getTime() - Date.now()),
      // Deterministic id: scheduling the same reminder twice is a no-op.
      jobId: `reminder:${invoiceId}:${attempt}`,
      attempts: 3,
      backoff: { type: 'exponential', delay: 60_000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  )
}

Two options there are doing more work than their length suggests. jobId makes scheduling idempotent: a deployment that re-runs your reminder setup does not produce a second reminder, because BullMQ ignores an add whose job id already exists. removeOnComplete and removeOnFail cap how much history the queue keeps, which matters when your whole database is a memory budget.

The worker is where the send policy lives:

typescript
import { Worker } from 'bullmq'

new Worker(
  'reminders',
  async (job) => {
    const { invoiceId, waId } = job.data
    if (await invoiceIsPaid(invoiceId)) return
    await sendTemplate(waId, 'payment_reminder', { invoiceId })
  },
  {
    connection: createConnection(),
    prefix: 'bot:prod',
    concurrency: 5,
    // Queue-wide, shared across every worker process.
    limiter: { max: 20, duration: 1000 },
  },
)

The limiter is the throughput guard. It is enforced across every worker connected to the queue rather than per process, so scaling to three workers does not silently triple your send rate. BullMQ's rate limiting guide covers the semantics.

Note that each Worker opens its own blocking connection, and a QueueEvents listener opens another. Three or four workers plus a queue client is a handful of connections, not one. A free database accepts up to 20 concurrent connections, which is enough for a small bot and not enough for an unbounded pool, so count them before you scale.

Per-recipient limits are a separate problem

The queue limiter protects Meta's throughput ceiling. It does nothing about messaging limits, which are per business and per unique recipient over a rolling window, and it does nothing about the more important limit, which is not annoying people.

A counter per recipient per window covers both:

typescript
const WINDOW_SECONDS = 60 * 60 * 24

export async function withinDailyCap(
  waId: string,
  cap: number,
): Promise<boolean> {
  const bucket = Math.floor(Date.now() / (WINDOW_SECONDS * 1000))
  const key = `send:${waId}:${bucket}`

  const result = await redis
    .multi()
    .incr(key)
    .expire(key, WINDOW_SECONDS)
    .exec()

  const count = Number(result?.[0]?.[1] ?? 0)
  return count <= cap
}

Be honest with yourself about what that is. It is a fixed window, aligned to a UTC boundary, so a recipient can receive cap messages just before the rollover and cap again just after. For a courtesy cap that is fine and I would ship it. For a hard rolling 24-hour guarantee you need a sorted set of send timestamps, trimmed by score on each check. The Redis rate limiter walkthrough builds the atomic version of both if you want the mechanics.

Check the cap in the worker, before the send, not at enqueue time. A reminder scheduled on Monday for Friday should be judged against Friday's budget.

Session storage if you are not on the Cloud API

Plenty of bots run on Baileys or whatsapp-web.js, which drive WhatsApp Web rather than the official Business API. Different trade-offs, and worth naming once: those are unofficial clients, and your account is the thing at risk if you abuse them.

They share one operational problem. Baileys' useMultiFileAuthState writes credentials and Signal keys to a folder on disk. On a container that gets replaced on every deploy, that folder is gone and your bot asks for a QR scan in production. whatsapp-web.js has the same issue and answers it with RemoteAuth, whose store interface is small enough to back with anything.

Valkey is a reasonable target for both, with two rules. Do not put a TTL on these keys: they are long-lived credentials, not session state, and expiring them logs the bot out. And treat the database as a secrets store, because that is what it now is. TLS on the wire, the password in a server-side environment variable, no key dumping into logs.

If you are on the official Cloud API, skip this section entirely. There is no session to persist, only an access token.

Keep staging out of production's keyspace

Two things, and both are cheap.

Prefix everything by environment. BullMQ takes a prefix, which is why bot:prod is on the queue and the worker above. Your own keys should carry the same marker. A staging worker pointed at the wrong URL is then visibly wrong instead of quietly consuming production jobs.

Then give staging its own database rather than its own key prefix in the same one. Two Valkey instances on the same account is the normal shape for this, and a wrong VALKEY_URL in staging can no longer flush production's keyspace. Valkey is also one of the engines that supports branching, so when you want staging to hold a real conversation you can copy the parent instead of building fixtures. That is the same mechanism described in databases for CI and AI agents, where a per-run branch replaces a shared test database.

Hibernation and an always-on bot

Idle Layerbase databases hibernate and wake on the next connection. On the free plan the idle window is 15 minutes; on paid plans it is 6 hours. Waking takes roughly 1 to 5 seconds, on the connection itself, with nothing to click. The Valkey port is SNI-routed, so the precondition is the servername from the connection snippet above.

For a bot, that has a simple implication. A BullMQ worker holds an open connection and keeps polling, so a bot with a worker running is not idle in the first place, and hibernation never enters the picture. Where it does show up is a bot deployed to a serverless runtime with no long-lived worker, where the first webhook after a quiet night pays the wake before it does anything else.

If you want a guarantee rather than a side effect of how your worker happens to be deployed, pin the database always-on. Pinning draws from your plan's pool and the database stops hibernating entirely. The always-on pool docs explain what draws what, and pricing has the plan-by-plan version.

One more lifecycle note specific to free: a free database left hibernated for 14 days is archived, which needs an explicit Restore rather than waking on connect. Paid databases are never auto-archived. A bot in production should not be on the free plan for that reason alone, quite apart from the connection cap.

The whole thing

The backend for a WhatsApp bot ends up being one Valkey instance and about two hundred lines. Conversation hashes with a sliding TTL. A SET NX claim per inbound message. A BullMQ queue with delayed jobs, a queue-wide limiter, and deterministic job ids. A counter per recipient. Separate databases for staging and production, prefixed so a mistake is obvious.

None of that is novel, which is the point. The reason it goes wrong is usually not the design, it is that half of it ends up in Postgres with a sweeper job attached, and the other half ends up in process memory that dies with the container.

Create a Valkey database and point VALKEY_URL at it. The free plan is enough to build the whole loop before you decide what to pay for.

FAQ

Do I need Redis for a WhatsApp bot, or can I use Postgres?

Postgres can do all of it. The question is what you have to operate afterwards. Conversation state, dedup keys, and send counters all want to expire, and expiry in SQL means an expires_at column plus a sweeper you write and monitor. In Redis or Valkey it is a property of the key. If you are already running a queue in Redis for scheduled sends, the rest of the state has an obvious home too.

How do I stop a retried WhatsApp webhook from processing twice?

Claim the message id with SET <key> 1 EX <ttl> NX before doing any work, and skip the message when the command returns null instead of OK. That is atomic, so two concurrent deliveries cannot both win. Give the key a TTL comfortably longer than the retry window and let it expire. Answering the webhook fast, by enqueueing rather than working inline, is what stops most retries from happening at all.

Can I schedule WhatsApp reminders with BullMQ?

Yes, and a delayed job is a better fit than a cron that scans a table. Add the job with delay set to the difference between the due time and now, give it a deterministic jobId so re-running your scheduler is a no-op, and set attempts with an exponential backoff for the sends that fail. Cap the queue's history with removeOnComplete and removeOnFail, since the whole queue lives in memory.

How do I respect WhatsApp messaging limits?

Two separate mechanisms. BullMQ's limiter option caps throughput queue-wide across every worker, which is what protects the API rate. Messaging limits, which are per unique recipient over a rolling window, need a counter keyed by recipient that you check in the worker just before the send rather than at enqueue time. A fixed-window INCR with a TTL is usually enough; a strict rolling window needs a sorted set of timestamps.

Where should Baileys or whatsapp-web.js store its session?

Anywhere that survives a deploy, which rules out the default local folder on an ephemeral container. Baileys' auth state and whatsapp-web.js's RemoteAuth store are both small enough interfaces to back with Valkey. Two rules if you do: no TTL on those keys, because they are credentials rather than session state, and treat the database as a secrets store, with TLS on the wire and the password in a server-side environment variable.

Will my bot's database go to sleep between messages?

Only if nothing is connected to it. A BullMQ worker holds an open connection and keeps polling it, so a bot with a running worker does not go idle. A serverless deployment with no long-lived worker can hibernate, and the next webhook pays roughly 1 to 5 seconds to wake it on the connection. Free databases idle out at 15 minutes and paid ones at 6 hours. Pin the database always-on if you want the guarantee rather than the side effect.

Should staging and production share one Valkey?

No. Give them separate databases. A key prefix protects you from collisions but not from a wrong VALKEY_URL, and the failure mode is a staging worker consuming production jobs or flushing a live keyspace. Prefix by environment as well, so a misconfiguration is visible in the keys themselves. When staging needs realistic data, branch the production database instead of writing fixtures.