@zindua/sdk 1.2.8 → 1.3.0
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 +20 -9
- package/dist/client.d.ts +168 -0
- package/dist/client.js +148 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ await zindua.send({
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
// WhatsApp — E.164 phone with + (channel is required for WhatsApp)
|
|
61
|
+
// Works for OTP, alerts, receipts — any template.
|
|
61
62
|
await zindua.send({
|
|
62
63
|
to: "+243812345678",
|
|
63
64
|
channel: "whatsapp",
|
|
@@ -66,22 +67,32 @@ await zindua.send({
|
|
|
66
67
|
});
|
|
67
68
|
```
|
|
68
69
|
|
|
70
|
+
### WhatsApp: brand name + save contact
|
|
71
|
+
|
|
72
|
+
WhatsApp may still show the phone number in the chat header until the recipient saves your contact. Zindua helps in three ways:
|
|
73
|
+
|
|
74
|
+
1. **Sender name** in Dashboard → Project → WhatsApp (required after linking).
|
|
75
|
+
2. **Brand prefix** — every WhatsApp body is prefixed with that name (`Acme: …`) when set.
|
|
76
|
+
3. **Save contact** — download the `.vcf` from the dashboard and ask users to save it before important messages. Optionally enable **Send contact card on first message** so each new recipient gets a saveable card once (never again for that number).
|
|
77
|
+
|
|
69
78
|
### WhatsApp anti-ban robot (required)
|
|
70
79
|
|
|
71
|
-
Zindua runs an **anti-ban
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
- **Server:** unsafe WhatsApp traffic is paced or refused so your linked line stays protected.
|
|
83
|
+
- **SDK:** official clients cooperate with the Guardian automatically on WhatsApp `send()`.
|
|
84
|
+
- **Your app:** use a queue or resend cooldown in product UX; never fire parallel WhatsApp bursts.
|
|
72
85
|
|
|
73
|
-
|
|
74
|
-
- **SDK (default):** `autoPaceWhatsapp: true` waits on this client and prints a warning so you learn to space sends in your app (queue, debounce, resend countdown).
|
|
75
|
-
- **Your app:** still use a queue or ≥3s gap for production UX — the SDK cannot pace across multiple servers or cold starts.
|
|
86
|
+
Guide: [WhatsApp anti-ban Guardian](https://zindua.run/whatsapp/anti-ban)
|
|
76
87
|
|
|
77
|
-
|
|
88
|
+
### Upgrade (already installed?)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm install @zindua/sdk@1.2.8
|
|
92
|
+
```
|
|
78
93
|
|
|
79
94
|
```typescript
|
|
80
|
-
// Default: SDK waits ≥3s between WhatsApp sends on this client
|
|
81
95
|
const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
|
|
82
|
-
|
|
83
|
-
// Only if you already queue WhatsApp OTP yourself:
|
|
84
|
-
new Zindua({ apiKey: process.env.ZINDUA_API_KEY!, autoPaceWhatsapp: false });
|
|
85
96
|
```
|
|
86
97
|
|
|
87
98
|
### Channel and `to` must match
|
package/dist/client.d.ts
CHANGED
|
@@ -9,6 +9,23 @@ export type ZinduaSendOptions = {
|
|
|
9
9
|
bcc?: string;
|
|
10
10
|
replyTo?: string;
|
|
11
11
|
attachments?: unknown[];
|
|
12
|
+
/** When true, server verifies email (format/MX/disposable) before queueing. */
|
|
13
|
+
validate?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type ZinduaEmailVerifyResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
email: string;
|
|
18
|
+
status: "valid" | "risky" | "invalid";
|
|
19
|
+
score: number;
|
|
20
|
+
formatOk: boolean;
|
|
21
|
+
mxFound: boolean;
|
|
22
|
+
disposable: boolean;
|
|
23
|
+
didYouMean: string | null;
|
|
24
|
+
domain: string | null;
|
|
25
|
+
reasons: string[];
|
|
26
|
+
suppressed: boolean;
|
|
27
|
+
suppressionReason: string | null;
|
|
28
|
+
deliverable: boolean;
|
|
12
29
|
};
|
|
13
30
|
export type ZinduaClientOptions = {
|
|
14
31
|
apiKey: string;
|
|
@@ -112,6 +129,152 @@ export declare class Zindua {
|
|
|
112
129
|
private lastWhatsappSendAt;
|
|
113
130
|
private whatsappPaceChain;
|
|
114
131
|
constructor(options: ZinduaClientOptions);
|
|
132
|
+
/** PushMirror — 1-tap push challenges (emoji / approve / digit) */
|
|
133
|
+
readonly pushMirror: {
|
|
134
|
+
create: (options: {
|
|
135
|
+
/** Phone (E.164), email, or your user id — used to resolve devices / WhatsApp */
|
|
136
|
+
to?: string;
|
|
137
|
+
/** Alias of `to` (docs / older snippets) */
|
|
138
|
+
userId?: string;
|
|
139
|
+
userExternalId?: string;
|
|
140
|
+
type?: "emoji" | "approve" | "digit";
|
|
141
|
+
purpose?: "login" | "sensitive_action";
|
|
142
|
+
context?: Record<string, unknown>;
|
|
143
|
+
/** Delivery: auto | whatsapp | webpush | fcm | byo */
|
|
144
|
+
channel?: "auto" | "whatsapp" | "webpush" | "fcm" | "byo";
|
|
145
|
+
fallbackChannel?: "whatsapp" | "email" | "none";
|
|
146
|
+
/** Alias: `{ channel, to }` → fallbackChannel + recipient */
|
|
147
|
+
fallback?: {
|
|
148
|
+
channel?: "whatsapp" | "email" | "none";
|
|
149
|
+
to?: string;
|
|
150
|
+
};
|
|
151
|
+
ttlSeconds?: number;
|
|
152
|
+
}) => Promise<{
|
|
153
|
+
success: boolean;
|
|
154
|
+
challengeId: string;
|
|
155
|
+
id: string;
|
|
156
|
+
display: string;
|
|
157
|
+
choices: string[];
|
|
158
|
+
type: string;
|
|
159
|
+
status: string;
|
|
160
|
+
channel?: string;
|
|
161
|
+
expiresAt: string;
|
|
162
|
+
}>;
|
|
163
|
+
getStatus: (challengeId: string) => Promise<{
|
|
164
|
+
ok: boolean;
|
|
165
|
+
challenge: {
|
|
166
|
+
id: string;
|
|
167
|
+
status: string;
|
|
168
|
+
type: string;
|
|
169
|
+
choices: string[];
|
|
170
|
+
targetValue: string;
|
|
171
|
+
display?: string;
|
|
172
|
+
expiresAt: string;
|
|
173
|
+
};
|
|
174
|
+
}>;
|
|
175
|
+
respond: (challengeId: string, value: string) => Promise<{
|
|
176
|
+
ok: boolean;
|
|
177
|
+
status: string;
|
|
178
|
+
error?: string;
|
|
179
|
+
}>;
|
|
180
|
+
/**
|
|
181
|
+
* SSE listener for challenge status (requires API key — server-side or trusted runtime).
|
|
182
|
+
* Resolves when status is approved | denied | expired | failed.
|
|
183
|
+
*/
|
|
184
|
+
listen: (challengeId: string, handlers?: {
|
|
185
|
+
onApproved?: () => void;
|
|
186
|
+
onDenied?: () => void;
|
|
187
|
+
onExpired?: () => void;
|
|
188
|
+
onEvent?: (event: {
|
|
189
|
+
challengeId: string;
|
|
190
|
+
status: string;
|
|
191
|
+
}) => void;
|
|
192
|
+
signal?: AbortSignal;
|
|
193
|
+
}) => Promise<{
|
|
194
|
+
challengeId: string;
|
|
195
|
+
status: string;
|
|
196
|
+
}>;
|
|
197
|
+
};
|
|
198
|
+
/** @deprecated Prefer `pushMirror` — same API */
|
|
199
|
+
readonly confirm: {
|
|
200
|
+
create: (options: {
|
|
201
|
+
/** Phone (E.164), email, or your user id — used to resolve devices / WhatsApp */
|
|
202
|
+
to?: string;
|
|
203
|
+
/** Alias of `to` (docs / older snippets) */
|
|
204
|
+
userId?: string;
|
|
205
|
+
userExternalId?: string;
|
|
206
|
+
type?: "emoji" | "approve" | "digit";
|
|
207
|
+
purpose?: "login" | "sensitive_action";
|
|
208
|
+
context?: Record<string, unknown>;
|
|
209
|
+
/** Delivery: auto | whatsapp | webpush | fcm | byo */
|
|
210
|
+
channel?: "auto" | "whatsapp" | "webpush" | "fcm" | "byo";
|
|
211
|
+
fallbackChannel?: "whatsapp" | "email" | "none";
|
|
212
|
+
/** Alias: `{ channel, to }` → fallbackChannel + recipient */
|
|
213
|
+
fallback?: {
|
|
214
|
+
channel?: "whatsapp" | "email" | "none";
|
|
215
|
+
to?: string;
|
|
216
|
+
};
|
|
217
|
+
ttlSeconds?: number;
|
|
218
|
+
}) => Promise<{
|
|
219
|
+
success: boolean;
|
|
220
|
+
challengeId: string;
|
|
221
|
+
id: string;
|
|
222
|
+
display: string;
|
|
223
|
+
choices: string[];
|
|
224
|
+
type: string;
|
|
225
|
+
status: string;
|
|
226
|
+
channel?: string;
|
|
227
|
+
expiresAt: string;
|
|
228
|
+
}>;
|
|
229
|
+
getStatus: (challengeId: string) => Promise<{
|
|
230
|
+
ok: boolean;
|
|
231
|
+
challenge: {
|
|
232
|
+
id: string;
|
|
233
|
+
status: string;
|
|
234
|
+
type: string;
|
|
235
|
+
choices: string[];
|
|
236
|
+
targetValue: string;
|
|
237
|
+
display?: string;
|
|
238
|
+
expiresAt: string;
|
|
239
|
+
};
|
|
240
|
+
}>;
|
|
241
|
+
respond: (challengeId: string, value: string) => Promise<{
|
|
242
|
+
ok: boolean;
|
|
243
|
+
status: string;
|
|
244
|
+
error?: string;
|
|
245
|
+
}>;
|
|
246
|
+
/**
|
|
247
|
+
* SSE listener for challenge status (requires API key — server-side or trusted runtime).
|
|
248
|
+
* Resolves when status is approved | denied | expired | failed.
|
|
249
|
+
*/
|
|
250
|
+
listen: (challengeId: string, handlers?: {
|
|
251
|
+
onApproved?: () => void;
|
|
252
|
+
onDenied?: () => void;
|
|
253
|
+
onExpired?: () => void;
|
|
254
|
+
onEvent?: (event: {
|
|
255
|
+
challengeId: string;
|
|
256
|
+
status: string;
|
|
257
|
+
}) => void;
|
|
258
|
+
signal?: AbortSignal;
|
|
259
|
+
}) => Promise<{
|
|
260
|
+
challengeId: string;
|
|
261
|
+
status: string;
|
|
262
|
+
}>;
|
|
263
|
+
};
|
|
264
|
+
/** Zindua Devices (FCM / APNs / WebPush Token Registration) API */
|
|
265
|
+
readonly devices: {
|
|
266
|
+
register: (options: {
|
|
267
|
+
userExternalId: string;
|
|
268
|
+
platform?: "web" | "android" | "ios";
|
|
269
|
+
pushToken?: string;
|
|
270
|
+
keys?: Record<string, string>;
|
|
271
|
+
}) => Promise<{
|
|
272
|
+
success: boolean;
|
|
273
|
+
deviceId: string;
|
|
274
|
+
platform: string;
|
|
275
|
+
userExternalId: string;
|
|
276
|
+
}>;
|
|
277
|
+
};
|
|
115
278
|
/** Returns true when using a znd_test_ key (sandbox / no real delivery). */
|
|
116
279
|
isTestMode(): boolean;
|
|
117
280
|
/**
|
|
@@ -126,6 +289,11 @@ export declare class Zindua {
|
|
|
126
289
|
templates: ZinduaTemplateInfo[];
|
|
127
290
|
limits?: Record<string, unknown>;
|
|
128
291
|
}>;
|
|
292
|
+
/**
|
|
293
|
+
* Verify an email address (format, MX, disposable, typo suggestion).
|
|
294
|
+
* POST /api/v1/email/verify — protects your BYO provider reputation.
|
|
295
|
+
*/
|
|
296
|
+
verifyEmail(email: string): Promise<ZinduaEmailVerifyResult>;
|
|
129
297
|
/** Delivery status for a logId returned by send() (GET /logs/{logId}). */
|
|
130
298
|
getLog(logId: string): Promise<Record<string, unknown>>;
|
|
131
299
|
send(options: ZinduaSendOptions): Promise<ZinduaSendResult>;
|
package/dist/client.js
CHANGED
|
@@ -4,7 +4,7 @@ exports.Zindua = void 0;
|
|
|
4
4
|
const errors_1 = require("./errors");
|
|
5
5
|
const validate_1 = require("./validate");
|
|
6
6
|
const whatsapp_anti_ban_1 = require("./whatsapp-anti-ban");
|
|
7
|
-
const SDK_VERSION = "1.
|
|
7
|
+
const SDK_VERSION = "1.3.0";
|
|
8
8
|
const USER_AGENT = `Zindua-JS/${SDK_VERSION}`;
|
|
9
9
|
function buildPayload(options, channel) {
|
|
10
10
|
const to = (0, validate_1.validateRecipient)(options.to, channel);
|
|
@@ -35,6 +35,8 @@ function buildPayload(options, channel) {
|
|
|
35
35
|
payload.replyTo = replyTo;
|
|
36
36
|
if (attachments)
|
|
37
37
|
payload.attachments = attachments;
|
|
38
|
+
if (options.validate === true)
|
|
39
|
+
payload.validate = true;
|
|
38
40
|
}
|
|
39
41
|
return payload;
|
|
40
42
|
}
|
|
@@ -98,6 +100,119 @@ class Zindua {
|
|
|
98
100
|
: USER_AGENT;
|
|
99
101
|
this.autoPaceWhatsapp = options.autoPaceWhatsapp !== false;
|
|
100
102
|
}
|
|
103
|
+
/** PushMirror — 1-tap push challenges (emoji / approve / digit) */
|
|
104
|
+
pushMirror = {
|
|
105
|
+
create: async (options) => {
|
|
106
|
+
const payload = {
|
|
107
|
+
to: options.to,
|
|
108
|
+
userId: options.userId,
|
|
109
|
+
userExternalId: options.userExternalId,
|
|
110
|
+
type: options.type,
|
|
111
|
+
purpose: options.purpose,
|
|
112
|
+
context: options.context,
|
|
113
|
+
channel: options.channel,
|
|
114
|
+
fallbackChannel: options.fallbackChannel ?? options.fallback?.channel,
|
|
115
|
+
fallback: options.fallback,
|
|
116
|
+
ttlSeconds: options.ttlSeconds,
|
|
117
|
+
};
|
|
118
|
+
return this.request("POST", "challenges", payload);
|
|
119
|
+
},
|
|
120
|
+
getStatus: async (challengeId) => {
|
|
121
|
+
return this.request("GET", `challenges/${encodeURIComponent(challengeId)}`);
|
|
122
|
+
},
|
|
123
|
+
respond: async (challengeId, value) => {
|
|
124
|
+
return this.request("POST", `challenges/${encodeURIComponent(challengeId)}/respond`, { value });
|
|
125
|
+
},
|
|
126
|
+
/**
|
|
127
|
+
* SSE listener for challenge status (requires API key — server-side or trusted runtime).
|
|
128
|
+
* Resolves when status is approved | denied | expired | failed.
|
|
129
|
+
*/
|
|
130
|
+
listen: async (challengeId, handlers) => {
|
|
131
|
+
const id = challengeId.trim();
|
|
132
|
+
if (!id) {
|
|
133
|
+
throw new errors_1.ZinduaError("challengeId is required.", { status: 0, code: "MISSING_FIELDS" });
|
|
134
|
+
}
|
|
135
|
+
const url = `${this.apiBase}/challenges/${encodeURIComponent(id)}/stream`;
|
|
136
|
+
const res = await fetch(url, {
|
|
137
|
+
method: "GET",
|
|
138
|
+
headers: this.buildHeaders(),
|
|
139
|
+
signal: handlers?.signal,
|
|
140
|
+
redirect: "error",
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const text = await res.text();
|
|
144
|
+
let body = {};
|
|
145
|
+
try {
|
|
146
|
+
body = text ? JSON.parse(text) : {};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* ignore */
|
|
150
|
+
}
|
|
151
|
+
throw parseApiError(res.status, body);
|
|
152
|
+
}
|
|
153
|
+
if (!res.body) {
|
|
154
|
+
throw new errors_1.ZinduaError("SSE stream unavailable.", { status: 0, code: "INVALID_RESPONSE" });
|
|
155
|
+
}
|
|
156
|
+
const reader = res.body.getReader();
|
|
157
|
+
const decoder = new TextDecoder();
|
|
158
|
+
let buffer = "";
|
|
159
|
+
while (true) {
|
|
160
|
+
const { done, value } = await reader.read();
|
|
161
|
+
if (done)
|
|
162
|
+
break;
|
|
163
|
+
buffer += decoder.decode(value, { stream: true });
|
|
164
|
+
const chunks = buffer.split("\n\n");
|
|
165
|
+
buffer = chunks.pop() || "";
|
|
166
|
+
for (const chunk of chunks) {
|
|
167
|
+
const line = chunk
|
|
168
|
+
.split("\n")
|
|
169
|
+
.find((l) => l.startsWith("data:"));
|
|
170
|
+
if (!line)
|
|
171
|
+
continue;
|
|
172
|
+
const raw = line.slice(5).trim();
|
|
173
|
+
let event;
|
|
174
|
+
try {
|
|
175
|
+
event = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!event.status || event.status === "connected" || event.status === "pending") {
|
|
181
|
+
handlers?.onEvent?.(event);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
handlers?.onEvent?.(event);
|
|
185
|
+
if (event.status === "approved")
|
|
186
|
+
handlers?.onApproved?.();
|
|
187
|
+
if (event.status === "denied")
|
|
188
|
+
handlers?.onDenied?.();
|
|
189
|
+
if (event.status === "expired")
|
|
190
|
+
handlers?.onExpired?.();
|
|
191
|
+
if (["approved", "denied", "expired", "failed"].includes(event.status)) {
|
|
192
|
+
try {
|
|
193
|
+
await reader.cancel();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* ignore */
|
|
197
|
+
}
|
|
198
|
+
return event;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
throw new errors_1.ZinduaError("SSE stream ended without a terminal status.", {
|
|
203
|
+
status: 0,
|
|
204
|
+
code: "STREAM_ENDED",
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
/** @deprecated Prefer `pushMirror` — same API */
|
|
209
|
+
confirm = this.pushMirror;
|
|
210
|
+
/** Zindua Devices (FCM / APNs / WebPush Token Registration) API */
|
|
211
|
+
devices = {
|
|
212
|
+
register: async (options) => {
|
|
213
|
+
return this.request("POST", "devices", options);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
101
216
|
/** Returns true when using a znd_test_ key (sandbox / no real delivery). */
|
|
102
217
|
isTestMode() {
|
|
103
218
|
return this.apiKey.startsWith("znd_test_");
|
|
@@ -143,6 +258,38 @@ class Zindua {
|
|
|
143
258
|
limits: data.limits,
|
|
144
259
|
};
|
|
145
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Verify an email address (format, MX, disposable, typo suggestion).
|
|
263
|
+
* POST /api/v1/email/verify — protects your BYO provider reputation.
|
|
264
|
+
*/
|
|
265
|
+
async verifyEmail(email) {
|
|
266
|
+
const trimmed = email.trim();
|
|
267
|
+
if (!trimmed) {
|
|
268
|
+
throw new errors_1.ZinduaError("email is required.", { status: 0, code: "MISSING_FIELDS" });
|
|
269
|
+
}
|
|
270
|
+
const data = await this.request("POST", "email/verify", { email: trimmed });
|
|
271
|
+
if (data.ok !== true || typeof data.email !== "string") {
|
|
272
|
+
throw new errors_1.ZinduaError("API response missing verify payload.", {
|
|
273
|
+
status: 200,
|
|
274
|
+
code: "INVALID_RESPONSE",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
email: data.email,
|
|
280
|
+
status: data.status === "risky" || data.status === "invalid" ? data.status : "valid",
|
|
281
|
+
score: typeof data.score === "number" ? data.score : 0,
|
|
282
|
+
formatOk: Boolean(data.formatOk),
|
|
283
|
+
mxFound: Boolean(data.mxFound),
|
|
284
|
+
disposable: Boolean(data.disposable),
|
|
285
|
+
didYouMean: typeof data.didYouMean === "string" ? data.didYouMean : null,
|
|
286
|
+
domain: typeof data.domain === "string" ? data.domain : null,
|
|
287
|
+
reasons: Array.isArray(data.reasons) ? data.reasons : [],
|
|
288
|
+
suppressed: Boolean(data.suppressed),
|
|
289
|
+
suppressionReason: typeof data.suppressionReason === "string" ? data.suppressionReason : null,
|
|
290
|
+
deliverable: Boolean(data.deliverable),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
146
293
|
/** Delivery status for a logId returned by send() (GET /logs/{logId}). */
|
|
147
294
|
async getLog(logId) {
|
|
148
295
|
const trimmed = logId.trim();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Zindua } from "./client";
|
|
2
|
-
export type { ZinduaClientOptions, ZinduaConnectResult, ZinduaProjectInfo, ZinduaSendContext, ZinduaSendOptions, ZinduaSendResult, ZinduaTemplateInfo, SendChannel, } from "./client";
|
|
2
|
+
export type { ZinduaClientOptions, ZinduaConnectResult, ZinduaEmailVerifyResult, ZinduaProjectInfo, ZinduaSendContext, ZinduaSendOptions, ZinduaSendResult, ZinduaTemplateInfo, SendChannel, } from "./client";
|
|
3
3
|
export { ZinduaError } from "./errors";
|
|
4
4
|
export type { ZinduaErrorCode } from "./errors";
|
|
5
5
|
export { DEFAULT_API_BASE, LIMITS } from "./validate";
|