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
| Configuration | Inbound Effect | Outbound Effect |
|---|---|---|
"allowFrom": ["+1234567890"] | Only +1234567890 can trigger bot | Bot can only send to +1234567890 |
"allowFrom": ["*"] | Anyone can trigger bot | Bot 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
๐ Related Errors
E_DELIVERY_FAILEDโ Generic delivery failure when WhatsApp API rejects the messageE_INVALID_TARGETโ Target number format is invalid (not E.164 compliant)E_INBOUND_NOT_AUTHORIZEDโ Inbound message rejected due to allowlist policyE_SESSION_NOT_READYโ WhatsApp session not established before outbound attemptE_ALLOWLIST_BLOCKEDโ Outbound target not in configured allowlist
Related GitHub Issues
- 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]