Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ QRCODE_LIMIT=30
# Color of the QRCode on base64
QRCODE_COLOR='#175197'

# Abuse safety guardrails
ABUSE_SAFETY_WHATSAPP_NUMBERS_MAX_BATCH_SIZE=50
ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_SIZE=10
ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_INTERVAL_MS=1000

# Typebot - Environment variables
TYPEBOT_ENABLED=false
# old | latest
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ RabbitMQ, Amazon SQS, NATS, Pusher and WebSocket for events. Configurable per in
### Media handling
Local storage or S3/MinIO. Automatic media download from WhatsApp. Optional audio transcription via OpenAI.

### Responsible messaging
Evolution API includes abuse-safety guardrails to reduce accidental bursts on
sensitive endpoints such as `/chat/whatsappNumbers`. These controls are not an
anti-ban feature and do not guarantee delivery or account safety.

See [Responsible messaging and deliverability](./docs/responsible-messaging.md)
for configuration details, known limitations, and related community reports.

---

## Documentation
Expand Down
64 changes: 64 additions & 0 deletions docs/responsible-messaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Responsible Messaging And Deliverability

Evolution API is a messaging infrastructure project. Operators are responsible
for following WhatsApp and Meta policies, collecting opt-in consent, respecting
opt-out requests, and avoiding unsolicited or high-volume messaging.

This guide documents guardrails that reduce accidental bursts and make risky
usage easier to identify. They are not anti-ban features, do not bypass platform
enforcement, and do not guarantee message delivery.

## WhatsApp Number Checks

The `/chat/whatsappNumbers/{instance}` endpoint can call WhatsApp Web through
Baileys when a number is not already cached. Large uncached batches create a
burst of platform checks from a single instance.

Evolution API limits and chunks these checks by default:

```env
ABUSE_SAFETY_WHATSAPP_NUMBERS_MAX_BATCH_SIZE=50
ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_SIZE=10
ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_INTERVAL_MS=1000
```

When a request exceeds `ABUSE_SAFETY_WHATSAPP_NUMBERS_MAX_BATCH_SIZE`, the API
returns `429 Too Many Requests` with a `Retry-After` header and a structured
response that includes the configured limit.

The chunk interval only adds backpressure between direct Baileys checks. Cached
numbers, groups, broadcasts, and newsletters do not require the same Baileys
lookup path.

## Responsible Operation

- Use WhatsApp Business Platform / Cloud API for production business messaging
when possible.
- Send messages only to contacts who have explicitly opted in.
- Provide and honor opt-out flows.
- Keep batch sizes bounded and monitor failures, pending delivery, and user
complaints.
- Treat `delay` as application pacing only. It does not guarantee delivery,
account safety, or policy compliance.

## Out Of Scope

The guardrails in this project intentionally do not implement proxy rotation,
IP rotation, fingerprint randomization, automated warmup, human-like behavior
simulation, or guarantees that an account will not be restricted.

## References

- Community report about `/chat/whatsappNumbers` bulk check risk:
https://github.com/evolution-foundation/evolution-api/issues/2228
- Community discussion about constant bans and high-volume sending:
https://github.com/evolution-foundation/evolution-api/issues/1870
- High-volume queue/rate-limit question closed as usage support:
https://github.com/evolution-foundation/evolution-api/issues/2538
- Deliverability reports with pending/one-tick messages:
https://github.com/evolution-foundation/evolution-api/issues/1854
https://github.com/evolution-foundation/evolution-api/issues/2404
- WhatsApp Business Messaging Policy:
https://whatsappbusiness.com/policy/
- WhatsApp Business Terms:
https://www.whatsapp.com/legal/business-terms
7 changes: 7 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ CONFIG_SESSION_PHONE_NAME=Chrome
QRCODE_LIMIT=30
QRCODE_COLOR=#198754

# ===========================================
# ABUSE SAFETY
# ===========================================
ABUSE_SAFETY_WHATSAPP_NUMBERS_MAX_BATCH_SIZE=50
ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_SIZE=10
ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_INTERVAL_MS=1000

