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/__init__.py +75 -0
- nitroping/_client.py +204 -0
- nitroping/_devices.py +61 -0
- nitroping/_http.py +158 -0
- nitroping/_notifications.py +94 -0
- nitroping/errors.py +110 -0
- nitroping/py.typed +0 -0
- nitroping/types.py +100 -0
- nitroping/webhooks.py +160 -0
- nitroping-0.1.3.dist-info/METADATA +426 -0
- nitroping-0.1.3.dist-info/RECORD +12 -0
- nitroping-0.1.3.dist-info/WHEEL +4 -0
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,,
|