@zindua/sdk 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,26 @@ await zindua.send({
66
66
  });
67
67
  ```
68
68
 
69
+ ### WhatsApp anti-ban robot (required)
70
+
71
+ Zindua runs an **anti-ban Guardian** on WhatsApp sends. Burst / simultaneous OTP traffic looks like spam and can **ban the number you linked** in the dashboard.
72
+
73
+ - **Server:** unsafe WhatsApp traffic is paced or refused so your linked line stays protected.
74
+ - **SDK:** official clients cooperate with the Guardian automatically on WhatsApp `send()`.
75
+ - **Your app:** use a queue or resend cooldown in product UX; never fire parallel WhatsApp bursts.
76
+
77
+ Guide: [WhatsApp anti-ban Guardian](https://zindua.run/whatsapp/anti-ban)
78
+
79
+ ### Upgrade (already installed?)
80
+
81
+ ```bash
82
+ npm install @zindua/sdk@1.2.8
83
+ ```
84
+
85
+ ```typescript
86
+ const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
87
+ ```
88
+
69
89
  ### Channel and `to` must match
70
90
 
71
91
  The SDK checks **before** calling the API (same rules as [zindua.run](https://zindua.run)). A phone number cannot be sent as email, and an email cannot be sent on WhatsApp.
@@ -534,7 +554,7 @@ try {
534
554
  | `WHATSAPP_NOT_CONNECTED` | 422 | WhatsApp not linked. | Dashboard → project → **WhatsApp** → scan QR. |
535
555
  | `WHATSAPP_PAUSED` | 422 | WhatsApp session paused. | Resume in the dashboard. |
536
556
  | `WHATSAPP_QUOTA_EXCEEDED` | 429 | Monthly WhatsApp limit reached. | Upgrade or wait. |
537
- | `RATE_LIMIT_EXCEEDED` | 429 | Sending too fast (WhatsApp). | Wait `retryAfterSec` from `e.details`, then retry. |
557
+ | `RATE_LIMIT_EXCEEDED` | 429 | WhatsApp anti-ban robot: sending too fast. | Wait `retryAfterSec` (details), keep ≥3s gap / queue. See [anti-ban guide](https://zindua.run/blog/whatsapp-otp-anti-ban-pacing). |
538
558
  | `TEMPLATE_NOT_FOUND` | 404 | Unknown template slug. | Create template or fix slug; check `availableTemplateSlugs` in details. |
539
559
  | `TEMPLATE_NO_CONTENT` | 404 | Template has no language version. | Add content in Dashboard → Templates. |
540
560
  | `TEMPLATE_NO_WHATSAPP_BODY` | 422 | No plain text for WhatsApp. | Add a text body for that language. |
package/dist/client.d.ts CHANGED
@@ -21,6 +21,17 @@ export type ZinduaClientOptions = {
21
21
  * Sent as X-Zindua-Site-Url — required once the API key is bound to a site.
22
22
  */
23
23
  siteUrl?: string;
24
+ /**
25
+ * Override User-Agent (default Zindua-JS/{version}).
26
+ * Used by @zindua/cli as Zindua-CLI/{version} for admin telemetry.
27
+ */
28
+ userAgent?: string;
29
+ /**
30
+ * When true (default), the SDK waits ≥3s between WhatsApp sends on this client
31
+ * and logs an anti-ban warning. Mirrors Zindua’s server anti-ban robot.
32
+ * Set false only if your app already queues / debounces WhatsApp OTP.
33
+ */
34
+ autoPaceWhatsapp?: boolean;
24
35
  };
25
36
  export type ZinduaProjectInfo = {
26
37
  name: string;
@@ -96,7 +107,157 @@ export declare class Zindua {
96
107
  private readonly apiBase;
97
108
  private readonly timeoutMs;
98
109
  private readonly siteUrl?;
110
+ private readonly userAgent;
111
+ private readonly autoPaceWhatsapp;
112
+ private lastWhatsappSendAt;
113
+ private whatsappPaceChain;
99
114
  constructor(options: ZinduaClientOptions);
115
+ /** PushMirror — 1-tap push challenges (emoji / approve / digit) */
116
+ readonly pushMirror: {
117
+ create: (options: {
118
+ /** Phone (E.164), email, or your user id — used to resolve devices / WhatsApp */
119
+ to?: string;
120
+ /** Alias of `to` (docs / older snippets) */
121
+ userId?: string;
122
+ userExternalId?: string;
123
+ type?: "emoji" | "approve" | "digit";
124
+ purpose?: "login" | "sensitive_action";
125
+ context?: Record<string, unknown>;
126
+ /** Delivery: auto | whatsapp | webpush | fcm | byo */
127
+ channel?: "auto" | "whatsapp" | "webpush" | "fcm" | "byo";
128
+ fallbackChannel?: "whatsapp" | "email" | "none";
129
+ /** Alias: `{ channel, to }` → fallbackChannel + recipient */
130
+ fallback?: {
131
+ channel?: "whatsapp" | "email" | "none";
132
+ to?: string;
133
+ };
134
+ ttlSeconds?: number;
135
+ }) => Promise<{
136
+ success: boolean;
137
+ challengeId: string;
138
+ id: string;
139
+ display: string;
140
+ choices: string[];
141
+ type: string;
142
+ status: string;
143
+ channel?: string;
144
+ expiresAt: string;
145
+ }>;
146
+ getStatus: (challengeId: string) => Promise<{
147
+ ok: boolean;
148
+ challenge: {
149
+ id: string;
150
+ status: string;
151
+ type: string;
152
+ choices: string[];
153
+ targetValue: string;
154
+ display?: string;
155
+ expiresAt: string;
156
+ };
157
+ }>;
158
+ respond: (challengeId: string, value: string) => Promise<{
159
+ ok: boolean;
160
+ status: string;
161
+ error?: string;
162
+ }>;
163
+ /**
164
+ * SSE listener for challenge status (requires API key — server-side or trusted runtime).
165
+ * Resolves when status is approved | denied | expired | failed.
166
+ */
167
+ listen: (challengeId: string, handlers?: {
168
+ onApproved?: () => void;
169
+ onDenied?: () => void;
170
+ onExpired?: () => void;
171
+ onEvent?: (event: {
172
+ challengeId: string;
173
+ status: string;
174
+ }) => void;
175
+ signal?: AbortSignal;
176
+ }) => Promise<{
177
+ challengeId: string;
178
+ status: string;
179
+ }>;
180
+ };
181
+ /** @deprecated Prefer `pushMirror` — same API */
182
+ readonly confirm: {
183
+ create: (options: {
184
+ /** Phone (E.164), email, or your user id — used to resolve devices / WhatsApp */
185
+ to?: string;
186
+ /** Alias of `to` (docs / older snippets) */
187
+ userId?: string;
188
+ userExternalId?: string;
189
+ type?: "emoji" | "approve" | "digit";
190
+ purpose?: "login" | "sensitive_action";
191
+ context?: Record<string, unknown>;
192
+ /** Delivery: auto | whatsapp | webpush | fcm | byo */
193
+ channel?: "auto" | "whatsapp" | "webpush" | "fcm" | "byo";
194
+ fallbackChannel?: "whatsapp" | "email" | "none";
195
+ /** Alias: `{ channel, to }` → fallbackChannel + recipient */
196
+ fallback?: {
197
+ channel?: "whatsapp" | "email" | "none";
198
+ to?: string;
199
+ };
200
+ ttlSeconds?: number;
201
+ }) => Promise<{
202
+ success: boolean;
203
+ challengeId: string;
204
+ id: string;
205
+ display: string;
206
+ choices: string[];
207
+ type: string;
208
+ status: string;
209
+ channel?: string;
210
+ expiresAt: string;
211
+ }>;
212
+ getStatus: (challengeId: string) => Promise<{
213
+ ok: boolean;
214
+ challenge: {
215
+ id: string;
216
+ status: string;
217
+ type: string;
218
+ choices: string[];
219
+ targetValue: string;
220
+ display?: string;
221
+ expiresAt: string;
222
+ };
223
+ }>;
224
+ respond: (challengeId: string, value: string) => Promise<{
225
+ ok: boolean;
226
+ status: string;
227
+ error?: string;
228
+ }>;
229
+ /**
230
+ * SSE listener for challenge status (requires API key — server-side or trusted runtime).
231
+ * Resolves when status is approved | denied | expired | failed.
232
+ */
233
+ listen: (challengeId: string, handlers?: {
234
+ onApproved?: () => void;
235
+ onDenied?: () => void;
236
+ onExpired?: () => void;
237
+ onEvent?: (event: {
238
+ challengeId: string;
239
+ status: string;
240
+ }) => void;
241
+ signal?: AbortSignal;
242
+ }) => Promise<{
243
+ challengeId: string;
244
+ status: string;
245
+ }>;
246
+ };
247
+ /** Zindua Devices (FCM / APNs / WebPush Token Registration) API */
248
+ readonly devices: {
249
+ register: (options: {
250
+ userExternalId: string;
251
+ platform?: "web" | "android" | "ios";
252
+ pushToken?: string;
253
+ keys?: Record<string, string>;
254
+ }) => Promise<{
255
+ success: boolean;
256
+ deviceId: string;
257
+ platform: string;
258
+ userExternalId: string;
259
+ }>;
260
+ };
100
261
  /** Returns true when using a znd_test_ key (sandbox / no real delivery). */
101
262
  isTestMode(): boolean;
102
263
  /**
@@ -111,7 +272,11 @@ export declare class Zindua {
111
272
  templates: ZinduaTemplateInfo[];
112
273
  limits?: Record<string, unknown>;
113
274
  }>;
275
+ /** Delivery status for a logId returned by send() (GET /logs/{logId}). */
276
+ getLog(logId: string): Promise<Record<string, unknown>>;
114
277
  send(options: ZinduaSendOptions): Promise<ZinduaSendResult>;
278
+ /** Serialize WhatsApp sends on this client and wait for the anti-ban gap. */
279
+ private paceWhatsappSend;
115
280
  private buildHeaders;
116
281
  private request;
117
282
  }
package/dist/client.js CHANGED
@@ -3,7 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Zindua = void 0;
4
4
  const errors_1 = require("./errors");
5
5
  const validate_1 = require("./validate");
6
- const SDK_VERSION = "1.2.7";
6
+ const whatsapp_anti_ban_1 = require("./whatsapp-anti-ban");
7
+ const SDK_VERSION = "1.2.9";
7
8
  const USER_AGENT = `Zindua-JS/${SDK_VERSION}`;
8
9
  function buildPayload(options, channel) {
9
10
  const to = (0, validate_1.validateRecipient)(options.to, channel);
@@ -38,7 +39,7 @@ function buildPayload(options, channel) {
38
39
  return payload;
39
40
  }
40
41
  function parseApiError(status, body) {
41
- const message = typeof body.error === "string"
42
+ let message = typeof body.error === "string"
42
43
  ? body.error
43
44
  : typeof body.message === "string"
44
45
  ? body.message
@@ -58,10 +59,17 @@ function parseApiError(status, body) {
58
59
  if (body.context && typeof body.context === "object") {
59
60
  details.context = body.context;
60
61
  }
61
- const fullMessage = typeof body.hint === "string" && body.hint.length > 0 ? `${message} ${body.hint}` : message;
62
+ const code = typeof body.code === "string" ? body.code : "API_ERROR";
63
+ if (code === "RATE_LIMIT_EXCEEDED" || status === 429) {
64
+ message = (0, whatsapp_anti_ban_1.antiBanRateLimitMessage)(message, typeof body.retryAfterSec === "number" ? body.retryAfterSec : undefined);
65
+ details.antiBanGuide = whatsapp_anti_ban_1.WHATSAPP_ANTI_BAN_GUIDE_URL;
66
+ }
67
+ const fullMessage = typeof body.hint === "string" && body.hint.length > 0 && code !== "RATE_LIMIT_EXCEEDED"
68
+ ? `${message} ${body.hint}`
69
+ : message;
62
70
  return new errors_1.ZinduaError(fullMessage, {
63
71
  status,
64
- code: typeof body.code === "string" ? body.code : "API_ERROR",
72
+ code,
65
73
  details: Object.keys(details).length > 0
66
74
  ? details
67
75
  : typeof body.hint === "string"
@@ -74,13 +82,135 @@ class Zindua {
74
82
  apiBase;
75
83
  timeoutMs;
76
84
  siteUrl;
85
+ userAgent;
86
+ autoPaceWhatsapp;
87
+ lastWhatsappSendAt = 0;
88
+ whatsappPaceChain = Promise.resolve();
77
89
  constructor(options) {
78
90
  (0, validate_1.assertServerRuntime)();
79
91
  this.apiKey = (0, validate_1.validateApiKey)(options.apiKey);
80
92
  this.apiBase = (0, validate_1.resolveBaseUrl)(options.baseUrl);
81
93
  this.timeoutMs = (0, validate_1.validateTimeoutMs)(options.timeoutMs);
82
94
  this.siteUrl = (0, validate_1.validateSiteUrl)(options.siteUrl);
95
+ this.userAgent =
96
+ typeof options.userAgent === "string" && options.userAgent.trim()
97
+ ? options.userAgent.trim()
98
+ : USER_AGENT;
99
+ this.autoPaceWhatsapp = options.autoPaceWhatsapp !== false;
83
100
  }
101
+ /** PushMirror — 1-tap push challenges (emoji / approve / digit) */
102
+ pushMirror = {
103
+ create: async (options) => {
104
+ const payload = {
105
+ to: options.to,
106
+ userId: options.userId,
107
+ userExternalId: options.userExternalId,
108
+ type: options.type,
109
+ purpose: options.purpose,
110
+ context: options.context,
111
+ channel: options.channel,
112
+ fallbackChannel: options.fallbackChannel ?? options.fallback?.channel,
113
+ fallback: options.fallback,
114
+ ttlSeconds: options.ttlSeconds,
115
+ };
116
+ return this.request("POST", "challenges", payload);
117
+ },
118
+ getStatus: async (challengeId) => {
119
+ return this.request("GET", `challenges/${encodeURIComponent(challengeId)}`);
120
+ },
121
+ respond: async (challengeId, value) => {
122
+ return this.request("POST", `challenges/${encodeURIComponent(challengeId)}/respond`, { value });
123
+ },
124
+ /**
125
+ * SSE listener for challenge status (requires API key — server-side or trusted runtime).
126
+ * Resolves when status is approved | denied | expired | failed.
127
+ */
128
+ listen: async (challengeId, handlers) => {
129
+ const id = challengeId.trim();
130
+ if (!id) {
131
+ throw new errors_1.ZinduaError("challengeId is required.", { status: 0, code: "MISSING_FIELDS" });
132
+ }
133
+ const url = `${this.apiBase}/challenges/${encodeURIComponent(id)}/stream`;
134
+ const res = await fetch(url, {
135
+ method: "GET",
136
+ headers: this.buildHeaders(),
137
+ signal: handlers?.signal,
138
+ redirect: "error",
139
+ });
140
+ if (!res.ok) {
141
+ const text = await res.text();
142
+ let body = {};
143
+ try {
144
+ body = text ? JSON.parse(text) : {};
145
+ }
146
+ catch {
147
+ /* ignore */
148
+ }
149
+ throw parseApiError(res.status, body);
150
+ }
151
+ if (!res.body) {
152
+ throw new errors_1.ZinduaError("SSE stream unavailable.", { status: 0, code: "INVALID_RESPONSE" });
153
+ }
154
+ const reader = res.body.getReader();
155
+ const decoder = new TextDecoder();
156
+ let buffer = "";
157
+ while (true) {
158
+ const { done, value } = await reader.read();
159
+ if (done)
160
+ break;
161
+ buffer += decoder.decode(value, { stream: true });
162
+ const chunks = buffer.split("\n\n");
163
+ buffer = chunks.pop() || "";
164
+ for (const chunk of chunks) {
165
+ const line = chunk
166
+ .split("\n")
167
+ .find((l) => l.startsWith("data:"));
168
+ if (!line)
169
+ continue;
170
+ const raw = line.slice(5).trim();
171
+ let event;
172
+ try {
173
+ event = JSON.parse(raw);
174
+ }
175
+ catch {
176
+ continue;
177
+ }
178
+ if (!event.status || event.status === "connected" || event.status === "pending") {
179
+ handlers?.onEvent?.(event);
180
+ continue;
181
+ }
182
+ handlers?.onEvent?.(event);
183
+ if (event.status === "approved")
184
+ handlers?.onApproved?.();
185
+ if (event.status === "denied")
186
+ handlers?.onDenied?.();
187
+ if (event.status === "expired")
188
+ handlers?.onExpired?.();
189
+ if (["approved", "denied", "expired", "failed"].includes(event.status)) {
190
+ try {
191
+ await reader.cancel();
192
+ }
193
+ catch {
194
+ /* ignore */
195
+ }
196
+ return event;
197
+ }
198
+ }
199
+ }
200
+ throw new errors_1.ZinduaError("SSE stream ended without a terminal status.", {
201
+ status: 0,
202
+ code: "STREAM_ENDED",
203
+ });
204
+ },
205
+ };
206
+ /** @deprecated Prefer `pushMirror` — same API */
207
+ confirm = this.pushMirror;
208
+ /** Zindua Devices (FCM / APNs / WebPush Token Registration) API */
209
+ devices = {
210
+ register: async (options) => {
211
+ return this.request("POST", "devices", options);
212
+ },
213
+ };
84
214
  /** Returns true when using a znd_test_ key (sandbox / no real delivery). */
85
215
  isTestMode() {
86
216
  return this.apiKey.startsWith("znd_test_");
@@ -126,8 +256,26 @@ class Zindua {
126
256
  limits: data.limits,
127
257
  };
128
258
  }
259
+ /** Delivery status for a logId returned by send() (GET /logs/{logId}). */
260
+ async getLog(logId) {
261
+ const trimmed = logId.trim();
262
+ if (!trimmed) {
263
+ throw new errors_1.ZinduaError("logId is required.", { status: 0, code: "MISSING_FIELDS" });
264
+ }
265
+ const data = await this.request("GET", `logs/${encodeURIComponent(trimmed)}`);
266
+ if (data.ok !== true || !data.log || typeof data.log !== "object") {
267
+ throw new errors_1.ZinduaError("API response missing log payload.", {
268
+ status: 200,
269
+ code: "INVALID_RESPONSE",
270
+ });
271
+ }
272
+ return data.log;
273
+ }
129
274
  async send(options) {
130
275
  const channel = (0, validate_1.validateChannel)(options.channel);
276
+ if (channel === "whatsapp" && this.autoPaceWhatsapp) {
277
+ await this.paceWhatsappSend();
278
+ }
131
279
  const payload = buildPayload(options, channel);
132
280
  const data = await this.request("POST", "send", payload);
133
281
  if (data.success !== true || typeof data.logId !== "string") {
@@ -136,6 +284,9 @@ class Zindua {
136
284
  code: "INVALID_RESPONSE",
137
285
  });
138
286
  }
287
+ if (channel === "whatsapp") {
288
+ this.lastWhatsappSendAt = Date.now();
289
+ }
139
290
  return {
140
291
  success: true,
141
292
  channel: (data.channel === "whatsapp" ? "whatsapp" : "email"),
@@ -150,10 +301,23 @@ class Zindua {
150
301
  : undefined,
151
302
  };
152
303
  }
304
+ /** Serialize WhatsApp sends on this client and wait for the anti-ban gap. */
305
+ paceWhatsappSend() {
306
+ const run = async () => {
307
+ const elapsed = Date.now() - this.lastWhatsappSendAt;
308
+ if (this.lastWhatsappSendAt > 0 && elapsed < whatsapp_anti_ban_1.WHATSAPP_MIN_INTERVAL_MS) {
309
+ const waitMs = whatsapp_anti_ban_1.WHATSAPP_MIN_INTERVAL_MS - elapsed;
310
+ (0, whatsapp_anti_ban_1.warnWhatsappAntiBanPace)(waitMs);
311
+ await (0, whatsapp_anti_ban_1.sleepMs)(waitMs);
312
+ }
313
+ };
314
+ this.whatsappPaceChain = this.whatsappPaceChain.then(run, run);
315
+ return this.whatsappPaceChain;
316
+ }
153
317
  buildHeaders() {
154
318
  const headers = {
155
319
  Authorization: `Bearer ${this.apiKey}`,
156
- "User-Agent": USER_AGENT,
320
+ "User-Agent": this.userAgent,
157
321
  Accept: "application/json",
158
322
  };
159
323
  if (this.siteUrl) {
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export type { ZinduaClientOptions, ZinduaConnectResult, ZinduaProjectInfo, Zindu
3
3
  export { ZinduaError } from "./errors";
4
4
  export type { ZinduaErrorCode } from "./errors";
5
5
  export { DEFAULT_API_BASE, LIMITS } from "./validate";
6
+ export { WHATSAPP_ANTI_BAN_GUIDE_URL, WHATSAPP_MIN_INTERVAL_MS, } from "./whatsapp-anti-ban";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LIMITS = exports.DEFAULT_API_BASE = exports.ZinduaError = exports.Zindua = void 0;
3
+ exports.WHATSAPP_MIN_INTERVAL_MS = exports.WHATSAPP_ANTI_BAN_GUIDE_URL = exports.LIMITS = exports.DEFAULT_API_BASE = exports.ZinduaError = exports.Zindua = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "Zindua", { enumerable: true, get: function () { return client_1.Zindua; } });
6
6
  var errors_1 = require("./errors");
@@ -8,3 +8,6 @@ Object.defineProperty(exports, "ZinduaError", { enumerable: true, get: function
8
8
  var validate_1 = require("./validate");
9
9
  Object.defineProperty(exports, "DEFAULT_API_BASE", { enumerable: true, get: function () { return validate_1.DEFAULT_API_BASE; } });
10
10
  Object.defineProperty(exports, "LIMITS", { enumerable: true, get: function () { return validate_1.LIMITS; } });
11
+ var whatsapp_anti_ban_1 = require("./whatsapp-anti-ban");
12
+ Object.defineProperty(exports, "WHATSAPP_ANTI_BAN_GUIDE_URL", { enumerable: true, get: function () { return whatsapp_anti_ban_1.WHATSAPP_ANTI_BAN_GUIDE_URL; } });
13
+ Object.defineProperty(exports, "WHATSAPP_MIN_INTERVAL_MS", { enumerable: true, get: function () { return whatsapp_anti_ban_1.WHATSAPP_MIN_INTERVAL_MS; } });
@@ -0,0 +1,7 @@
1
+ /** Zindua WhatsApp anti-ban robot — client-side pacing helpers. */
2
+ export declare const WHATSAPP_MIN_INTERVAL_MS = 3000;
3
+ /** Guide for developers integrating WhatsApp OTP safely. */
4
+ export declare const WHATSAPP_ANTI_BAN_GUIDE_URL = "https://zindua.run/blog/whatsapp-otp-anti-ban-pacing";
5
+ export declare function antiBanRateLimitMessage(baseMessage: string, retryAfterSec?: number): string;
6
+ export declare function warnWhatsappAntiBanPace(waitMs: number): void;
7
+ export declare function sleepMs(ms: number): Promise<void>;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /** Zindua WhatsApp anti-ban robot — client-side pacing helpers. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.WHATSAPP_ANTI_BAN_GUIDE_URL = exports.WHATSAPP_MIN_INTERVAL_MS = void 0;
5
+ exports.antiBanRateLimitMessage = antiBanRateLimitMessage;
6
+ exports.warnWhatsappAntiBanPace = warnWhatsappAntiBanPace;
7
+ exports.sleepMs = sleepMs;
8
+ exports.WHATSAPP_MIN_INTERVAL_MS = 3000;
9
+ /** Guide for developers integrating WhatsApp OTP safely. */
10
+ exports.WHATSAPP_ANTI_BAN_GUIDE_URL = "https://zindua.run/blog/whatsapp-otp-anti-ban-pacing";
11
+ function antiBanRateLimitMessage(baseMessage, retryAfterSec) {
12
+ const wait = typeof retryAfterSec === "number" && retryAfterSec > 0
13
+ ? ` Wait ${retryAfterSec}s, then retry.`
14
+ : " Wait at least 3s between WhatsApp sends.";
15
+ return (`${baseMessage}${wait} ` +
16
+ `Zindua's anti-ban robot paces WhatsApp to protect your linked number from bans. ` +
17
+ `Space sends in your app (queue / debounce) or keep SDK autoPaceWhatsapp on. ` +
18
+ `Guide: ${exports.WHATSAPP_ANTI_BAN_GUIDE_URL}`);
19
+ }
20
+ function warnWhatsappAntiBanPace(waitMs) {
21
+ const sec = Math.ceil(waitMs / 1000);
22
+ console.warn(`[Zindua anti-ban] Waiting ${sec}s before WhatsApp send — ` +
23
+ `rapid OTP looks like spam and can ban your linked number. ` +
24
+ `Prefer a queue or ≥3s gap in your code. ${exports.WHATSAPP_ANTI_BAN_GUIDE_URL}`);
25
+ }
26
+ async function sleepMs(ms) {
27
+ await new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zindua/sdk",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Official Zindua SDK for Node.js — transactional email and WhatsApp via POST /api/v1/send.",
5
5
  "author": "Zindua",
6
6
  "license": "MIT",