# ===========================================
# INTEGRAÇÕES
# ===========================================
Expand Down
71 changes: 67 additions & 4 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { ChannelStartupService } from '@api/services/channel.service';
import { Events, MessageSubtype, TypeMediaMessage, wa } from '@api/types/wa.types';
import { CacheEngine } from '@cache/cacheengine';
import {
AbuseSafety,
AudioConverter,
CacheConf,
Chatwoot,
Expand All @@ -78,7 +79,12 @@ import {
QrCode,
S3,
} from '@config/env.config';
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@exceptions';
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
TooManyRequestsException,
} from '@exceptions';
import ffmpegPath from '@ffmpeg-installer/ffmpeg';
import { Boom } from '@hapi/boom';
import { createId as cuid } from '@paralleldrive/cuid2';
Expand Down Expand Up @@ -4027,8 +4033,62 @@ export class BaileysStartupService extends ChannelStartupService {
});
}

private getWhatsappNumbersGuardrails() {
const config = this.configService.get<AbuseSafety>('ABUSE_SAFETY')?.WHATSAPP_NUMBERS;

return {
maxBatchSize: Math.max(1, config?.MAX_BATCH_SIZE ?? 50),
queryBatchSize: Math.max(1, config?.QUERY_BATCH_SIZE ?? 10),
queryBatchIntervalMs: Math.max(0, config?.QUERY_BATCH_INTERVAL_MS ?? 1000),
};
}

private async queryOnWhatsappInChunks(numbers: string[], batchSize: number, intervalMs: number) {
const results: { jid: string; exists: boolean }[] = [];

for (let index = 0; index < numbers.length; index += batchSize) {
const chunk = numbers.slice(index, index + batchSize);
const currentBatch = Math.floor(index / batchSize) + 1;
const totalBatches = Math.ceil(numbers.length / batchSize);

this.logger.verbose(`Checking ${chunk.length} numbers via Baileys (${currentBatch}/${totalBatches})`);
results.push(...(await this.client.onWhatsApp(...chunk)));

const hasMoreChunks = index + batchSize < numbers.length;
if (hasMoreChunks && intervalMs > 0) {
await delay(intervalMs);
}
}

return results;
}

