---
name: baileys-whatsapp
description: Hard-won lessons for building WhatsApp integrations with Baileys (Node.js). Covers session management, message history, group metadata, participant resolution, and critical pitfalls that cause silent data loss. Use this skill when building any WhatsApp bot or integration with @whiskeysockets/baileys.
license: MIT
compatibility: Claude Code, any LLM-based coding agent
metadata:
  author: Paulo Silveira
  version: "1.0"
  library: "@whiskeysockets/baileys"
  library_version: "7.x"
---

# baileys-whatsapp — WhatsApp Integration with Baileys

Practical guide for building WhatsApp bots and integrations using [@whiskeysockets/baileys](https://github.com/WhiskeySockets/Baileys) v7. Based on production experience building a content orchestration platform that captures, processes, and redistributes WhatsApp group conversations.

## Critical: Use Baileys v7

Baileys v6.x uses an older protocol that WhatsApp servers now reject. QR scan will hang indefinitely. Always use v7+.

```bash
npm install @whiskeysockets/baileys@latest
```

## Session Management

### Initial Connection (QR Scan)

```typescript
import makeWASocket, { useMultiFileAuthState, DisconnectReason } from '@whiskeysockets/baileys';

const { state, saveCreds } = await useMultiFileAuthState('./auth');

const sock = makeWASocket({
  auth: state,
  printQRInTerminal: true,
});

sock.ev.on('creds.update', saveCreds);
```

### Reconnection After Code 515

After the initial QR scan, WhatsApp sends a **code 515** (stream restart). This is normal. Your bot must auto-reconnect with saved credentials:

```typescript
sock.ev.on('connection.update', (update) => {
  const { connection, lastDisconnect } = update;
  if (connection === 'close') {
    const code = (lastDisconnect?.error as any)?.output?.statusCode;
    if (code !== DisconnectReason.loggedOut) {
      // Reconnect with saved creds — do NOT re-scan QR
      makeWASocket({ auth: state, /* ... */ });
    }
  }
});
```

### Portable Sessions

For deployment across machines (local dev → CI → production), serialize `creds.json` as base64:

```bash
# Export
base64 -i auth/creds.json | tr -d '\n' > session.b64

# Import (in your entrypoint)
echo "$WHATSAPP_SESSION" | base64 -d > auth/creds.json
```

Only `creds.json` is needed for reconnection. The `app-state-sync-*` files are rebuilt automatically.

## Message History

### History Sync (Initial Link Only)

`messaging-history.set` fires **only** on the first device link (QR scan). It provides ~3 days of message history. Subsequent reconnections do NOT trigger history sync.

```typescript
sock.ev.on('messaging-history.set', ({ messages, chats, isLatest }) => {
  // Save immediately — WhatsApp considers these "delivered" and won't resend
  saveMessages(messages);
});
```

### On-Demand History (fetchMessageHistory)

For fetching older messages or messages missed during disconnection:

```typescript
await sock.fetchMessageHistory(
  count,
  { remoteJid: groupJid, id: lastKnownMessageId, fromMe: false },
  timestampInSeconds  // ← SECONDS, not milliseconds
);
```

**Critical pitfalls:**

1. **Timestamp unit**: The `oldestMsgTimestamp` field internally uses milliseconds (`oldestMsgTimestampMs`), but the API parameter expects **seconds**. Passing milliseconds silently returns nothing.

2. **Message key `id` must be real**: An empty string `id: ''` is accepted by the server but returns zero messages. Use an actual message ID from a previous sync.

3. **Response event**: Messages arrive via `messaging-history.set`, NOT `messages.upsert`. Listening on the wrong event means you receive nothing.

4. **No retry**: WhatsApp considers on-demand history as delivered. If your process crashes between receiving and persisting, those messages are gone. **Save immediately, batch by batch.**

5. **Rate limiting**: Requesting history for multiple groups in rapid succession may silently fail for some groups. Add delays between group requests.

### Real-Time Messages

`messages.upsert` captures messages that arrive while your bot is connected. It does not replay missed messages from disconnection periods.

```typescript
sock.ev.on('messages.upsert', ({ messages }) => {
  for (const msg of messages) {
    // msg.key.remoteJid = group or chat JID
    // msg.message = message content
    // msg.participant = sender (in groups) — TOP-LEVEL on WAMessage
  }
});
```

**Note**: `msg.participant` is a top-level field on WAMessage, NOT `msg.key.participant`.

## Group Metadata and Participants

### Listing Groups

```typescript
const groups = await sock.groupFetchAllParticipating();

for (const [jid, metadata] of Object.entries(groups)) {
  console.log(metadata.subject, jid, metadata.participants.length);
}
```

**Caveat**: After a re-link, `groupFetchAllParticipating()` may not return all groups. Keep a list of known group JIDs as fallback.

### Participant Identity Resolution

WhatsApp uses **LID format** (`xxxxx@lid`) for participants in groups. These are internal IDs, not phone numbers. To resolve them to names:

1. **Group metadata**: `participant.phoneNumber` may be available via `groupFetchAllParticipating()`
2. **History sync contacts**: The initial QR scan provides a contacts list with phone↔LID mappings
3. **lid-to-phone mapping**: Build from history sync data, then cross-reference with your user database

**Recommended approach**: Build a flat lookup map where every possible key (phone number, LID, display name) points to the canonical user identity. One lookup, no chains.

```typescript
// Build once at startup
const userMap = new Map<string, string>();

// From your user database: register all known keys
for (const user of users) {
  userMap.set(user.phone, user.displayName);
  userMap.set(user.name, user.displayName);
}

// From LID→phone mappings: register LIDs pointing to display names
for (const [lid, phone] of lidToPhoneEntries) {
  const name = userMap.get(phone);
  if (name) userMap.set(lid, name);
  else userMap.set(lid, phone); // best effort
}

// Resolve any sender in one lookup
function resolveSender(sender: string): string {
  return userMap.get(sender) ?? sender;
}
```

## Data Persistence Best Practices

### Save Atomically

Write-then-rename prevents corruption if the process crashes mid-write:

```typescript
import { writeFileSync, renameSync } from 'node:fs';

function atomicWrite(path: string, data: string) {
  const tmp = path + '.tmp';
  writeFileSync(tmp, data);
  renameSync(tmp, path);
}
```

### Never Hardcode Timestamped Filenames

If your script writes files like `raw-messages-2026-03-19-1314.json`, always find the latest file dynamically:

```typescript
const files = readdirSync(dir)
  .filter(f => f.startsWith('raw-messages-') && f.endsWith('.json'))
  .sort()
  .reverse();
const latest = files[0]; // most recent by timestamp in filename
```

### Dump Overwrite Risk

A dump file keyed by date (`dump-2026-03-19.json`) gets overwritten if you run multiple times in one day. Intermediate runs may produce empty or partial dumps that overwrite good data. Use timestamped files (`raw-messages-{date}-{time}.json`) as the source of truth, and derive parsed dumps from the latest raw file.

## Thread Detection in Group Chats

For group conversations, detect conversation threads using gap-based detection:

```typescript
function detectThreads(messages: Message[], gapMinutes = 30): Thread[] {
  const threads: Thread[] = [];
  let current: Message[] = [messages[0]];

  for (let i = 1; i < messages.length; i++) {
    const gap = messages[i].timestamp - messages[i - 1].timestamp;
    if (gap > gapMinutes * 60 * 1000) {
      threads.push(buildThread(current));
      current = [];
    }
    current.push(messages[i]);
  }
  if (current.length) threads.push(buildThread(current));
  return threads;
}
```

A 30-minute gap works well for most group conversations. Smaller gaps (10-15min) are better for fast-moving groups.

## Common Pitfalls Summary

| Pitfall | Impact | Fix |
|---------|--------|-----|
| Using Baileys v6 | QR scan hangs forever | Upgrade to v7+ |
| Not handling code 515 | Bot disconnects permanently | Auto-reconnect with saved creds |
| Wrong timestamp unit in fetchMessageHistory | Silent empty response | Use seconds, not milliseconds |
| Empty message ID in fetchMessageHistory | Silent empty response | Use real message ID from previous sync |
| Listening on `messages.upsert` for history | Never receives history messages | Listen on `messaging-history.set` |
| Not saving messages immediately | Data loss on crash | Save batch by batch, never at the end |
| Hardcoding timestamped filenames | Script reads stale data | Always find latest file dynamically |
| Overwriting daily dump files | Good data replaced by partial run | Use timestamped raw files as source of truth |
| Calling `groupFetchAllParticipating()` after re-link | Missing groups | Keep known JID list as fallback |
| Rapid sequential history requests for multiple groups | Some groups silently fail | Add delays between requests |

## WhatsApp Business Considerations

- WhatsApp Business accounts do not have saved contacts. Names come from group metadata and history sync.
- The sync window default (120s) may be too short for full history sync. 240-300s works better.
- QR re-scan is the "nuclear reset" — provides ~3 days of complete history. Useful when all else fails.
