nitroping 0.1.3__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.
nitroping/types.py ADDED
@@ -0,0 +1,100 @@
1
+ """Shared request / response types for the nitroping HTTP API.
2
+
3
+ The wire shape mirrors ``POST /api/v1/notifications`` on the
4
+ nitroping-pro server. Field names are snake_case on the wire; the SDK
5
+ accepts the same snake_case names on input so there is no impedance
6
+ mismatch with the server.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, TypedDict
12
+
13
+ #: Supported device platforms.
14
+ Platform = Literal["ios", "android", "web"]
15
+
16
+
17
+ class _NotificationActionBase(TypedDict):
18
+ id: str
19
+ title: str
20
+
21
+
22
+ class NotificationAction(_NotificationActionBase, total=False):
23
+ """Action button rendered on the notification (where the platform
24
+ supports it)."""
25
+
26
+ icon: str
27
+
28
+
29
+ class TargetAll(TypedDict):
30
+ """Broadcast target — every active device."""
31
+
32
+ all: bool
33
+
34
+
35
+ class TargetDeviceIds(TypedDict):
36
+ """Hit specific device rows."""
37
+
38
+ device_ids: list[str]
39
+
40
+
41
+ class TargetUserIds(TypedDict):
42
+ """Hit every device row a user owns."""
43
+
44
+ user_ids: list[str]
45
+
46
+
47
+ #: Target selector for a notification. Exactly one of the three.
48
+ NotificationTarget = TargetAll | TargetDeviceIds | TargetUserIds
49
+
50
+
51
+ class SendOptions(TypedDict, total=False):
52
+ """Per-call overrides for :meth:`NotificationsClient.send`.
53
+
54
+ ``idempotency_key`` — if the same key + the same body is sent again
55
+ the server replays the cached response within 24 hours. Same key +
56
+ different body returns a 409 (``idempotency_conflict``). Max 255
57
+ characters.
58
+ """
59
+
60
+ idempotency_key: str
61
+
62
+
63
+ class NotificationResult(TypedDict):
64
+ """Response from ``POST /api/v1/notifications``."""
65
+
66
+ #: UUID of the notification row.
67
+ id: str
68
+ #: Initial status, usually ``"queued"``.
69
+ status: str
70
+
71
+
72
+ class RegisterDeviceResult(TypedDict):
73
+ """Response from ``POST /api/v1/devices``."""
74
+
75
+ #: UUID of the device row.
76
+ id: str
77
+ #: ``True`` if the device was created on this request; ``False`` if it
78
+ #: already existed.
79
+ created: bool
80
+
81
+
82
+ class DeactivateDeviceResult(TypedDict):
83
+ """Response from ``DELETE /api/v1/devices/:id``."""
84
+
85
+ id: str
86
+ status: str
87
+
88
+
89
+ class WebhookEvent(TypedDict):
90
+ """Outbound webhook event envelope (parsed from a
91
+ :func:`nitroping.webhooks.verify` call)."""
92
+
93
+ #: Event id, prefixed with ``evt_``.
94
+ id: str
95
+ #: Event type, e.g. ``"notification.delivered"``, ``"webhook.test"``.
96
+ type: str
97
+ #: ISO-8601 timestamp set when the event was queued.
98
+ created_at: str
99
+ #: Event-specific payload.
100
+ data: dict[str, Any]
nitroping/webhooks.py ADDED
@@ -0,0 +1,160 @@
1
+ """Verify outbound webhook signatures.
2
+
3
+ The nitroping server signs every outbound webhook with HMAC-SHA256 and
4
+ ships the result in the ``X-Nitroping-Signature`` header. The header
5
+ format mirrors Polar / Stripe::
6
+
7
+ X-Nitroping-Signature: t=1700000000, v1=<hex>
8
+
9
+ where ``v1 = HMAC-SHA256(secret, "<unix>.<raw body>")``.
10
+
11
+ Example::
12
+
13
+ from nitroping.webhooks import verify
14
+
15
+ event = verify(
16
+ body=raw_body_bytes,
17
+ signature=request.headers["x-nitroping-signature"],
18
+ secret=os.environ["NITROPING_WEBHOOK_SECRET"],
19
+ )
20
+ if event["type"] == "notification.delivered":
21
+ ...
22
+
23
+ Zero deps — :mod:`hmac` and :mod:`hashlib` from the stdlib.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import hmac
30
+ import json
31
+ import re
32
+ import time
33
+ from typing import cast
34
+
35
+ from .errors import (
36
+ InvalidSignatureError,
37
+ MissingSignatureHeaderError,
38
+ NitropingError,
39
+ TimestampOutOfRangeError,
40
+ )
41
+ from .types import WebhookEvent
42
+
43
+ __all__ = ["sign", "verify"]
44
+
45
+ _HEX_RE = re.compile(r"^[0-9a-f]+$", re.IGNORECASE)
46
+
47
+
48
+ def verify(
49
+ *,
50
+ body: bytes | str,
51
+ signature: str | None,
52
+ secret: str,
53
+ tolerance: int = 300,
54
+ now: int | float | None = None,
55
+ ) -> WebhookEvent:
56
+ """Verify and parse a webhook delivery.
57
+
58
+ :param body: Raw request body, exactly as received (do **not**
59
+ re-serialize a parsed JSON object — whitespace and key order
60
+ matter to the HMAC).
61
+ :param signature: The ``X-Nitroping-Signature`` header value, or
62
+ ``None`` if the header was missing.
63
+ :param secret: Webhook signing secret, configured in the app panel.
64
+ :param tolerance: Maximum drift between ``t=`` and the verifier's
65
+ wall clock, in seconds. Default: 300 (five minutes). Set lower
66
+ for stricter replay defense.
67
+ :param now: Override "now" — useful for tests and replaying a saved
68
+ request during incident investigation. Unix seconds.
69
+
70
+ :returns: The parsed :class:`~nitroping.types.WebhookEvent`.
71
+
72
+ :raises MissingSignatureHeaderError: ``signature`` was ``None``.
73
+ :raises InvalidSignatureError: header malformed or HMAC mismatch.
74
+ :raises TimestampOutOfRangeError: signature valid but ``t=`` outside
75
+ the tolerance window.
76
+ :raises NitropingError: body wasn't valid JSON (``code = "invalid_body"``).
77
+ """
78
+ if signature is None:
79
+ raise MissingSignatureHeaderError()
80
+
81
+ parsed = _parse_signature_header(signature)
82
+ if parsed is None:
83
+ raise InvalidSignatureError("Malformed X-Nitroping-Signature header")
84
+ t, v1 = parsed
85
+
86
+ raw_body: bytes
87
+ raw_body_str: str
88
+ if isinstance(body, bytes):
89
+ raw_body = body
90
+ raw_body_str = body.decode("utf-8", errors="replace")
91
+ else:
92
+ raw_body_str = body
93
+ raw_body = body.encode()
94
+
95
+ expected = _hmac_sha256_hex(secret, f"{t}.".encode() + raw_body)
96
+
97
+ # Constant-time compare via hmac.compare_digest. Both inputs are
98
+ # lowercase hex of the same length when the header is well-formed;
99
+ # if the lengths differ compare_digest still runs in constant time
100
+ # for the length of the shorter input.
101
+ if not hmac.compare_digest(expected, v1):
102
+ raise InvalidSignatureError()
103
+
104
+ current = int(time.time()) if now is None else int(now)
105
+ if abs(current - t) > tolerance:
106
+ raise TimestampOutOfRangeError(
107
+ f"Webhook timestamp {t} is more than {tolerance}s from now ({current})"
108
+ )
109
+
110
+ try:
111
+ event = json.loads(raw_body_str)
112
+ except json.JSONDecodeError as cause:
113
+ raise NitropingError(
114
+ "Webhook body is not valid JSON",
115
+ code="invalid_body",
116
+ cause=cause,
117
+ ) from cause
118
+
119
+ return cast(WebhookEvent, event)
120
+
121
+
122
+ def sign(secret: str, body: bytes | str, timestamp: int | float | None = None) -> str:
123
+ """Compute a header value for the nitroping signing scheme.
124
+
125
+ Mostly useful for tests; production code should rely on the server.
126
+ """
127
+ t = int(time.time()) if timestamp is None else int(timestamp)
128
+ body_bytes = body.encode("utf-8") if isinstance(body, str) else body
129
+ v1 = _hmac_sha256_hex(secret, f"{t}.".encode() + body_bytes)
130
+ return f"t={t}, v1={v1}"
131
+
132
+
133
+ def _parse_signature_header(header: str) -> tuple[int, str] | None:
134
+ """Parse ``t=<unix>, v1=<hex>``. Tolerant of extra whitespace and
135
+ ordering — match by key. Returns ``None`` on malformed input."""
136
+ parts = [piece.strip() for piece in header.split(",")]
137
+ t: int | None = None
138
+ v1: str | None = None
139
+ for part in parts:
140
+ eq = part.find("=")
141
+ if eq <= 0:
142
+ continue
143
+ key = part[:eq].strip()
144
+ value = part[eq + 1 :].strip()
145
+ if key == "t":
146
+ try:
147
+ t = int(value)
148
+ except ValueError:
149
+ return None
150
+ elif key == "v1":
151
+ if not _HEX_RE.match(value):
152
+ return None
153
+ v1 = value.lower()
154
+ if t is None or v1 is None:
155
+ return None
156
+ return t, v1
157
+
158
+
159
+ def _hmac_sha256_hex(secret: str, message: bytes) -> str:
160
+ return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
@@ -0,0 +1,426 @@
1
+ Metadata-Version: 2.4
2
+ Name: nitroping
3
+ Version: 0.1.3
4
+ Summary: Zero-dependency Python SDK for nitroping push notifications. Send pushes, register devices, verify webhooks.
5
+ Project-URL: Homepage, https://nitroping.dev
6
+ Project-URL: Repository, https://github.com/productdevbook/nitroping-sdk
7
+ Project-URL: Issues, https://github.com/productdevbook/nitroping-sdk/issues
8
+ Project-URL: Documentation, https://nitroping.dev/docs
9
+ Author: productdevbook
10
+ License: MIT
11
+ Keywords: apns,fcm,nitroping,notifications,push,sdk,web-push
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.10; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ > Part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo.
28
+ > The PyPI package name (`nitroping`) is unchanged. See the [top-level README](../README.md) for SDKs in other languages.
29
+
30
+ <p align="center">
31
+ <br>
32
+ <b style="font-size: 2em;">nitroping-python</b>
33
+ <br><br>
34
+ Zero-dependency Python SDK for <a href="https://nitroping.dev">nitroping</a>.
35
+ <br>
36
+ Send push notifications, register devices, verify webhooks. Pure stdlib, runs on Python 3.10+.
37
+ <br><br>
38
+ <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/v/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI version"></a>
39
+ <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/pyversions/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="Python versions"></a>
40
+ <a href="https://pypi.org/project/nitroping/"><img src="https://img.shields.io/pypi/dm/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="PyPI downloads"></a>
41
+ <a href="https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="license"></a>
42
+ </p>
43
+
44
+ ## Why nitroping?
45
+
46
+ [nitroping](https://nitroping.dev) is a hosted push notification service that
47
+ unifies APNs (iOS), FCM (Android), and Web Push behind one API. Send to a
48
+ single device, a user across all of their devices, or every device in your app
49
+ with one HTTP call. The service handles fanout, retries, idempotency, quota
50
+ and outbound webhooks for delivery state — you write the product, not the
51
+ plumbing.
52
+
53
+ `nitroping` (Python) is the official Python client. It has **zero runtime
54
+ dependencies**, ships type stubs (PEP 561), and runs anywhere CPython 3.10+
55
+ runs: Django, FastAPI, Flask, Celery workers, AWS Lambda, plain scripts. The
56
+ package weighs in under 30 kB.
57
+
58
+ ## Install
59
+
60
+ ```sh
61
+ pip install nitroping
62
+ # or
63
+ uv pip install nitroping
64
+ # or
65
+ poetry add nitroping
66
+ ```
67
+
68
+ ## Quick Start
69
+
70
+ ### Send a notification
71
+
72
+ ```python
73
+ import os
74
+ from nitroping import Nitroping
75
+
76
+ np = Nitroping(api_key=os.environ["NITROPING_API_KEY"])
77
+
78
+ result = np.notifications.send(
79
+ title="Order #4129 shipped",
80
+ body="Your package is on its way.",
81
+ deep_link="https://example.com/orders/4129",
82
+ actions=[
83
+ {"id": "track", "title": "Track"},
84
+ {"id": "view", "title": "View order"},
85
+ ],
86
+ target={"user_ids": ["user-42"]},
87
+ idempotency_key="order-shipped-4129",
88
+ )
89
+
90
+ print(result["id"], result["status"]) # "abc-...", "queued"
91
+ ```
92
+
93
+ ### Register a device (server side)
94
+
95
+ ```python
96
+ np.devices.register(
97
+ platform="ios",
98
+ token=device_token, # raw APNs hex token
99
+ user_id="user-42",
100
+ metadata={"app_version": "2.4.1"},
101
+ )
102
+ ```
103
+
104
+ ### Verify a webhook
105
+
106
+ ```python
107
+ import os
108
+ from nitroping.webhooks import verify
109
+ from nitroping.errors import (
110
+ InvalidSignatureError,
111
+ TimestampOutOfRangeError,
112
+ MissingSignatureHeaderError,
113
+ )
114
+
115
+ def handle_webhook(raw_body: bytes, signature_header: str | None) -> None:
116
+ try:
117
+ event = verify(
118
+ body=raw_body,
119
+ signature=signature_header,
120
+ secret=os.environ["NITROPING_WEBHOOK_SECRET"],
121
+ )
122
+ except (InvalidSignatureError, TimestampOutOfRangeError, MissingSignatureHeaderError):
123
+ # Reject with HTTP 400 — do NOT leak which check failed.
124
+ raise
125
+
126
+ if event["type"] == "notification.delivered":
127
+ print("delivered", event["data"]["notification_id"])
128
+ ```
129
+
130
+ ## Sync and async
131
+
132
+ `Nitroping` is synchronous and uses only the stdlib (`urllib.request`).
133
+
134
+ `AsyncNitroping` exposes the same API as coroutines. It wraps the sync
135
+ client and runs each call on the default executor via
136
+ `asyncio.get_running_loop().run_in_executor(None, ...)`. This is a
137
+ **best-effort wrapper** — it keeps the SDK zero-dependency and lets you
138
+ `await` from FastAPI / aiohttp / Starlette without surprises, but it is not
139
+ true non-blocking I/O. For high-fanout workloads (thousands of concurrent
140
+ sends) bring your own async HTTP client and call the API directly.
141
+
142
+ ```python
143
+ import asyncio
144
+ from nitroping import AsyncNitroping
145
+
146
+ async def main() -> None:
147
+ np = AsyncNitroping(api_key="np_live_...")
148
+ result = await np.notifications.send(
149
+ title="Hello",
150
+ body="World",
151
+ target={"all": True},
152
+ )
153
+ print(result)
154
+
155
+ asyncio.run(main())
156
+ ```
157
+
158
+ ## API reference
159
+
160
+ ### `Nitroping(api_key=None, *, base_url=..., timeout=30.0, user_agent=None)`
161
+
162
+ Creates a synchronous server-side client. `api_key` falls back to the
163
+ `NITROPING_API_KEY` environment variable when omitted.
164
+
165
+ | Argument | Default | Notes |
166
+ | ------------ | -------------------------- | ------------------------------------- |
167
+ | `api_key` | `$NITROPING_API_KEY` | Secret key, format `np_...`. |
168
+ | `base_url` | `"https://nitroping.dev"` | Override for self-hosted / staging. |
169
+ | `timeout` | `30.0` seconds | Per-request socket timeout. |
170
+ | `user_agent` | `"nitroping-python/0.1.0"` | Sent on every request. |
171
+
172
+ ### `np.notifications.send(*, target, title=None, body=None, ..., idempotency_key=None)`
173
+
174
+ Enqueues a notification. Returns `{"id": str, "status": str}` (`NotificationResult`).
175
+ Raises `ApiError` on non-2xx, carrying the server's `code`, `message`, and
176
+ per-field `details`.
177
+
178
+ ```python
179
+ np.notifications.send(
180
+ title="Welcome!",
181
+ body="Glad to have you on board.",
182
+ icon="https://example.com/icon.png",
183
+ image="https://example.com/hero.png",
184
+ deep_link="https://example.com/welcome",
185
+ data={"onboarding": True},
186
+ actions=[{"id": "tour", "title": "Take the tour"}],
187
+ target={"all": True},
188
+ idempotency_key="welcome-user-42",
189
+ )
190
+ ```
191
+
192
+ `target` is one of three shapes (exactly one):
193
+
194
+ | Selector | Use when |
195
+ | ------------------------------ | -------------------------------- |
196
+ | `{"all": True}` | Broadcast to every active device |
197
+ | `{"device_ids": [...]}` | Hit specific device rows |
198
+ | `{"user_ids": [...]}` | Hit every device row a user owns |
199
+
200
+ ### `np.notifications.get(notification_id)`
201
+
202
+ Fetches a previously enqueued notification by id. Returns the full row
203
+ (including counters: `total_sent`, `total_delivered`, `total_failed`, etc.).
204
+
205
+ ### `np.devices.register(*, platform, token, user_id=None, ...)`
206
+
207
+ Registers (or updates) a device with the **secret** API key. Use this for
208
+ iOS / Android where you control the server. Returns `{"id": str,
209
+ "created": bool}` — `created` is `False` when an existing row matched on
210
+ `(app_id, token, user_id)`.
211
+
212
+ ```python
213
+ np.devices.register(
214
+ platform="ios",
215
+ token="apns-hex-token",
216
+ user_id="user-42",
217
+ metadata={"app_version": "2.4.1"},
218
+ )
219
+ ```
220
+
221
+ For Web Push, also pass `web_push_p256dh` and `web_push_auth` from the
222
+ browser's `PushSubscription`.
223
+
224
+ ### `np.devices.deactivate(device_id)`
225
+
226
+ Soft-deletes a device (`status = "inactive"`). Subsequent sends skip it.
227
+
228
+ ### `verify(*, body, signature, secret, tolerance=300, now=None)` — `nitroping.webhooks`
229
+
230
+ Verifies the `X-Nitroping-Signature` header and returns the parsed event.
231
+
232
+ ```python
233
+ from nitroping.webhooks import verify
234
+
235
+ event = verify(
236
+ body=raw_body_bytes,
237
+ signature=request.headers.get("x-nitroping-signature"),
238
+ secret=os.environ["NITROPING_WEBHOOK_SECRET"],
239
+ tolerance=300, # optional, seconds. Default 300.
240
+ )
241
+ ```
242
+
243
+ The signing scheme is HMAC-SHA256 over `"<unix>.<raw body>"`. The header
244
+ ships as `t=<unix>, v1=<hex>` — same as Polar / Stripe. Use the raw
245
+ request body bytes (not a re-serialized parsed dict) or the HMAC won't
246
+ match.
247
+
248
+ ## Framework recipes
249
+
250
+ ### FastAPI
251
+
252
+ ```python
253
+ from fastapi import FastAPI, Header, HTTPException, Request
254
+ from nitroping import Nitroping
255
+ from nitroping.errors import (
256
+ InvalidSignatureError,
257
+ MissingSignatureHeaderError,
258
+ TimestampOutOfRangeError,
259
+ )
260
+ from nitroping.webhooks import verify
261
+ import os
262
+
263
+ app = FastAPI()
264
+ np = Nitroping(api_key=os.environ["NITROPING_API_KEY"])
265
+
266
+ @app.post("/notify")
267
+ def notify(title: str, body: str) -> dict[str, str]:
268
+ return np.notifications.send(
269
+ title=title, body=body, target={"all": True}
270
+ )
271
+
272
+ @app.post("/webhooks/nitroping")
273
+ async def webhook(
274
+ request: Request,
275
+ x_nitroping_signature: str | None = Header(default=None),
276
+ ) -> dict[str, str]:
277
+ raw = await request.body()
278
+ try:
279
+ event = verify(
280
+ body=raw,
281
+ signature=x_nitroping_signature,
282
+ secret=os.environ["NITROPING_WEBHOOK_SECRET"],
283
+ )
284
+ except (
285
+ InvalidSignatureError,
286
+ MissingSignatureHeaderError,
287
+ TimestampOutOfRangeError,
288
+ ):
289
+ raise HTTPException(status_code=400, detail="bad signature")
290
+ return {"received": event["id"]}
291
+ ```
292
+
293
+ ### Django
294
+
295
+ ```python
296
+ # settings.py
297
+ NITROPING_API_KEY = os.environ["NITROPING_API_KEY"]
298
+ NITROPING_WEBHOOK_SECRET = os.environ["NITROPING_WEBHOOK_SECRET"]
299
+
300
+ # views.py
301
+ from django.conf import settings
302
+ from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, JsonResponse
303
+ from django.views.decorators.csrf import csrf_exempt
304
+ from django.views.decorators.http import require_POST
305
+ from nitroping import Nitroping
306
+ from nitroping.errors import (
307
+ InvalidSignatureError,
308
+ MissingSignatureHeaderError,
309
+ TimestampOutOfRangeError,
310
+ )
311
+ from nitroping.webhooks import verify
312
+
313
+ np = Nitroping(api_key=settings.NITROPING_API_KEY)
314
+
315
+ @csrf_exempt
316
+ @require_POST
317
+ def nitroping_webhook(request: HttpRequest) -> HttpResponse:
318
+ try:
319
+ event = verify(
320
+ body=request.body,
321
+ signature=request.headers.get("X-Nitroping-Signature"),
322
+ secret=settings.NITROPING_WEBHOOK_SECRET,
323
+ )
324
+ except (
325
+ InvalidSignatureError,
326
+ MissingSignatureHeaderError,
327
+ TimestampOutOfRangeError,
328
+ ):
329
+ return HttpResponseBadRequest("bad signature")
330
+ # ...handle event...
331
+ return JsonResponse({"received": event["id"]})
332
+ ```
333
+
334
+ ### Flask
335
+
336
+ ```python
337
+ import os
338
+ from flask import Flask, abort, jsonify, request
339
+ from nitroping.errors import (
340
+ InvalidSignatureError,
341
+ MissingSignatureHeaderError,
342
+ TimestampOutOfRangeError,
343
+ )
344
+ from nitroping.webhooks import verify
345
+
346
+ app = Flask(__name__)
347
+
348
+ @app.post("/webhooks/nitroping")
349
+ def nitroping_webhook():
350
+ try:
351
+ event = verify(
352
+ body=request.get_data(), # bytes — do NOT use request.json
353
+ signature=request.headers.get("X-Nitroping-Signature"),
354
+ secret=os.environ["NITROPING_WEBHOOK_SECRET"],
355
+ )
356
+ except (
357
+ InvalidSignatureError,
358
+ MissingSignatureHeaderError,
359
+ TimestampOutOfRangeError,
360
+ ):
361
+ abort(400)
362
+ return jsonify(received=event["id"])
363
+ ```
364
+
365
+ ## Errors
366
+
367
+ Every error raised by the SDK extends `NitropingError`. Narrow by
368
+ `isinstance` to handle specific cases:
369
+
370
+ | Class | When it fires |
371
+ | ------------------------------ | ------------------------------------------------------------------------------------------ |
372
+ | `NitropingError` | Base class for every SDK error. Catch this to handle everything. |
373
+ | `ApiError` | The server returned a non-2xx response. Has `status`, `code`, `details`. |
374
+ | `NetworkError` | DNS / TLS / offline / timeout — the request never reached the server. Cause attached. |
375
+ | `InvalidSignatureError` | `verify()` HMAC mismatch or malformed header. |
376
+ | `TimestampOutOfRangeError` | `verify()` signature valid but `t=` outside the tolerance window. |
377
+ | `MissingSignatureHeaderError` | `verify()` called with `signature=None`. |
378
+
379
+ ```python
380
+ from nitroping import Nitroping
381
+ from nitroping.errors import ApiError, NetworkError
382
+
383
+ np = Nitroping() # reads NITROPING_API_KEY
384
+
385
+ try:
386
+ np.notifications.send(title="Hi", body="There", target={"all": True})
387
+ except NetworkError:
388
+ # transient — retry with backoff
389
+ ...
390
+ except ApiError as err:
391
+ if err.code == "quota_exceeded":
392
+ print(err.details) # {"quota": ..., "used": ..., "resets_at": ...}
393
+ else:
394
+ raise
395
+ ```
396
+
397
+ ## Type hints
398
+
399
+ The package ships `py.typed` (PEP 561) — `mypy`, `pyright`, and `ruff` see
400
+ the public surface as fully typed. Every request shape is a `TypedDict`:
401
+
402
+ ```python
403
+ from nitroping import NotificationResult, RegisterDeviceResult, WebhookEvent
404
+ ```
405
+
406
+ ## Runtime support
407
+
408
+ | Runtime | Status |
409
+ | -------------- | ------ |
410
+ | CPython 3.10 | Yes |
411
+ | CPython 3.11 | Yes |
412
+ | CPython 3.12 | Yes |
413
+ | CPython 3.13 | Yes |
414
+ | PyPy 3.10+ | Should work (untested in CI). No C extensions. |
415
+
416
+ ## License
417
+
418
+ [MIT](../LICENSE) — Copyright (c) 2026 productdevbook.
419
+
420
+ ---
421
+
422
+ <p align="center">
423
+ <sub>
424
+ Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a>
425
+ </sub>
426
+ </p>
@@ -0,0 +1,12 @@
1
+ nitroping/__init__.py,sha256=ENaM_brocxzN2YZChy4F4zsIyrlZMp15r1XsHrfDJaI,1684
2
+ nitroping/_client.py,sha256=5TP8ttg2FYBzwvvW1ZZ7tXmfEHciCknmWjW5V-hC_wY,6359
3
+ nitroping/_devices.py,sha256=ADqLPpAdtqwqKAQGfLYguJp72pA98C4EfVDqBzgUeWY,2109
4
+ nitroping/_http.py,sha256=k07nEuKwBGEwNWbCkIkX4cHmttPTjPG6ksBuQQuYKH0,5368
5
+ nitroping/_notifications.py,sha256=q6ka9W7bxRPV3huXUR3nKHlZ_Y4ontcIW0dAgv2RKLk,3216
6
+ nitroping/errors.py,sha256=h6S3Ks-HQmWih9v529hCRwejA1YqpY5SVnwU_EJBlGk,3485
7
+ nitroping/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ nitroping/types.py,sha256=cX9XxTiEOY6dYzvPf2i0VxXnzP6O54ZG09jljs-BPEw,2493
9
+ nitroping/webhooks.py,sha256=-WhVyhbR0wyK6LcrWGwTSzlhHXtuzvNSNxZZyvaFXXg,5131
10
+ nitroping-0.1.3.dist-info/METADATA,sha256=Xk6YvXu3mlWk5EOnXlPxjDGfGzvaWi2KzPuSXjtPp30,14318
11
+ nitroping-0.1.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ nitroping-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any