Skip to main content
The Polls struct provides methods for creating polls, casting votes, and decrypting poll results. Votes are end-to-end encrypted using AES-256-GCM with HKDF-SHA256 key derivation.

Access

Access poll operations through the client:
let polls = client.polls();

Methods

create

Create a new poll in a chat.
pub async fn create(
    &self,
    to: &Jid,
    name: &str,
    options: &[String],
    selectable_count: u32,
) -> Result<(SendResult, Vec<u8>), PollError>
to
&Jid
required
Recipient JID. Can be a direct message or group chat.
name
&str
required
Poll question or title.
options
&[String]
required
List of poll options. Must have between 2 and 12 entries, with no duplicates.
selectable_count
u32
required
Maximum number of options a voter can select. Must be between 1 and the number of options. When set to 1, creates a single-select poll (uses poll_creation_message_v3). When greater than 1, creates a multi-select poll (uses poll_creation_message).
(send_result, message_secret)
(SendResult, Vec<u8>)
A tuple containing a SendResult (with message_id, to, and message_key()) and a 32-byte random secret. The message_secret is required to decrypt votes — store it securely. See SendResult for details.
Example:
let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?;

let options = vec![
    "Yes".to_string(),
    "No".to_string(),
    "Maybe".to_string(),
];

let (result, secret) = client
    .polls()
    .create(&chat_jid, "Do you agree?", &options, 1)
    .await?;

println!("Poll sent with ID: {}", result.message_id);

create_quiz

Create a quiz poll — a single-select poll with exactly one correct answer. Quizzes are inherently single-select (WhatsApp Web forces selectableOptionsCount = 1); the chosen option is sent as the poll’s correctAnswer and poll_type is set to QUIZ.
pub async fn create_quiz(
    &self,
    to: &Jid,
    name: &str,
    options: &[String],
    correct_index: usize,
) -> Result<(SendResult, Vec<u8>), PollError>
to
&Jid
required
Recipient JID. Can be a direct message or group chat.
name
&str
required
Quiz question or title.
options
&[String]
required
List of answer options (2–12 entries, no duplicates).
correct_index
usize
required
0-based index into options of the correct answer. Out-of-range indices return an error.
(send_result, message_secret)
(SendResult, Vec<u8>)
Same shape as create: a SendResult plus the 32-byte secret needed to decrypt votes. Votes are cast, decrypted, and aggregated exactly like a regular single-select poll.
Example:
let chat_jid: Jid = "15551234567@s.whatsapp.net".parse()?;

let options = vec![
    "Paris".to_string(),
    "London".to_string(),
    "Berlin".to_string(),
];

// The correct answer is "Paris" (index 0).
let (result, secret) = client
    .polls()
    .create_quiz(&chat_jid, "Capital of France?", &options, 0)
    .await?;

vote

Cast a vote on an existing poll.
pub async fn vote(
    &self,
    chat_jid: &Jid,
    poll_msg_id: &str,
    poll_creator_jid: &Jid,
    message_secret: &[u8],
    option_names: &[String],
) -> Result<SendResult, PollError>
chat_jid
&Jid
required
Chat JID where the poll was sent.
poll_msg_id
&str
required
Message ID of the original poll creation message.
poll_creator_jid
&Jid
required
JID of the user who created the poll.
message_secret
&[u8]
required
The 32-byte secret returned by create. Required for vote encryption.
option_names
&[String]
required
Names of the selected options. Pass an empty slice to clear your vote.
SendResult
SendResult
Result containing the message ID and recipient JID. See SendResult.
Example:
let selected = vec!["Yes".to_string()];

let vote_result = client
    .polls()
    .vote(
        &chat_jid,
        &poll_msg_id,
        &poll_creator_jid,
        &message_secret,
        &selected,
    )
    .await?;

println!("Vote sent with ID: {}", vote_result.message_id);

decrypt_vote

