# Build the Redis backend for a WhatsApp bot

> Conversation state with TTLs, BullMQ queues for scheduled reminders, idempotency keys for retried webhooks, and per-recipient rate limits, all on one Redis-compatible Valkey.

Published: 2026-09-01
Canonical: https://layerbase.com/blog/whatsapp-bot-redis-backend

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](https://docs.bullmq.io/) 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](/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](/db/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](/db/redis) runs on the same plans and none of the code below changes. [Redis vs Valkey](/blog/redis-vs-valkey) has the longer argument.

Create one at [layerbase.com/create/valkey](/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](https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks) 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](https://docs.bullmq.io/guide/rate-limiting) 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](https://developers.facebook.com/docs/whatsapp/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](/blog/getting-started-with-redis) 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](https://github.com/WhiskeySockets/Baileys) or [whatsapp-web.js](https://github.com/pedroslopez/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](/blog/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](/docs/plans/always-on) explain what draws what, and [pricing](/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](/create/valkey) 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.
