Skip to main content

How to Create a WhatsApp Poll Bot How to Create a WhatsApp Poll Bot

How to Create a WhatsApp Poll Bot

whatsmeow-node lets you create polls, receive encrypted votes, decrypt them, and tally results — all programmatically. This is great for group bots that run votes, surveys, or decision-making flows.

Prerequisites

  • A paired whatsmeow-node session (How to Pair)
  • A group to test in (polls work best in group chats)

Step 1: Create a Poll

const resp = await client.sendPollCreation(
groupJid,
"Where should we eat?", // question
["Pizza", "Sushi", "Tacos"], // options
1, // max selectable (1 = single choice)
);

console.log(`Poll sent with ID: ${resp.id}`);

Set selectableCount to allow multiple selections:

// Multi-select poll — voters can pick up to 3
await client.sendPollCreation(
groupJid,
"Which topics should we cover?",
["Frontend", "Backend", "DevOps", "Mobile", "AI"],
3,
);

Step 2: Listen for Votes

Poll votes arrive as message events with encrypted vote data. Use decryptPollVote() to read them.

Vote hashes

decryptPollVote() returns selectedOptions as SHA-256 hashes (base64-encoded), not option text. You must hash your known options and compare to identify which options were selected.

import { createHash } from "node:crypto";

// Hash an option name the same way WhatsApp does
function hashOption(option: string): string {
return createHash("sha256").update(option).digest("base64");
}

client.on("message", async ({ info, message }) => {
const pollUpdate = message.pollUpdateMessage as Record<string, unknown> | undefined;
if (!pollUpdate) return;

// Get the poll message ID from the poll update
const pollKey = pollUpdate.pollCreationMessageKey as { id?: string } | undefined;
const pollId = pollKey?.id;
if (!pollId) return;

try {
const vote = await client.decryptPollVote(
info as unknown as Record<string, unknown>,
message,
);
const hashes = (vote.selectedOptions ?? []) as string[];
console.log(`${info.sender} voted (${hashes.length} options)`);
} catch (err) {
console.error("Failed to decrypt poll vote:", err);
}
});

Step 3: Track Results

Store polls with a hash→option lookup so you can resolve vote hashes back to option names:

import { createHash } from "node:crypto";

function hashOption(option: string): string {
return createHash("sha256").update(option).digest("base64");
}

type PollData = {
question: string;
hashToOption: Map<string, string>; // SHA-256 hash → option name
results: Map<string, string[]>; // option name → voter JIDs
};

const polls = new Map<string, PollData>();

// When creating a poll, store option hashes
async function createPoll(groupJid: string, question: string, options: string[]) {
const resp = await client.sendPollCreation(groupJid, question, options, 1);

const hashToOption = new Map<string, string>();
const results = new Map<string, string[]>();
for (const opt of options) {
hashToOption.set(hashOption(opt), opt);
results.set(opt, []);
}

polls.set(resp.id, { question, hashToOption, results });
return resp;
}

// When receiving a vote, resolve hashes and tally
function recordVote(pollId: string, voter: string, selectedHashes: string[]) {
const poll = polls.get(pollId);
if (!poll) return;

// Remove previous votes from this voter
for (const [, voters] of poll.results) {
const idx = voters.indexOf(voter);
if (idx !== -1) voters.splice(idx, 1);
}

// Resolve hashes to option names and add votes
for (const hash of selectedHashes) {
const option = poll.hashToOption.get(hash);
if (option) {
poll.results.get(option)?.push(voter);
}
}
}

Step 4: Announce Results

Send a summary with the command !results:

function formatResults(pollId: string): string | null {
const poll = polls.get(pollId);
if (!poll) return null;

let text = `Poll: ${poll.question}\n\n`;

const sorted = [...poll.results.entries()].sort((a, b) => b[1].length - a[1].length);

for (const [option, voters] of sorted) {
const bar = "█".repeat(voters.length);
text += `${option}: ${bar} ${voters.length}\n`;
}

const total = sorted.reduce((sum, [, v]) => sum + v.length, 0);
text += `\nTotal votes: ${total}`;

return text;
}