Decrypt an encrypted poll vote.
pub async fn decrypt_vote(
    &self,
    enc_payload: &[u8],
    enc_iv: &[u8],
    message_secret: &[u8],
    poll_msg_id: &str,
    poll_creator_jid: &Jid,
    voter_jid: &Jid,
) -> Result<Vec<Vec<u8>>, PollError>
Since v0.6 this is an async instance method (&self) rather than a static helper. The client uses its LID↔PN cache to resolve the voter against the poll creator’s namespace and falls back to the opposite namespace if the first attempt fails — both encrypt and decrypt now address the vote with the same JID family as the poll itself, so votes authored across LID/PN migrations still decrypt. Update old call sites: Polls::decrypt_vote(...)client.polls().decrypt_vote(...).await?.
enc_payload
&[u8]
required
Encrypted vote payload (from the PollUpdateMessage).
enc_iv
&[u8]
required
12-byte initialization vector from the encrypted vote.
message_secret
&[u8]
required
The 32-byte secret from poll creation.
poll_msg_id
&str
required
Message ID of the original poll.
poll_creator_jid
&Jid
required
JID of the poll creator (AD suffix is stripped automatically).
voter_jid
&Jid
required
JID of the voter (AD suffix is stripped automatically).
selected_hashes
Vec<Vec<u8>>
List of 32-byte SHA-256 hashes of the selected option names.
Example:
use whatsapp_rust::features::Polls;

let selected_hashes = client
    .polls()
    .decrypt_vote(
        &enc_payload,
        &enc_iv,
        &message_secret,
        "3EB0ABC123",
        &poll_creator_jid,
        &voter_jid,
    )
    .await?;

aggregate_votes

Decrypt multiple votes and tally results per option. Later votes from the same voter replace earlier ones (last-vote-wins). Voters are deduped by their canonical (LID-preferred) identity, so a voter who re-votes under the opposite namespace counts once instead of producing a duplicate row.
pub async fn aggregate_votes(
    &self,
    poll_options: &[String],
    votes: &[(&Jid, &[u8], &[u8])],
    message_secret: &[u8],
    poll_msg_id: &str,
    poll_creator_jid: &Jid,
) -> Result<Vec<PollOptionResult>, PollError>
Also async + &self since v0.6 for the same LID↔PN reasons. Migrate Polls::aggregate_votes(...)client.polls().aggregate_votes(...).await?.
poll_options
&[String]
required
The original poll option names (in order).
votes
&[(&Jid, &[u8], &[u8])]
required
Slice of (voter_jid, enc_payload, enc_iv) tuples, ordered oldest-first.
message_secret
&[u8]
required
The 32-byte secret from poll creation.
poll_msg_id
&str
required
Message ID of the original poll.
poll_creator_jid
&Jid
required
JID of the poll creator.
results
Vec<PollOptionResult>
One entry per poll option, each containing the option name and a list of voter JID strings.
Example:
use whatsapp_rust::features::{Polls, PollOptionResult};

let options = vec!["Yes".to_string(), "No".to_string()];

let results = client
    .polls()
    .aggregate_votes(
        &options,
        &votes,
        &message_secret,
        &poll_msg_id,
        &poll_creator_jid,
    )
    .await?;

for result in &results {
    println!("{}: {} votes", result.name, result.voters.len());
}
Votes that fail to decrypt are logged as warnings and skipped. An empty selection from a voter clears their previous vote.

Types

PollOptionResult

Aggregated result for a single poll option.
pub struct PollOptionResult {
    pub name: String,
    pub voters: Vec<String>,
}
FieldTypeDescription
nameStringThe option name
votersVec<String>JID strings of voters who selected this option

Low-level utilities

The wacore::poll module exposes the cryptographic primitives used internally:
FunctionDescription
compute_option_hash(name: &str) -> [u8; 32]SHA-256 hash of an option name
derive_vote_encryption_key(secret, stanza_id, creator, voter) -> [u8; 32]HKDF-SHA256 key derivation
encrypt_poll_vote(hashes, key, stanza_id, voter) -> (Vec<u8>, [u8; 12])AES-256-GCM encryption
decrypt_poll_vote(payload, iv, key, stanza_id, voter) -> Vec<Vec<u8>>AES-256-GCM decryption
decrypt_poll_vote_with_secret(payload, iv, secret, stanza_id, creator, voter) -> Vec<Vec<u8>>Single-namespace convenience: derives the vote key from the poll secret and decrypts
decrypt_poll_vote_with_fallback(payload, iv, secret, stanza_id, creator, voter, alt_creator, alt_voter) -> Vec<Vec<u8>>Tries the canonical (creator, voter) namespace first and falls back to (alt_creator, alt_voter) so LID/PN-mixed votes still decrypt

Decrypting secret-encrypted envelopes

