@wazapi/sdk 0.1.0 → 0.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 +67 -0
- package/dist/error.d.ts +7 -0
- package/dist/error.js +18 -0
- package/dist/types.d.ts +93 -0
- package/package.json +16 -5
package/README.md
CHANGED
|
@@ -108,6 +108,27 @@ operation is created):
|
|
|
108
108
|
| `template_parameter_count_mismatch` | `parameters.length` ≠ `body_parameter_count`. |
|
|
109
109
|
| `template_format_unsupported` | Needs media/variable header, named params, buttons. |
|
|
110
110
|
|
|
111
|
+
Compliance gates on template sends (rejected synchronously with 403/422, and
|
|
112
|
+
re-checked by the worker — the same codes can appear as `error.code` on the
|
|
113
|
+
polled operation):
|
|
114
|
+
|
|
115
|
+
| Code | Meaning |
|
|
116
|
+
| --------------------------------------- | ------------------------------------------------------------------------ |
|
|
117
|
+
| `marketing_sends_disabled` | MARKETING sending is off for the company (an owner enables it in the dashboard) or platform-wide. |
|
|
118
|
+
| `recipient_opted_out` | The recipient opted out (reply keyword, native WhatsApp control, or manual suppression). Filter on `contact.marketing_opted_out`. |
|
|
119
|
+
| `duplicate_template_send` | Same template already sent to this phone in the last 24h. |
|
|
120
|
+
| `template_frequency_cap_exceeded` | Per-contact MARKETING cap (1/24h, 3/7 days). |
|
|
121
|
+
| `company_daily_marketing_cap_exceeded` | Company-wide daily MARKETING cap. |
|
|
122
|
+
| `channel_marketing_paused` | Meta flagged the number's quality; MARKETING is paused temporarily. |
|
|
123
|
+
| `template_quality_blocked` | This specific template dropped to RED quality on Meta. |
|
|
124
|
+
| `send_pacing_timeout` | Operation-only: delivery was deferred by per-channel pacing for too long. |
|
|
125
|
+
|
|
126
|
+
Delivery is paced per channel to protect number quality, so an accepted `202`
|
|
127
|
+
can take longer to complete under load — poll the operation instead of
|
|
128
|
+
re-sending. AUTHENTICATION (OTP) templates are exempt from opt-outs and caps.
|
|
129
|
+
Use `error.isComplianceBlocked` to branch on this whole family, and the
|
|
130
|
+
`SendComplianceErrorCode` type to narrow `operation.error.code`.
|
|
131
|
+
|
|
111
132
|
## Contacts
|
|
112
133
|
|
|
113
134
|
```ts
|
|
@@ -119,6 +140,52 @@ await wazapi.upsertContactByExternalId('customer-1847', {
|
|
|
119
140
|
})
|
|
120
141
|
```
|
|
121
142
|
|
|
143
|
+
## Webhooks
|
|
144
|
+
|
|
145
|
+
Wazapi POSTs events to the HTTPS endpoint you register in **Settings → Wazapi API**.
|
|
146
|
+
The SDK ships the payload types; the union is discriminated on `type`, so narrowing
|
|
147
|
+
`event.type` narrows `event.data` with it.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import type { WazapiWebhookEvent } from '@wazapi/sdk'
|
|
151
|
+
|
|
152
|
+
function handle(event: WazapiWebhookEvent) {
|
|
153
|
+
switch (event.type) {
|
|
154
|
+
case 'message.received':
|
|
155
|
+
return reply(event.data.contact_uuid, event.data.content)
|
|
156
|
+
case 'message.status.updated':
|
|
157
|
+
return markDelivered(event.data.message_uuid, event.data.status)
|
|
158
|
+
case 'flow.execution.updated':
|
|
159
|
+
return event.data.error ? alert(event.data.error.code) : done()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Verify the signature over the **raw** body, before parsing:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
168
|
+
|
|
169
|
+
function verify(rawBody: string, headers: Record<string, string>, secret: string) {
|
|
170
|
+
const expected = createHmac('sha256', secret)
|
|
171
|
+
.update(`${headers['wazapi-timestamp']}.${rawBody}`)
|
|
172
|
+
.digest('hex')
|
|
173
|
+
const received = (headers['wazapi-signature'] ?? '').replace(/^v1=/, '')
|
|
174
|
+
const a = Buffer.from(expected)
|
|
175
|
+
const b = Buffer.from(received)
|
|
176
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Delivery is at-least-once — deduplicate on `event.id` (also sent as the
|
|
181
|
+
`Wazapi-Event-Id` header). Wazapi treats only `2xx` as success and retries after
|
|
182
|
+
1min, 5min, 30min, 2h, 12h and 24h.
|
|
183
|
+
|
|
184
|
+
Two fields are added at delivery time when they apply: `data.external_id`, your own
|
|
185
|
+
identifier echoed back when the contact was linked via `upsertContactByExternalId`,
|
|
186
|
+
and `data.tracking`, the allowlisted attribution subset (`utm_*`, click IDs, ad
|
|
187
|
+
referral fields). No other custom field key is ever forwarded.
|
|
188
|
+
|
|
122
189
|
## Configuration
|
|
123
190
|
|
|
124
191
|
```ts
|
package/dist/error.d.ts
CHANGED
|
@@ -21,4 +21,11 @@ export declare class WazapiError extends Error {
|
|
|
21
21
|
});
|
|
22
22
|
/** True for 429 rate-limit responses. */
|
|
23
23
|
get isRateLimited(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* True when the send was refused by a compliance gate (opt-out, frequency
|
|
26
|
+
* cap, marketing policy, channel/template quality pause) rather than by a
|
|
27
|
+
* malformed request. Retrying without changing the audience will not help —
|
|
28
|
+
* fix the recipient list or the account policy instead.
|
|
29
|
+
*/
|
|
30
|
+
get isComplianceBlocked(): boolean;
|
|
24
31
|
}
|
package/dist/error.js
CHANGED
|
@@ -24,4 +24,22 @@ export class WazapiError extends Error {
|
|
|
24
24
|
get isRateLimited() {
|
|
25
25
|
return this.status === 429;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* True when the send was refused by a compliance gate (opt-out, frequency
|
|
29
|
+
* cap, marketing policy, channel/template quality pause) rather than by a
|
|
30
|
+
* malformed request. Retrying without changing the audience will not help —
|
|
31
|
+
* fix the recipient list or the account policy instead.
|
|
32
|
+
*/
|
|
33
|
+
get isComplianceBlocked() {
|
|
34
|
+
return [
|
|
35
|
+
'marketing_sends_disabled',
|
|
36
|
+
'template_sends_disabled',
|
|
37
|
+
'recipient_opted_out',
|
|
38
|
+
'duplicate_template_send',
|
|
39
|
+
'template_frequency_cap_exceeded',
|
|
40
|
+
'company_daily_marketing_cap_exceeded',
|
|
41
|
+
'channel_marketing_paused',
|
|
42
|
+
'template_quality_blocked',
|
|
43
|
+
].includes(this.code);
|
|
44
|
+
}
|
|
27
45
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface Contact {
|
|
|
30
30
|
email: string | null;
|
|
31
31
|
tags: string[];
|
|
32
32
|
custom_fields: Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* True when the contact opted out of marketing messages (reply keyword such
|
|
35
|
+
* as PARAR/STOP, the native WhatsApp control, or manual suppression).
|
|
36
|
+
* Sending a MARKETING template to an opted-out contact fails with
|
|
37
|
+
* `recipient_opted_out` — filter your audience on this before a campaign.
|
|
38
|
+
*/
|
|
39
|
+
marketing_opted_out: boolean;
|
|
40
|
+
marketing_opt_out_at: string | null;
|
|
33
41
|
last_interaction_at: string | null;
|
|
34
42
|
created_at: string | null;
|
|
35
43
|
updated_at: string | null;
|
|
@@ -91,6 +99,13 @@ export interface Message {
|
|
|
91
99
|
updated_at: string | null;
|
|
92
100
|
}
|
|
93
101
|
export type OperationStatus = 'queued' | 'processing' | 'waiting' | 'succeeded' | 'failed';
|
|
102
|
+
/**
|
|
103
|
+
* Compliance codes the send guard can answer with. They surface both as a
|
|
104
|
+
* synchronous 4xx on `POST /messages` (403 for the two `*_disabled` codes,
|
|
105
|
+
* 422 for the rest) and as `error.code` on the polled operation —
|
|
106
|
+
* `send_pacing_timeout` only ever appears on the operation.
|
|
107
|
+
*/
|
|
108
|
+
export type SendComplianceErrorCode = 'marketing_sends_disabled' | 'template_sends_disabled' | 'recipient_opted_out' | 'duplicate_template_send' | 'template_frequency_cap_exceeded' | 'company_daily_marketing_cap_exceeded' | 'channel_marketing_paused' | 'template_quality_blocked' | 'send_pacing_timeout';
|
|
94
109
|
export interface Operation {
|
|
95
110
|
uuid: string;
|
|
96
111
|
type: 'message.send' | 'flow.execute';
|
|
@@ -143,3 +158,81 @@ export interface AcceptedResult {
|
|
|
143
158
|
/** True when the request replayed a previously accepted idempotent operation. */
|
|
144
159
|
replayed: boolean;
|
|
145
160
|
}
|
|
161
|
+
export type WebhookEventType = 'message.received' | 'message.status.updated' | 'conversation.created' | 'conversation.updated' | 'flow.execution.updated' | 'webhook.test';
|
|
162
|
+
/**
|
|
163
|
+
* Allowlisted attribution subset of the contact's and conversation's custom
|
|
164
|
+
* fields. Conversation values win over contact values (last touch over first
|
|
165
|
+
* touch); no other custom field key is ever forwarded.
|
|
166
|
+
*/
|
|
167
|
+
export type WebhookTracking = Record<string, unknown>;
|
|
168
|
+
/**
|
|
169
|
+
* Added at delivery time whenever the payload carries a `contact_uuid` and that
|
|
170
|
+
* contact was linked through `PUT /contacts/external/{external_id}`.
|
|
171
|
+
*/
|
|
172
|
+
interface WebhookContactRefs {
|
|
173
|
+
external_id?: string;
|
|
174
|
+
tracking?: WebhookTracking;
|
|
175
|
+
}
|
|
176
|
+
export interface MessageReceivedData extends WebhookContactRefs {
|
|
177
|
+
message_uuid: string;
|
|
178
|
+
conversation_uuid: string;
|
|
179
|
+
contact_uuid: string;
|
|
180
|
+
channel_uuid: string;
|
|
181
|
+
type: string;
|
|
182
|
+
content: Record<string, unknown>;
|
|
183
|
+
status: string;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* `provider_message_id` comes from the provider status callback; `operation_uuid`
|
|
187
|
+
* and `conversation_uuid` are present when the message was sent through the API.
|
|
188
|
+
*/
|
|
189
|
+
export interface MessageStatusUpdatedData {
|
|
190
|
+
message_uuid: string;
|
|
191
|
+
status: string;
|
|
192
|
+
provider_message_id?: string | null;
|
|
193
|
+
operation_uuid?: string;
|
|
194
|
+
conversation_uuid?: string;
|
|
195
|
+
tracking?: WebhookTracking;
|
|
196
|
+
}
|
|
197
|
+
export interface ConversationCreatedData extends WebhookContactRefs {
|
|
198
|
+
conversation_uuid: string;
|
|
199
|
+
contact_uuid: string;
|
|
200
|
+
channel_uuid: string;
|
|
201
|
+
status: string;
|
|
202
|
+
}
|
|
203
|
+
/** `unread_count` is only present when an inbound message triggered the update. */
|
|
204
|
+
export interface ConversationUpdatedData extends ConversationCreatedData {
|
|
205
|
+
unread_count?: number;
|
|
206
|
+
last_message_at?: string | null;
|
|
207
|
+
}
|
|
208
|
+
export interface FlowExecutionUpdatedData {
|
|
209
|
+
operation_uuid: string;
|
|
210
|
+
operation_type: 'flow.execute';
|
|
211
|
+
status: OperationStatus;
|
|
212
|
+
result: Record<string, unknown> | null;
|
|
213
|
+
error: {
|
|
214
|
+
code: string;
|
|
215
|
+
message: string | null;
|
|
216
|
+
} | null;
|
|
217
|
+
}
|
|
218
|
+
export interface WebhookTestData {
|
|
219
|
+
integration_uuid: string;
|
|
220
|
+
message: string;
|
|
221
|
+
}
|
|
222
|
+
interface WebhookEnvelope<TType extends WebhookEventType, TData> {
|
|
223
|
+
/** Unique event id. Delivery is at-least-once — deduplicate on this value. */
|
|
224
|
+
id: string;
|
|
225
|
+
type: TType;
|
|
226
|
+
api_version: 'v1';
|
|
227
|
+
occurred_at: string;
|
|
228
|
+
data: TData;
|
|
229
|
+
}
|
|
230
|
+
export type MessageReceivedEvent = WebhookEnvelope<'message.received', MessageReceivedData>;
|
|
231
|
+
export type MessageStatusUpdatedEvent = WebhookEnvelope<'message.status.updated', MessageStatusUpdatedData>;
|
|
232
|
+
export type ConversationCreatedEvent = WebhookEnvelope<'conversation.created', ConversationCreatedData>;
|
|
233
|
+
export type ConversationUpdatedEvent = WebhookEnvelope<'conversation.updated', ConversationUpdatedData>;
|
|
234
|
+
export type FlowExecutionUpdatedEvent = WebhookEnvelope<'flow.execution.updated', FlowExecutionUpdatedData>;
|
|
235
|
+
export type WebhookTestEvent = WebhookEnvelope<'webhook.test', WebhookTestData>;
|
|
236
|
+
/** Discriminated on `type` — narrow it and `data` narrows with it. */
|
|
237
|
+
export type WazapiWebhookEvent = MessageReceivedEvent | MessageStatusUpdatedEvent | ConversationCreatedEvent | ConversationUpdatedEvent | FlowExecutionUpdatedEvent | WebhookTestEvent;
|
|
238
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wazapi/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Official Node.js SDK for the Wazapi Public API v1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
"homepage": "https://wazapi.io",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/wazap-ai/
|
|
14
|
-
"directory": "apps/app/sdk"
|
|
13
|
+
"url": "git+https://github.com/wazap-ai/wazapi-node.git"
|
|
15
14
|
},
|
|
16
15
|
"main": "./dist/index.js",
|
|
17
16
|
"module": "./dist/index.js",
|
|
@@ -22,7 +21,10 @@
|
|
|
22
21
|
"import": "./dist/index.js"
|
|
23
22
|
}
|
|
24
23
|
},
|
|
25
|
-
"files": [
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
26
28
|
"engines": {
|
|
27
29
|
"node": ">=18"
|
|
28
30
|
},
|
|
@@ -31,8 +33,17 @@
|
|
|
31
33
|
"test": "npm run build && node --test --experimental-strip-types test/*.test.ts",
|
|
32
34
|
"prepublishOnly": "npm run build"
|
|
33
35
|
},
|
|
34
|
-
"keywords": [
|
|
36
|
+
"keywords": [
|
|
37
|
+
"wazapi",
|
|
38
|
+
"whatsapp",
|
|
39
|
+
"api",
|
|
40
|
+
"sdk",
|
|
41
|
+
"messaging"
|
|
42
|
+
],
|
|
35
43
|
"devDependencies": {
|
|
36
44
|
"typescript": "^5.6.0"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/wazap-ai/wazapi-node/issues"
|
|
37
48
|
}
|
|
38
49
|
}
|