Complete Example

A group bot that handles !poll and !results commands:

import { createClient } from "@whatsmeow-node/whatsmeow-node";
import { createHash } from "node:crypto";

const client = createClient({ store: "session.db" });

function hashOption(option: string): string {
return createHash("sha256").update(option).digest("base64");
}

type PollData = {
question: string;
chat: string;
hashToOption: Map<string, string>;
results: Map<string, string[]>;
};

const polls = new Map<string, PollData>();
let lastPollId: string | null = null;

client.on("message", async ({ info, message }) => {
if (info.isFromMe) return;

// Handle poll votes
const pollUpdate = message.pollUpdateMessage as Record<string, unknown> | undefined;
if (pollUpdate) {
const pollKey = pollUpdate.pollCreationMessageKey as { id?: string } | undefined;
const pollId = pollKey?.id;
if (!pollId) return;

const poll = polls.get(pollId);
if (!poll) return;

try {
const vote = await client.decryptPollVote(
info as unknown as Record<string, unknown>,
message,
);
const selectedHashes = (vote.selectedOptions ?? []) as string[];

// Remove previous votes from this voter
for (const [, voters] of poll.results) {
const idx = voters.indexOf(info.sender);
if (idx !== -1) voters.splice(idx, 1);
}

// Resolve hashes to option names and tally
for (const hash of selectedHashes) {
const option = poll.hashToOption.get(hash);
if (option) {
poll.results.get(option)?.push(info.sender);
}
}
} catch {
// Ignore decryption errors for polls we don't track
}
return;
}

// Handle text commands
const text =
(message.conversation as string) ??
(message.extendedTextMessage as { text?: string } | undefined)?.text;
if (!text?.startsWith("!")) return;

// !poll Where should we eat? | Pizza | Sushi | Tacos
if (text.startsWith("!poll ")) {
const parts = text.slice(6).split("|").map((s) => s.trim());
if (parts.length < 3) {
await client.sendMessage(info.chat, {
conversation: "Usage: !poll Question | Option 1 | Option 2 | ...",
});
return;
}

const [question, ...options] = parts;
const resp = await client.sendPollCreation(info.chat, question, options, 1);

const hashToOption = new Map<string, string>();
const results = new Map<string, string[]>();
for (const opt of options) {
hashToOption.set(hashOption(opt), opt);
results.set(opt, []);
}
polls.set(resp.id, { question, chat: info.chat, hashToOption, results });
lastPollId = resp.id;

return;
}

// !results — show results for the last poll
if (text === "!results") {
if (!lastPollId || !polls.has(lastPollId)) {
await client.sendMessage(info.chat, {
conversation: "No active poll. Create one with !poll",
});
return;
}

const poll = polls.get(lastPollId)!;
let summary = `Poll: ${poll.question}\n\n`;
const sorted = [...poll.results.entries()].sort((a, b) => b[1].length - a[1].length);
for (const [option, voters] of sorted) {
summary += `${option}: ${"█".repeat(voters.length)} ${voters.length}\n`;
}
const total = sorted.reduce((sum, [, v]) => sum + v.length, 0);
summary += `\nTotal votes: ${total}`;

await client.sendMessage(info.chat, { conversation: summary });
return;
}
});

async function main() {
const { jid } = await client.init();
if (!jid) {
console.error("Not paired!");
process.exit(1);
}
await client.connect();
await client.sendPresence("available");
console.log("Poll bot is online! Commands: !poll, !results");

process.on("SIGINT", async () => {
await client.sendPresence("unavailable");
await client.disconnect();
client.close();
process.exit(0);
});
}

main().catch(console.error);

Common Pitfalls

Poll votes are encrypted

You must call decryptPollVote() to read votes. The raw pollUpdateMessage contains encrypted data that's unreadable without decryption.

In-memory results

The example stores poll data in a Map that's lost on restart. For production, persist polls and votes in a database.

Echo loops

Always check info.isFromMe. The bot creates polls that trigger message events — without this check, it could react to its own polls.