// Chat Controller
public async whatsappNumber(data: WhatsAppNumberDto) {
const guardrails = this.getWhatsappNumbersGuardrails();
const numbers = Array.isArray(data?.numbers) ? data.numbers : [];

if (numbers.length === 0) {
throw new BadRequestException('At least one WhatsApp number must be provided.');
}

if (numbers.length > guardrails.maxBatchSize) {
const retryAfter = Math.max(1, Math.ceil(guardrails.queryBatchIntervalMs / 1000));

this.logger.warn(
`Rejected whatsappNumbers batch with ${numbers.length} numbers. Maximum allowed: ${guardrails.maxBatchSize}.`,
);

throw new TooManyRequestsException(retryAfter, {
message: `whatsappNumbers accepts up to ${guardrails.maxBatchSize} numbers per request.`,
received: numbers.length,
maxBatchSize: guardrails.maxBatchSize,
retryAfter,
docs: 'docs/responsible-messaging.md',
reference: 'https://github.com/evolution-foundation/evolution-api/issues/2228',
});
}

const jids: {
groups: { number: string; jid: string }[];
broadcast: { number: string; jid: string }[];
Expand All @@ -4037,7 +4097,7 @@ export class BaileysStartupService extends ChannelStartupService {

const onWhatsapp: OnWhatsAppDto[] = [];

data.numbers.forEach((number) => {
numbers.forEach((number) => {
const jid = createJid(number);

if (isJidNewsletter(jid)) {
Expand Down Expand Up @@ -4099,8 +4159,11 @@ export class BaileysStartupService extends ChannelStartupService {
const normalNumbersNotInCache = numbersNotInCache.filter((jid) => !jid.includes('@lid'));

if (normalNumbersNotInCache.length > 0) {
this.logger.verbose(`Checking ${normalNumbersNotInCache.length} numbers via Baileys (not found in cache)`);
verify = await this.client.onWhatsApp(...normalNumbersNotInCache);
verify = await this.queryOnWhatsappInChunks(
normalNumbersNotInCache,
guardrails.queryBatchSize,
guardrails.queryBatchIntervalMs,
);
}

const verifiedUsers = await Promise.all(
Expand Down
6 changes: 5 additions & 1 deletion src/api/routes/chat.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ export class ChatRouter extends RouterBroker {
return res.status(HttpStatus.OK).json(response);
} catch (error) {
console.log(error);
return res.status(HttpStatus.BAD_REQUEST).json(error);
if (error?.retryAfter) {
res.set('Retry-After', String(error.retryAfter));
}

return res.status(error?.status || HttpStatus.BAD_REQUEST).json(error);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
}
})
.post(this.routerPath('markMessageAsRead'), ...guards, async (req, res) => {
Expand Down
1 change: 1 addition & 0 deletions src/api/routes/index.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum HttpStatus {
FORBIDDEN = 403,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
TOO_MANY_REQUESTS = 429,
INTERNAL_SERVER_ERROR = 500,
}

Expand Down
28 changes: 28 additions & 0 deletions src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import dotenv from 'dotenv';

dotenv.config();

const positiveIntFromEnv = (value: string | undefined, fallback: number) => {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};

const nonNegativeIntFromEnv = (value: string | undefined, fallback: number) => {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};

export type HttpServer = {
NAME: string;
TYPE: 'http' | 'https';
Expand Down Expand Up @@ -328,6 +338,13 @@ export type Pusher = { ENABLED: boolean; GLOBAL?: GlobalPusher; EVENTS: EventsPu
export type ConfigSessionPhone = { CLIENT: string; NAME: string };
export type Baileys = { VERSION?: string };
export type QrCode = { LIMIT: number; COLOR: string };
export type AbuseSafety = {
WHATSAPP_NUMBERS: {
MAX_BATCH_SIZE: number;
QUERY_BATCH_SIZE: number;
QUERY_BATCH_INTERVAL_MS: number;
};
};
export type Typebot = { ENABLED: boolean; API_VERSION: string; SEND_MEDIA_BASE64: boolean };
export type Chatwoot = {
ENABLED: boolean;
Expand Down Expand Up @@ -427,6 +444,7 @@ export interface Env {
CONFIG_SESSION_PHONE: ConfigSessionPhone;
BAILEYS: Baileys;
QRCODE: QrCode;
ABUSE_SAFETY: AbuseSafety;
TYPEBOT: Typebot;
CHATWOOT: Chatwoot;
OPENAI: Openai;
Expand Down Expand Up @@ -837,6 +855,16 @@ export class ConfigService {
LIMIT: Number.parseInt(process.env.QRCODE_LIMIT) || 30,
COLOR: process.env.QRCODE_COLOR || '#198754',
},
ABUSE_SAFETY: {
WHATSAPP_NUMBERS: {
MAX_BATCH_SIZE: positiveIntFromEnv(process.env.ABUSE_SAFETY_WHATSAPP_NUMBERS_MAX_BATCH_SIZE, 50),
QUERY_BATCH_SIZE: positiveIntFromEnv(process.env.ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_SIZE, 10),
QUERY_BATCH_INTERVAL_MS: nonNegativeIntFromEnv(
process.env.ABUSE_SAFETY_WHATSAPP_NUMBERS_QUERY_BATCH_INTERVAL_MS,
1000,
),
},
},
TYPEBOT: {
ENABLED: process.env?.TYPEBOT_ENABLED === 'true',
API_VERSION: process.env?.TYPEBOT_API_VERSION || 'old',
Expand Down
12 changes: 12 additions & 0 deletions src/exceptions/429.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpStatus } from '@api/routes/index.router';

export class TooManyRequestsException {
constructor(retryAfter?: number, ...objectError: any[]) {
throw {
status: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
retryAfter,
message: objectError.length > 0 ? objectError : undefined,
};
}
}
1 change: 1 addition & 0 deletions src/exceptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './400.exception';
export * from './401.exception';
export * from './403.exception';
export * from './404.exception';
export * from './429.exception';
export * from './500.exception';
1 change: 1 addition & 0 deletions src/validate/chat.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const whatsappNumberSchema: JSONSchema7 = {
},
},
},
required: ['numbers'],
};

export const readMessageSchema: JSONSchema7 = {
Expand Down