zapcore-sdk 0.1.1__py3-none-any.whl

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.
zapcore/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """SDK oficial da API REST v2 do zapCore (WhatsApp).
2
+
3
+ from zapcore import Client
4
+ zap = Client("https://api.seudominio.com", token="TOKEN_DA_INSTANCIA")
5
+ zap.send_text("5527999999999", "Olá!")
6
+ """
7
+ from ._client import ApiResponse, Client, Page, SDK_VERSION, Transport, destino, parse_retry_after, urllib_transport
8
+ from ._errors import WebhookVerificationError, ZapcoreError
9
+ from ._generated import API_VERSION, SPEC_SHA256
10
+ from .webhook import parse_webhook, verify_webhook
11
+
12
+ __all__ = [
13
+ "Client", "ApiResponse", "Page", "Transport", "ZapcoreError", "WebhookVerificationError",
14
+ "verify_webhook", "parse_webhook", "destino", "parse_retry_after", "urllib_transport",
15
+ "SDK_VERSION", "API_VERSION", "SPEC_SHA256",
16
+ ]
17
+ __version__ = SDK_VERSION
zapcore/_client.py ADDED
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import email.utils
5
+ import json
6
+ import random
7
+ import re
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
14
+ from urllib.parse import urlencode, urlparse
15
+
16
+ from ._errors import ZapcoreError
17
+ from ._generated import _Operations
18
+
19
+ SDK_VERSION = "0.1.1" # x-release-please-version
20
+
21
+ #: (status, headers com chave minúscula, corpo)
22
+ TransportResponse = Tuple[int, Dict[str, str], bytes]
23
+ #: transport(method, url, headers, body, timeout) -> TransportResponse. Falha de rede: levante OSError.
24
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], TransportResponse]
25
+
26
+ _RETENTAVEIS_5XX = {502, 503, 504}
27
+ _POR_STATUS = {400: "bad_request", 401: "unauthorized", 403: "forbidden", 404: "not_found",
28
+ 409: "conflict", 423: "session_locked", 429: "rate_limited"}
29
+
30
+
31
+ @dataclass
32
+ class ApiResponse:
33
+ """Resposta completa, para quem precisa de ``meta``, status ou headers."""
34
+
35
+ data: Any
36
+ meta: Dict[str, Any]
37
+ status: int
38
+ headers: Dict[str, str]
39
+ #: ``True`` quando o servidor repetiu a resposta guardada de uma Idempotency-Key.
40
+ idempotent_replay: bool = False
41
+
42
+
43
+ @dataclass
44
+ class Page:
45
+ items: List[Any]
46
+ #: Repasse sem interpretar. ``None`` = acabou.
47
+ next_cursor: Optional[str]
48
+ meta: Dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ class _SemRedirect(urllib.request.HTTPRedirectHandler):
52
+ """Nunca segue redirect: o urllib reenviaria o header ``token`` para o destino do Location.
53
+
54
+ Devolver ``None`` faz o 3xx subir como HTTPError, que o transporte entrega ao cliente.
55
+ """
56
+
57
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
58
+ return None
59
+
60
+
61
+ _OPENER = urllib.request.build_opener(_SemRedirect())
62
+
63
+
64
+ #: Teto do download de imagem por URL (o motor também limita o que aceita).
65
+ MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024
66
+ #: Tipos aceitos no download por URL (sem svg: pode carregar script).
67
+ _IMAGENS_OK = {"image/jpeg", "image/png", "image/webp", "image/gif"}
68
+ #: ``http://`` só é aceito sem opt-in nestes hosts: o tráfego não sai da máquina.
69
+ _HOSTS_LOCAIS = {"localhost", "127.0.0.1", "::1"}
70
+
71
+
72
+ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float, max_bytes: Optional[int] = None) -> TransportResponse:
73
+ """Transporte padrão: só biblioteca padrão, sem seguir redirect.
74
+
75
+ ``max_bytes`` (opcional) corta a leitura do corpo: estourou, levanta :class:`ZapcoreError` 413.
76
+ """
77
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
78
+ try:
79
+ with _OPENER.open(req, timeout=timeout) as r: # noqa: S310 (URL vem do dono do cliente)
80
+ return r.status, {k.lower(): v for k, v in r.headers.items()}, _ler(r, max_bytes)
81
+ except urllib.error.HTTPError as e:
82
+ return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, _ler(e, max_bytes) or b""
83
+
84
+
85
+ def _ler(r: Any, max_bytes: Optional[int]) -> bytes:
86
+ if max_bytes is None:
87
+ return r.read()
88
+ declarado = r.headers.get("content-length")
89
+ if declarado and declarado.isdigit() and int(declarado) > max_bytes:
90
+ raise _estouro(max_bytes)
91
+ dados = r.read(max_bytes + 1)
92
+ if len(dados) > max_bytes:
93
+ raise _estouro(max_bytes)
94
+ return dados
95
+
96
+
97
+ def _estouro(max_bytes: int) -> "ZapcoreError":
98
+ return ZapcoreError(f"zapcore: a imagem passa de {max_bytes} bytes; baixe você e passe os bytes", status=413, code="bad_request")
99
+
100
+
101
+ def _exige_canal_cifrado(base_url: str, permitir_http: bool) -> None:
102
+ """``http://`` para host remoto mandaria o token da instância em claro."""
103
+ u = urlparse(base_url)
104
+ if u.scheme.lower() == "https" or permitir_http or (u.hostname or "").lower() in _HOSTS_LOCAIS:
105
+ return
106
+ raise ValueError(
107
+ "zapcore: base_url em http:// para um host remoto mandaria o token em claro. Use https://, "
108
+ "ou allow_insecure_http=True para uma rede interna sem TLS (http:// é aceito sem opt-in "
109
+ "só em localhost, 127.0.0.1 e [::1])."
110
+ )
111
+
112
+
113
+ def parse_retry_after(valor: Optional[str], agora: Optional[float] = None) -> Optional[float]:
114
+ """``Retry-After`` em segundos (número ou data HTTP)."""
115
+ if not valor:
116
+ return None
117
+ v = valor.strip()
118
+ if re.fullmatch(r"\d+(\.\d+)?", v):
119
+ return float(v)
120
+ try:
121
+ quando = email.utils.parsedate_to_datetime(v).timestamp()
122
+ except (TypeError, ValueError):
123
+ return None
124
+ return max(0.0, float(int(quando - (time.time() if agora is None else agora) + 0.999)))
125
+
126
+
127
+ def destino(phone: str) -> str:
128
+ """Número com pontuação vira só dígitos; JID (``...@g.us``) passa intacto."""
129
+ return phone.strip() if "@" in phone else re.sub(r"\D+", "", phone)
130
+
131
+
132
+ def _limpar_data_url(u: str) -> str:
133
+ """O motor recusa (400) parâmetro no mime do data URL."""
134
+ m = re.match(r"^data:([^;,]+)[^,]*?(;base64)?,", u, re.I)
135
+ if not m:
136
+ return u
137
+ return f"data:{m.group(1)}{m.group(2) or ''},{u[m.end():]}"
138
+
139
+
140
+ def _backoff(tentativa: int) -> float:
141
+ base = min(8.0, 0.5 * (2 ** tentativa))
142
+ return base / 2 + random.random() * (base / 2)
143
+
144
+
145
+ class Client(_Operations):
146
+ """Cliente da API v2 do zapCore.
147
+
148
+ Todo método gerado (um por ``operationId``, ex. ``post_chat_send_text``) e todo atalho
149
+ (``send_text``, ``send_image``…) devolve o ``data`` do envelope. Para ``meta``, status e
150
+ headers use :meth:`request`.
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ base_url: str,
156
+ token: Optional[str] = None,
157
+ *,
158
+ admin_token: Optional[str] = None,
159
+ max_retries: int = 2,
160
+ max_retry_wait: float = 60.0,
161
+ timeout: float = 30.0,
162
+ auto_idempotency: bool = True,
163
+ headers: Optional[Mapping[str, str]] = None,
164
+ transport: Optional[Transport] = None,
165
+ sleep: Callable[[float], None] = time.sleep,
166
+ idempotency_key_factory: Callable[[], str] = lambda: str(uuid.uuid4()),
167
+ allow_insecure_http: bool = False,
168
+ ) -> None:
169
+ if not re.match(r"^https?://", base_url or "", re.I):
170
+ raise ValueError("zapcore: base_url precisa ser uma URL http(s), ex. https://api.seudominio.com")
171
+ _exige_canal_cifrado(base_url, allow_insecure_http)
172
+ self.base_url = re.sub(r"/api/v2$", "", base_url.rstrip("/"))
173
+ self._token = token
174
+ self._admin_token = admin_token
175
+ self._max_retries = max(0, max_retries)
176
+ self._max_retry_wait = max_retry_wait
177
+ self._timeout = timeout
178
+ self._auto_idem = auto_idempotency
179
+ self._headers = dict(headers or {})
180
+ self._transport: Transport = transport or urllib_transport
181
+ self._sleep = sleep
182
+ self._nova_chave = idempotency_key_factory
183
+
184
+ # ------------------------------------------------------------ baixo nível
185
+
186
+ def _call(self, method: str, path: str, **kw: Any) -> Any:
187
+ return self.request(method, path, **kw).data
188
+
189
+ def request(
190
+ self,
191
+ method: str,
192
+ path: str,
193
+ *,
194
+ query: Optional[Mapping[str, Any]] = None,
195
+ body: Any = None,
196
+ send: bool = False,
197
+ admin: bool = False,
198
+ idempotency_key: Optional[str] = None,
199
+ headers: Optional[Mapping[str, str]] = None,
200
+ timeout: Optional[float] = None,
201
+ max_retries: Optional[int] = None,
202
+ ) -> ApiResponse:
203
+ """Chamada de baixo nível. Levanta :class:`ZapcoreError` em HTTP >= 400."""
204
+ url = self.base_url + path + _montar_query(query)
205
+ h: Dict[str, str] = {"Accept": "application/json", "User-Agent": f"zapcore-sdk-python/{SDK_VERSION}", **self._headers}
206
+ if admin:
207
+ if not self._admin_token:
208
+ raise ValueError("zapcore: rota /admin exige admin_token no cliente")
209
+ h["Authorization"] = self._admin_token
210
+ else:
211
+ if not self._token:
212
+ raise ValueError("zapcore: esta rota exige token (token da instância) no cliente")
213
+ h["token"] = self._token
214
+ dados: Optional[bytes] = None
215
+ if body is not None:
216
+ dados = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
217
+ h["Content-Type"] = "application/json"
218
+ # Uma chave por chamada LÓGICA, reaproveitada em todo retry: retry nunca duplica mensagem.
219
+ chave = (idempotency_key or (self._nova_chave() if self._auto_idem else None)) if send else None
220
+ if chave:
221
+ h["Idempotency-Key"] = chave
222
+ h.update(headers or {})
223
+
224
+ seguro = method == "GET" or chave is not None
225
+ limite = self._max_retries if max_retries is None else max(0, max_retries)
226
+ prazo = self._timeout if timeout is None else timeout
227
+
228
+ tentativa = 0
229
+ while True:
230
+ try:
231
+ status, rh, cru = self._transport(method, url, h, dados, prazo)
232
+ except OSError as e:
233
+ if seguro and tentativa < limite:
234
+ self._sleep(_backoff(tentativa))
235
+ tentativa += 1
236
+ continue
237
+ raise ZapcoreError(f"zapcore: falha de rede em {method} {path}: {e}", status=0, code="network_error") from e
238
+
239
+ texto = cru.decode("utf-8", errors="replace") if cru else ""
240
+ try:
241
+ corpo = json.loads(texto) if texto else None
242
+ except ValueError:
243
+ corpo = None
244
+
245
+ if 200 <= status < 300:
246
+ env = corpo if isinstance(corpo, dict) else {}
247
+ return ApiResponse(
248
+ data=env["data"] if "data" in env else corpo,
249
+ meta=env.get("meta") or {},
250
+ status=status,
251
+ headers=rh,
252
+ idempotent_replay=rh.get("idempotent-replay") == "true",
253
+ )
254
+
255
+ if 300 <= status < 400:
256
+ raise ZapcoreError(
257
+ f"zapcore: {method} {path} respondeu redirect ({status}). O SDK não segue redirect "
258
+ "para não vazar o token; confira o base_url (http→https? barra final?)",
259
+ status=status, code="redirect_refused", request_id=rh.get("request-id"),
260
+ )
261
+ erro = _montar_erro(status, rh, corpo, texto, method, path)
262
+ # 409 idempotency_in_progress: a MESMA requisição ainda está em voo no motor; repetir a
263
+ # MESMA chave (``h`` é reaproveitado) após o Retry-After. Teto = ``max_retries``.
264
+ em_voo = status == 409 and chave is not None and erro.code == "idempotency_in_progress"
265
+ if tentativa < limite and (status == 429 or em_voo or (status in _RETENTAVEIS_5XX and seguro)):
266
+ espera = erro.retry_after if erro.retry_after is not None else _backoff(tentativa)
267
+ # Janela de hora/dia estourada: esperar 40 min dentro de um request é travar o processo.
268
+ if espera > self._max_retry_wait:
269
+ raise erro
270
+ self._sleep(espera)
271
+ tentativa += 1
272
+ continue
273
+ raise erro
274
+
275
+ def paginate(self, path: str, query: Optional[Mapping[str, Any]] = None, **kw: Any) -> Iterator[Any]:
276
+ """Itera uma listagem por cursor; para quando ``next_cursor`` some ou a página volta vazia."""
277
+ cursor: Optional[str] = None
278
+ while True:
279
+ q = dict(query or {})
280
+ if cursor:
281
+ q["cursor"] = cursor
282
+ r = self.request("GET", path, query=q, **kw)
283
+ itens = r.data if isinstance(r.data, list) else []
284
+ yield from itens
285
+ cursor = r.meta.get("next_cursor") if itens else None
286
+ if not cursor:
287
+ return
288
+
289
+ # ------------------------------------------------------------ atalhos
290
+
291
+ def send_text(self, phone: str, text: str, **extra: Any) -> Dict[str, Any]:
292
+ """Envia texto. ``extra``: campos do corpo (``LinkPreview``…) e opções (``idempotency_key``…)."""
293
+ corpo, opts = _separar(extra)
294
+ return self.post_chat_send_text({**corpo, "Phone": destino(phone), "Body": text}, **opts)
295
+
296
+ def send_image(self, phone: str, image: Union[str, bytes], caption: Optional[str] = None, *, mime_type: str = "image/jpeg", **extra: Any) -> Dict[str, Any]:
297
+ """Envia imagem: data URL, base64 puro, URL http(s) (o SDK baixa) ou bytes."""
298
+ corpo, opts = _separar(extra)
299
+ corpo.update({"Phone": destino(phone), "Image": self._data_url(image, mime_type)})
300
+ if caption is not None:
301
+ corpo["Caption"] = caption
302
+ return self.post_chat_send_image(corpo, **opts)
303
+
304
+ def send_buttons(self, phone: str, text: str, buttons: Sequence[Union[str, Mapping[str, Any]]], **extra: Any) -> Dict[str, Any]:
305
+ """Botões (``reply``, ``cta_url``, ``cta_call``, ``copy``). Strings viram ``reply``."""
306
+ corpo, opts = _separar(extra)
307
+ lista = [{"id": f"b{i + 1}", "title": b, "type": "reply"} if isinstance(b, str) else dict(b) for i, b in enumerate(buttons)]
308
+ return self.post_chat_send_buttons({**corpo, "Phone": destino(phone), "Body": text, "Buttons": lista}, **opts)
309
+
310
+ def send_list(self, phone: str, text: str, button_text: str, sections: Sequence[Mapping[str, Any]], *, title: Optional[str] = None, footer: Optional[str] = None, **extra: Any) -> Dict[str, Any]:
311
+ corpo, opts = _separar(extra)
312
+ corpo.update({"Phone": destino(phone), "Desc": text, "ButtonText": button_text, "Sections": list(sections)})
313
+ if title is not None:
314
+ corpo["TopText"] = title
315
+ if footer is not None:
316
+ corpo["FooterText"] = footer
317
+ return self.post_chat_send_list(corpo, **opts)
318
+
319
+ def send_poll(self, to: str, question: str, choices: Sequence[str], **extra: Any) -> Dict[str, Any]:
320
+ corpo, opts = _separar(extra)
321
+ return self.post_chat_send_poll({**corpo, "group": destino(to), "header": question, "options": list(choices)}, **opts)
322
+
323
+ def get_qr(self, **opts: Any) -> Dict[str, Any]:
324
+ """QR de pareamento (data URL em ``QRCode``). Conecte antes com ``post_session_connect``."""
325
+ return self.get_session_qr(**opts)
326
+
327
+ def pair_phone(self, phone: str, **opts: Any) -> Dict[str, Any]:
328
+ """Pareia por código: devolve ``LinkingCode`` para digitar no celular."""
329
+ return self.post_session_pairphone({"Phone": destino(phone)}, **opts)
330
+
331
+ def status(self, **opts: Any) -> Dict[str, Any]:
332
+ return self.get_session_status(**opts)
333
+
334
+ def create_group(self, name: str, participants: Sequence[str], **extra: Any) -> Dict[str, Any]:
335
+ corpo, opts = _separar(extra)
336
+ return self.post_group_create({**corpo, "name": name, "participants": [destino(p) for p in participants]}, **opts)
337
+
338
+ def check_numbers(self, phones: Union[str, Sequence[str]], **opts: Any) -> List[Dict[str, Any]]:
339
+ """Quais números têm WhatsApp. Um item por número, na ordem enviada."""
340
+ lista = [phones] if isinstance(phones, str) else list(phones)
341
+ r = self.post_user_check({"Phone": [destino(p) for p in lista]}, **opts)
342
+ return list((r or {}).get("Users") or [])
343
+
344
+ def history(self, chat_jid: str, *, limit: Optional[int] = None, cursor: Optional[str] = None, **opts: Any) -> Page:
345
+ """Uma página do histórico (mais recente primeiro)."""
346
+ r = self.request("GET", "/api/v2/chat/history", query={"chat_jid": chat_jid, "limit": limit, "cursor": cursor}, **opts)
347
+ itens = r.data if isinstance(r.data, list) else []
348
+ return Page(items=itens, next_cursor=(r.meta.get("next_cursor") or None) if itens else None, meta=r.meta)
349
+
350
+ def iter_history(self, chat_jid: str, page_size: int = 100, **opts: Any) -> Iterator[Dict[str, Any]]:
351
+ """Itera o histórico inteiro, buscando páginas sob demanda."""
352
+ return self.paginate("/api/v2/chat/history", {"chat_jid": chat_jid, "limit": page_size}, **opts)
353
+
354
+ # ------------------------------------------------------------ internos
355
+
356
+ def _data_url(self, img: Union[str, bytes], mime: str) -> str:
357
+ if isinstance(img, (bytes, bytearray, memoryview)):
358
+ return f"data:{mime};base64,{base64.b64encode(bytes(img)).decode('ascii')}"
359
+ if img.startswith("data:"):
360
+ return _limpar_data_url(img)
361
+ if re.match(r"^https?://", img, re.I):
362
+ try:
363
+ if self._transport is urllib_transport:
364
+ status, rh, cru = urllib_transport("GET", img, {}, None, self._timeout, MAX_DOWNLOAD_BYTES)
365
+ else:
366
+ status, rh, cru = self._transport("GET", img, {}, None, self._timeout)
367
+ except OSError as e:
368
+ raise ZapcoreError(f"zapcore: falha de rede ao baixar a imagem: {e}", status=0, code="network_error") from e
369
+ if not 200 <= status < 300:
370
+ dica = ". A URL redirecionou: passe a URL final ou os bytes" if 300 <= status < 400 else ""
371
+ raise ZapcoreError(f"zapcore: não consegui baixar a imagem ({status}){dica}", status=status, code="bad_request")
372
+ if len(cru) > MAX_DOWNLOAD_BYTES:
373
+ raise _estouro(MAX_DOWNLOAD_BYTES)
374
+ tipo = (rh.get("content-type") or "").split(";")[0].strip().lower()
375
+ if tipo not in _IMAGENS_OK:
376
+ raise ZapcoreError(
377
+ f"zapcore: a URL não devolveu uma imagem (Content-Type: {tipo or 'ausente'}); passe a URL final ou os bytes",
378
+ status=status, code="bad_request",
379
+ )
380
+ return f"data:{tipo};base64,{base64.b64encode(cru).decode('ascii')}"
381
+ limpo = re.sub(r"\s+", "", img)
382
+ return f"data:{mime};base64,{limpo}"
383
+
384
+
385
+ _OPCOES = {"idempotency_key", "headers", "timeout", "query"}
386
+
387
+
388
+ def _separar(extra: Mapping[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
389
+ corpo = {k: v for k, v in extra.items() if k not in _OPCOES}
390
+ opts = {k: v for k, v in extra.items() if k in _OPCOES}
391
+ return corpo, opts
392
+
393
+
394
+ def _montar_query(q: Optional[Mapping[str, Any]]) -> str:
395
+ if not q:
396
+ return ""
397
+ pares: List[Tuple[str, str]] = []
398
+ for k, v in q.items():
399
+ if v is None or v == "":
400
+ continue
401
+ if isinstance(v, bool):
402
+ pares.append((k, "true" if v else "false"))
403
+ elif isinstance(v, (list, tuple)):
404
+ pares.extend((k, str(x)) for x in v)
405
+ else:
406
+ pares.append((k, str(v)))
407
+ return "?" + urlencode(pares) if pares else ""
408
+
409
+
410
+ def _montar_erro(status: int, rh: Mapping[str, str], corpo: Any, texto: str, method: str, path: str) -> ZapcoreError:
411
+ e = corpo.get("error") if isinstance(corpo, dict) else None
412
+ meta = corpo.get("meta") if isinstance(corpo, dict) else None
413
+ obj = e if isinstance(e, dict) else {}
414
+ msg = obj.get("message") or (e if isinstance(e, str) else f"zapcore: {method} {path} respondeu {status}" + (f": {texto[:200]}" if texto and corpo is None else ""))
415
+ return ZapcoreError(
416
+ msg,
417
+ status=status,
418
+ code=obj.get("code") or _POR_STATUS.get(status, "internal_error"),
419
+ field=obj.get("field"),
420
+ request_id=(meta or {}).get("request_id") or rh.get("request-id"),
421
+ retry_after=parse_retry_after(rh.get("retry-after")),
422
+ body=corpo if corpo is not None else texto,
423
+ )
zapcore/_errors.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class ZapcoreError(Exception):
7
+ """Erro da API v2. Case por ``code`` (contrato estável), nunca pela mensagem.
8
+
9
+ ``status`` é 0 em falha de rede (``code == "network_error"``).
10
+ """
11
+
12
+ def __init__(
13
+ self,
14
+ message: str,
15
+ *,
16
+ status: int,
17
+ code: str,
18
+ field: Optional[str] = None,
19
+ request_id: Optional[str] = None,
20
+ retry_after: Optional[float] = None,
21
+ body: Any = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.status = status
26
+ self.code = code
27
+ self.field = field
28
+ self.request_id = request_id
29
+ self.retry_after = retry_after
30
+ self.body = body
31
+
32
+ def __repr__(self) -> str:
33
+ return f"ZapcoreError(status={self.status}, code={self.code!r}, message={self.message!r})"
34
+
35
+
36
+ class WebhookVerificationError(Exception):
37
+ """A assinatura HMAC do webhook não fechou."""