April 20, 2026

WhatsApp Outbound Send Failure with Allowlist Policy

When using WhatsApp dmPolicy 'allowlist', outbound messages fail with 'Delivering to WhatsApp requires target' because sendTo targets must be in the same allowFrom list that controls inbound access.

๐Ÿ” Symptoms

Primary Error Manifestation

When attempting to send a WhatsApp message using the message tool to a contact that is not in the allowFrom list:

Error: Delivering to WhatsApp requires target <E.164|group JID>
    at resolveOutboundTarget (src/whatsapp/resolve-outbound-target.ts:XX)
    at sendWhatsAppMessage (src/whatsapp/sender.ts:XX)

Configuration Context

The issue manifests when the following configuration is in place:

json { “channels”: { “whatsapp”: { “dmPolicy”: “allowlist”, “allowFrom”: ["+1234567890"] } } }

CLI Diagnostic Commands

bash

Attempt to send a message to an unlisted contact

$ openclaw tools call message ‘{“to”: “+0987654321”, “body”: “Hello”}’

Expected: Message sent successfully

Actual: Error - Delivering to WhatsApp requires target <E.164|group JID>

Secondary Symptom: Confusing Security Model

Users observe that adding a contact to allowFrom has dual effects:

  • The contact can now receive outbound messages from the bot
  • The contact can also send inbound messages that trigger the bot

This violates the principle of least privilege and creates security confusion.

๐Ÿง  Root Cause

Architectural Analysis

The root cause lies in the shared data dependency between inbound and outbound access control logic.

File: src/whatsapp/resolve-outbound-target.ts

typescript export async function resolveOutboundTarget( normalizedTo: string, allowList: string[] ): Promise {

const hasWildcard = allowList.includes("*");

if (hasWildcard || allowList.length === 0) { return { ok: true, to: normalizedTo }; }

if (allowList.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; }

return { ok: false, error: Delivering to WhatsApp requires target <E.164|group JID>, }; }

The problem: This function receives the allowFrom array as the allowList parameter, meaning outbound permission is gatekept by inbound configuration.

File: src/web/inbound/access-control.ts

typescript export function checkInboundAccess( from: string, allowFrom: string[] ): InboundAccessResult { const hasWildcard = allowFrom.includes("*"); const isAllowed = hasWildcard || allowFrom.includes(from);

return { allowed: isAllowed, reason: isAllowed ? “allowed” : “inbound_not_authorized” }; }

The Shared Control Point Problem

ConfigurationInbound EffectOutbound Effect
"allowFrom": ["+1234567890"]Only +1234567890 can trigger botBot can only send to +1234567890
"allowFrom": ["*"]Anyone can trigger botBot can send to anyone

Design Violation

The current implementation violates the separation of concerns principle. The allowFrom field was designed for inbound access control but is being reused for outbound authorization, creating an unintended coupling.

๐Ÿ› ๏ธ Step-by-Step Fix

Phase 1: Add Configuration Type

File: src/config/types.whatsapp.ts

