Metadata-Version: 2.5
Name: otp-id
Version: 0.1.0
Summary: Official Python SDK for the OTP.ID V3 API
Project-URL: Homepage, https://github.com/otp-id/otp-id-python
Project-URL: Issues, https://github.com/otp-id/otp-id-python/issues
Project-URL: Documentation, https://docs.otp.id
Author: PT Aplikasi Kreasi Indonesia (OTP.ID)
License-Expression: MIT
License-File: LICENSE
Keywords: email,otp,otp.id,sdk,sms,whatsapp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# otp-id-python

Official Python SDK for the [OTP.ID](https://otp.id) V3 API — multi-channel OTP
delivery and verification (WhatsApp, SMS, Voice, Email, Missed Call, and
WhatsApp Inbound) with prepaid billing.

[![CI](https://github.com/otp-id/otp-id-python/actions/workflows/ci.yml/badge.svg)](https://github.com/otp-id/otp-id-python/actions/workflows/ci.yml)

- Zero dependencies — standard library only.
- Faithful to the API: one method per endpoint, no hidden retries.
- Fully typed, ships a [`py.typed`](https://peps.python.org/pep-0561/) marker.
- Full API reference: https://docs.otp.id

## Install

```bash
pip install otp-id
```

Requires Python 3.10+.

## Quickstart

```python
import os

from otpid import CHANNEL_WHATSAPP, Client

client = Client(os.environ["OTPID_API_KEY"])

# 1. Send an OTP (code generated by OTP.ID, never returned to you).
res = client.request_otp(
    channel=CHANNEL_WHATSAPP,
    destination="6281234567890",
    brand="MyApp",       # shown in the OTP message; defaults to your merchant brand_name
    external_id="order-8821",  # optional idempotency key
)
print("otp_id:", res.otp_id, "balance:", res.last_balance)

# 2. Later, verify what the user typed.
v = client.verify_otp(res.otp_id, "482913")
if v.verified:
    print("verified!")
else:
    print("wrong code:", v.reason)  # "mismatch" — not an error
```

## Error handling

Every non-success API response is an `otpid.APIError`:

```python
from otpid import ERR_DUPLICATE_EXTERNAL_ID, ERR_INSUFFICIENT_BALANCE, ERR_RATE_LIMITED, APIError

try:
    res = client.request_otp(channel=CHANNEL_WHATSAPP, destination="6281234567890")
except APIError as e:
    if e.code == ERR_INSUFFICIENT_BALANCE:
        ...  # top up first
    elif e.code == ERR_DUPLICATE_EXTERNAL_ID:
        existing = (e.details or {}).get("existing_otp_id")  # recover the original transaction
    elif e.code == ERR_RATE_LIMITED:
        ...  # slow down (20 requests per second per API key)
    raise
```

`ValueError` is raised immediately, with no network call, for SDK-side input
validation: an empty `api_key` passed to `Client()`, or an empty `otp_id`
passed to `verify_otp`/`otp_status`. A network failure (timeout, DNS, no
response at all) surfaces as `urllib.error.URLError` — the SDK does not
catch or wrap it.

A wrong code on `verify_otp` is **not** an error: the server answers HTTP 200
with `verified: false`, and the SDK returns a `VerifyResult(verified=False,
reason="mismatch")`. Expired, locked, or already-used transactions do raise
an `APIError` (`OTP_EXPIRED`, `TOO_MANY_ATTEMPTS`, `ALREADY_USED`).

## Channels

| Constant | Value | Notes |
| --- | --- | --- |
| `CHANNEL_WHATSAPP` | `whatsapp` | `request_otp` + `send_otp` |
| `CHANNEL_SMS` | `sms` | `request_otp` + `send_otp` |
| `CHANNEL_VOICE` | `voice` | `request_otp` only |
| `CHANNEL_EMAIL` | `email` | `request_otp` + `send_otp` |
| `CHANNEL_MISSCALL` | `misscall` | use `request_otp`; user completes the caller's number |
| `CHANNEL_WHATSAPP_INBOUND` | `whatsapp_inbound` | `request_otp` only; user messages OTP.ID |

### Bring your own code

`send_otp` accepts `channel`, `destination`, `brand`, `ttl`, and
`external_id` keyword arguments — there is no `otp_length` option, since the
code itself is supplied by you, not generated by OTP.ID:

```python
from otpid import CHANNEL_SMS

res = client.send_otp(
    "482913",
    channel=CHANNEL_SMS,
    destination="6281234567890",
)
```

### WhatsApp Inbound (user-initiated)

No code to type: show the user `res.verification.wa_link` and let OTP.ID
match their incoming message. Never call `verify_otp` for these
transactions — detect completion via the `otp.verified` webhook or by
polling `otp_status`.

```python
from otpid import CHANNEL_WHATSAPP_INBOUND

res = client.request_otp(channel=CHANNEL_WHATSAPP_INBOUND)
print("Ask the user to tap:", res.verification.wa_link)
```

### Missed Call

The last digits of the calling number are the code. Show
`res.verification.prefix` and ask the user to complete the number, then pass
the completed digits to `verify_otp`.

## Account & top-up

```python
acc = client.account()
print("balance:", acc.saldo)

topup = client.create_topup(100_000, 3)  # amount: 10000 | 100000 | 500000 | 1000000 | 2000000
print("pay at:", topup.payment_url)
```

## Webhook: `otp.verified`

OTP.ID signs every webhook with `HMAC-SHA256(secret, timestamp + "." + body)`.
`parse_verified_event` checks the signature (constant-time), rejects
timestamps outside ±5 minutes by default (replay protection, configurable
via `tolerance_seconds`), and decodes the payload:

```python
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from otpid import OtpIdError, parse_verified_event

MAX_BODY_BYTES = 1 << 20


class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self) -> None:
        if self.path != "/webhooks/otpid":
            self.send_response(404)
            self.end_headers()
            return

        length = int(self.headers.get("Content-Length") or 0)
        if length > MAX_BODY_BYTES:
            self.send_response(400)
            self.end_headers()
            return
        body = self.rfile.read(length)

        try:
            event = parse_verified_event(
                os.environ["OTPID_WEBHOOK_SECRET"],
                self.headers.get("X-OTPID-Timestamp", ""),
                self.headers.get("X-OTPID-Signature", ""),
                body,
            )
        except OtpIdError:
            self.send_response(401)
            self.end_headers()
            return

        print("verified:", event.otp_id, "external_id:", event.external_id)
        self.send_response(200)
        self.end_headers()

    def log_message(self, format: str, *args) -> None:
        pass


if __name__ == "__main__":
    ThreadingHTTPServer(("0.0.0.0", 8080), WebhookHandler).serve_forever()
```

## Configuration

```python
from otpid import Client

client = Client(
    api_key,
    base_url="https://api.otp.id",  # default
    timeout=10.0,                   # seconds; default 30.0
)
```

Pass a custom `urllib.request.OpenerDirector` via `opener=` if you need to
route requests through a proxy or install custom handlers.

The SDK never retries a request. If you add retries, only retry
`request_otp`/`send_otp` calls that carry an `external_id` (the server
replays them idempotently) — retrying without one may deliver a second OTP.

## Typing

The package ships a [`py.typed`](https://peps.python.org/pep-0561/) marker
and full type annotations, so `mypy`/`pyright` pick up its types with no
extra stub package.

## License

MIT — see [LICENSE](LICENSE).