WhatsApp wraps message edits, poll edits, poll add-option, and event edits in a single secret_encrypted_message envelope keyed by a per-use-case secret derived from the parent message’s message_secret. v0.6 added support for decrypting all four kinds.
The client now decrypts these inline on receive. Since v0.6 the receive path automatically resolves the parent secret from the MsgSecretStore, decrypts the envelope, and dispatches the result as a normal Event::Message carrying the decrypted payload — so most apps never call the helpers below. The client captures MessageContextInfo.message_secret from inbound messages and seeds secrets from history-sync, so edits decrypt as long as the parent secret is known. Edits whose secret can’t be found are skipped silently (no undecryptable event); the raw envelope stays on the message. The manual helpers remain for custom pipelines or when you store secrets yourself.
use whatsapp_rust::features::message_edit::{
    self, SecretEncKind, SecretEncrypted,
};

if let Some(envelope) = message_edit::extract_secret_encrypted(&message)? {
    let SecretEncrypted { kind, target_message_id, sender, .. } = &envelope;
    let parent_secret: [u8; 32] = load_message_secret(target_message_id)?;
    let plaintext: wa::Message = message_edit::decrypt_secret_encrypted_with_fallback(
        &envelope,
        &parent_secret,
        envelope.original_sender_jid().as_ref(),
    )?;

    match kind {
        SecretEncKind::MessageEdit   => handle_message_edit(plaintext),
        SecretEncKind::PollEdit      => handle_poll_edit(plaintext),
        SecretEncKind::PollAddOption => handle_poll_add_option(plaintext),
        SecretEncKind::EventEdit     => handle_event_edit(plaintext),
    }
}
FunctionDescription
extract_secret_encrypted(msg) -> Option<SecretEncrypted<'_>>Pulls envelope metadata (kind, target id, sender, ciphertext, IV) out of any secret_encrypted_message wrapper. Replaces the v0.5 extract_envelope helper (which now delegates to this).
decrypt_secret_encrypted(envelope, parent_secret) -> wa::MessageEmpty-AAD AES-GCM decrypt against the canonical addressing of the envelope.
decrypt_secret_encrypted_with_fallback(envelope, parent_secret, alt_sender) -> wa::MessageSame as above but retries with alt_sender (typically the LID↔PN twin) so edits authored across the migration still decrypt.
SecretEncrypted::original_sender_jid() -> Option<Jid>Returns the JID the parent message was addressed to — pass it as the fallback to recover edits whose sender migrated namespace between the original message and the edit.
SecretEncrypted::original_sender_for_dispatch(is_from_me, envelope_sender, my_jid) -> Result<Jid>Dispatch-time resolver. For MessageEdit it returns the envelope sender (an author edits their own message, so the secret target is written in the editor’s frame); for poll/event kinds it falls back to the target-key resolution. Use this — not original_sender_jid — when decrypting incoming peer edits.
rewrap_as_legacy_edit(plaintext)Re-emits a decrypted MessageEdit envelope as the legacy protocol_message { MESSAGE_EDIT } shape for consumers that already handled the old form.
For incoming peer message edits (edits authored on a contact’s other device, or self-synced from your own linked device), the parent-author resolution must use the envelope sender rather than the target key — otherwise the parent author resolves to the receiver and the GCM tag fails. The client handles this internally via original_sender_for_dispatch; replicate it if you decrypt edits yourself. EncryptedEdit exposes the same method for the MessageEdit-only façade.
pub enum SecretEncKind {
    MessageEdit,    // edited text of a previously sent message
    PollEdit,       // poll question / metadata change
    PollAddOption,  // new option appended to a multi-select poll
    EventEdit,      // event title / time change
}
The EncryptedEdit type from v0.5 still exists as a MessageEdit-specific façade for callers that only care about text edits; extract_envelope now returns SecretEncrypted and the older shape is reachable via the same struct.

Error handling

All five poll methods (create, create_quiz, vote, decrypt_vote, and aggregate_votes) return Result<T, PollError>:
#[non_exhaustive]
pub enum PollError {
    #[error(transparent)]
    Send(#[from] SendError),
    #[error("invalid poll: {0}")]
    InvalidPoll(String),
    #[error("client is not logged in")]
    NotLoggedIn,
    #[error("poll vote crypto failed: {0}")]
    Crypto(#[source] anyhow::Error),
}
use whatsapp_rust::PollError;

match client.polls().create(jid, "Question?", &options, 1).await {
    Ok((result, secret)) => println!("Poll created: {}", result.message_id),
    Err(PollError::InvalidPoll(msg)) => eprintln!("Invalid poll: {}", msg),
    Err(PollError::Send(e)) => eprintln!("Send failed: {}", e),
    Err(e) => eprintln!("Error: {}", e),
}

See also