Before: typescript export interface WhatsAppConfig { dmPolicy: “allowlist” | “open”; allowFrom: string[]; // … other fields }

After: typescript export interface WhatsAppConfig { dmPolicy: “allowlist” | “open”; allowFrom: string[]; allowSendTo?: string[]; // NEW: Separate outbound allowlist // … other fields }

Phase 2: Update Outbound Resolution Logic

File: src/whatsapp/resolve-outbound-target.ts

Before: typescript export async function resolveOutboundTarget( normalizedTo: string, allowList: string[] ): Promise {

const hasWildcard = allowList.includes("*");

if (hasWildcard || allowList.length === 0) { return { ok: true, to: normalizedTo }; }

if (allowList.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; }

return { ok: false, error: Delivering to WhatsApp requires target <E.164|group JID>, }; }

After: typescript export async function resolveOutboundTarget( normalizedTo: string, sendToList: string[] | undefined, inboundAllowFrom: string[] ): Promise {

// If sendTo is explicitly configured, use it if (sendToList !== undefined) { const hasWildcard = sendToList.includes("*");

if (hasWildcard || sendToList.length === 0) {
  return { ok: true, to: normalizedTo };
}

if (sendToList.includes(normalizedTo)) {
  return { ok: true, to: normalizedTo };
}

return {
  ok: false,
  error: `Target ${normalizedTo} is not in allowSendTo list`,
};

}

// Fallback to legacy behavior (use inbound allowFrom for outbound) const hasWildcard = inboundAllowFrom.includes("*");

if (hasWildcard || inboundAllowFrom.length === 0) { return { ok: true, to: normalizedTo }; }

if (inboundAllowFrom.includes(normalizedTo)) { return { ok: true, to: normalizedTo }; }

return { ok: false, error: Delivering to WhatsApp requires target <E.164|group JID>, }; }

Phase 3: Update Caller Sites

File: src/whatsapp/sender.ts (or wherever resolveOutboundTarget is called)

Before: typescript const target = await resolveOutboundTarget( normalizedTo, config.allowFrom // Passing inbound list for outbound check );

After: typescript const target = await resolveOutboundTarget( normalizedTo, config.allowSendTo, // Use dedicated outbound list config.allowFrom // Pass for legacy fallback );

Phase 4: Configuration Example

Recommended production configuration:

json { “channels”: { “whatsapp”: { “dmPolicy”: “allowlist”, “allowFrom”: ["+1234567890", “+1111111111”], “allowSendTo”: ["*"] } } }

Strict outbound configuration:

json { “channels”: { “whatsapp”: { “dmPolicy”: “allowlist”, “allowFrom”: ["+1234567890"], “allowSendTo”: [ “+0987654321”, “+1122334455”, “[email protected]” ] } } }

๐Ÿงช Verification

Test Case 1: Outbound to Allowlisted SendTo

bash

Configuration

“allowSendTo”: ["+0987654321"]

$ openclaw tools call message ‘{“to”: “+0987654321”, “body”: “Test”}’

Expected Output: json { “ok”: true, “messageId”: “wamid.xxx…”, “timestamp”: “2024-01-15T10:30:00Z” }

Test Case 2: Outbound to Unlisted SendTo

bash

Configuration

“allowSendTo”: ["+0987654321"]

$ openclaw tools call message ‘{“to”: “+5555555555”, “body”: “Test”}’

Expected Output: json { “ok”: false, “error”: “Target +5555555555 is not in allowSendTo list” }

Test Case 3: Inbound from Allowlisted Sender

bash

Configuration

“allowFrom”: ["+0987654321"]

“allowSendTo”: ["*"]

Send message FROM +0987654321 TO the bot

Expected Behavior: Message is processed and triggers bot response.

Test Case 4: Inbound from Unlisted Sender

bash

Configuration

“allowFrom”: ["+0987654321"]

Send message FROM +5555555555 TO the bot

Expected Behavior: Message is rejected with inbound access control error.

Test Case 5: Wildcard SendTo

bash

Configuration

“allowSendTo”: ["*"]

$ openclaw tools call message ‘{“to”: “+anyvalidnumber”, “body”: “Test”}’

Expected Output: Message is sent successfully.

Verification Script

typescript // test/whatsapp-outbound-permissions.test.ts

import { resolveOutboundTarget } from “../src/whatsapp/resolve-outbound-target”;

describe(“resolveOutboundTarget”, () => { test(“allows when target is in sendTo list”, async () => { const result = await resolveOutboundTarget( “+0987654321”, ["+0987654321", “+1122334455”], ["+1234567890"] ); expect(result.ok).toBe(true); });

test(“blocks when target is not in sendTo list”, async () => { const result = await resolveOutboundTarget( “+5555555555”, ["+0987654321"], ["+1234567890"] ); expect(result.ok).toBe(false); expect(result.error).toContain(“not in allowSendTo list”); });

test(“allows wildcard sendTo”, async () => { const result = await resolveOutboundTarget( “+5555555555”, ["*"], ["+1234567890"] ); expect(result.ok).toBe(true); });

test(“falls back to allowFrom when sendTo is undefined”, async () => { const result = await resolveOutboundTarget( “+1234567890”, undefined, // sendTo not configured ["+1234567890"] ); expect(result.ok).toBe(true); }); });

โš ๏ธ Common Pitfalls

Pitfall 1: Incorrect E.164 Format

WhatsApp requires numbers in E.164 format (e.g., +1234567890). Using formats without the leading + will cause silent failures.

bash

WRONG

$ openclaw tools call message ‘{“to”: “1234567890”, “body”: “Test”}’

CORRECT

$ openclaw tools call message ‘{“to”: “+1234567890”, “body”: “Test”}’

Pitfall 2: Group JID vs Phone Number

Group IDs use a different format than phone numbers. Ensure correct JID syntax:

json { “allowSendTo”: [ “+1234567890”, // Phone number “[email protected]” // Group JID ] }

Pitfall 3: Empty Array vs Undefined

An empty allowSendTo: [] array is treated differently than allowSendTo being undefined:

  • "allowSendTo": [] โ€” Blocks all outbound messages
  • "allowSendTo": undefined โ€” Falls back to legacy allowFrom behavior

Pitfall 4: Docker Environment Variable Mapping

When using environment variables for configuration:

bash

WRONG - This creates a string, not an array

WHATSAPP_ALLOW_SEND_TO=+1234567890,+0987654321

CORRECT - Use JSON string for arrays

WHATSAPP_ALLOW_SEND_TO=["+1234567890","+0987654321"]

Pitfall 5: Caching Issues

After updating configuration, ensure the running process reloads:

bash

Restart the OpenClaw service

$ systemctl restart openclaw

Or for Docker

$ docker-compose down && docker-compose up -d

Pitfall 6: Migration from Legacy Configuration

Existing configurations without allowSendTo should continue working via the fallback mechanism. However, test thoroughly:

typescript // Verify fallback still works const sendTo = config.allowSendTo ?? config.allowFrom;

Pitfall 7: Wildcard Security Implications

Setting "allowSendTo": ["*"] allows sending to any valid WhatsApp number. Consider:

  • Rate limiting on the message tool
  • Additional application-level authorization
  • Logging all outbound message attempts
  • E_DELIVERY_FAILED โ€” Generic delivery failure when WhatsApp API rejects the message
  • E_INVALID_TARGET โ€” Target number format is invalid (not E.164 compliant)
  • E_INBOUND_NOT_AUTHORIZED โ€” Inbound message rejected due to allowlist policy
  • E_SESSION_NOT_READY โ€” WhatsApp session not established before outbound attempt
  • E_ALLOWLIST_BLOCKED โ€” Outbound target not in configured allowlist
  • Issue #XXX โ€” WhatsApp dmPolicy allowlist blocks legitimate outbound messages
  • Issue #YYY โ€” Request: Separate inbound/outbound access control for WhatsApp
  • Issue #ZZZ โ€” Documentation: WhatsApp channel security model unclear

Configuration Reference

  • channels.whatsapp.dmPolicy โ€” Controls inbound access mode (`"allowlist"` | `"open"`)
  • channels.whatsapp.allowFrom โ€” Inbound allowlist (phone numbers and group JIDs)
  • channels.whatsapp.allowSendTo โ€” Outbound allowlist (phone numbers and group JIDs) [NEW]

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.