> ## Documentation Index
> Fetch the complete documentation index at: https://developers.1-funnel.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify an OTP challenge with Python

> Run a Python OTP application that saves challenge IDs, verifies recipient codes against the right challenge and handles expiry, lockout and uncertain responses.

Create an SMS OTP challenge, persist its ID against a local application session,
then verify the recipient's code against that saved challenge. This terminal
application uses Python's standard HTTP and SQLite libraries. It needs Python
3.11 or later and no pip packages.

This is a Python application tutorial, not an official SDK or complete login
system. For individual requests in cURL, JavaScript, Python and Java, use
[Create and verify challenges](/guides/otp/create-verify).

## 1. Run the application tests offline

```bash theme={null}
 git clone https://github.com/1funnel/1funnel-api-samples.git
 cd 1funnel-api-samples/samples/otp/create-and-verify/python
 python3 --version
 python3 -m unittest discover -s tests -v
```

The test suite starts a local HTTP stub and temporary database. No credentials
are needed and no SMS is sent. It tests saved idempotency after a lost response,
challenge/user correlation, duplicate verification, wrong codes, expiry, lockout,
invalidation, consumed challenges and uncertain verification outcomes. Read the
[complete application and README](https://github.com/1funnel/1funnel-api-samples/tree/main/samples/otp/create-and-verify/python).

These tests verify the application's behavior. Approved Sandbox access is still
required to exercise actual code delivery and platform policy.

## 2. Prepare a permitted Sandbox request

Complete the [Quickstart](/getting-started/quickstart). You need Sandbox access
to create, verify and retrieve OTP challenges for your business, an authorized
sender, a permitted test recipient and an approved OTP template key. If OTP is
unavailable for your business, contact [Support](/resources/support) before
continuing. See [OTP integration](/guides/otp/overview) for the workflow.

In the application directory:

```bash theme={null}
umask 077
export API_BASE_URL=https://api.sandbox.1-funnel.org
export BUSINESS_ID='<issued-business-id>'
export ACCESS_TOKEN='<issued-access-token>'
export DB_PATH="$PWD/otp.sqlite"
```

Create private `request.json`, replacing the sender, destination and template key:

```json theme={null}
{
  "purpose": "login",
  "channel": "sms",
  "destination": "+2348012345678",
  "from": "ONEFUNNEL",
  "locale": "en",
  "template_key": "<approved-otp-template-key>"
}
```

`template_key` identifies an approved OTP template. It is distinct from an SMS
`template_id`. If business policy permits inline OTP content, this alternative
request includes the required `{{code}}` placeholder and omits `template_key`:

```json theme={null}
{
  "purpose": "login",
  "channel": "sms",
  "destination": "+2348012345678",
  "from": "ONEFUNNEL",
  "message": "Your login code is {{code}}. Use it to continue."
}
```

Choose one payload before creating the local session. Do not put the actual
code into this file; OneFunnel generates it. Omitting both content selectors
requires a matching approved default binding; see
[Create an OTP challenge](/api-reference/otp/create-an-otp-challenge).

## 3. Create the challenge and save its ID

```bash theme={null}
python3 src/app.py create login-1001 user-42 request.json
python3 src/app.py inspect login-1001 user-42
```

`login-1001` is this attempt's stable application session ID; `user-42` represents
the application user. The terminal operator supplies both in this exercise. A
web integration must obtain user/session identity from trusted server context;
accepting a caller-supplied user ID is not authentication.

Before POST, the application commits an idempotency key and request bytes to
SQLite. It requires HTTP `202`, then saves the returned challenge ID, `status`,
`code_length` and `expires_at`. The create response never includes the code.
Acceptance does not mean the SMS was delivered.

If the response is lost, repeat the exact command with the same request and
database. It reuses the saved key/body instead of creating a new attempt. Once
an ID is saved, it skips another create POST. Changing the payload or subject
under the same local session is rejected. Follow
[idempotency recovery](/getting-started/idempotency) if acceptance remains
uncertain beyond the seven-day replay window.

## 4. Verify the code against the saved challenge

```bash theme={null}
python3 src/app.py verify login-1001 user-42
```

Enter the code received by the test recipient at the hidden prompt. Do not put
codes in command arguments, logs or the database. The application loads the
challenge ID associated with that local session and subject, then calls:

```http theme={null}
POST /v1/businesses/{businessId}/otp/challenges/{savedChallengeId}/verify
Content-Type: application/json

{"code":"<recipient-entered-code>"}
```

The URL supplies challenge correlation; the body carries only the code. See
[Verify an OTP challenge](/api-reference/otp/verify-an-otp-challenge).
Verification does not support an idempotency key and the application does not
add one or automatically retry the code.

Success requires HTTP `200`, the saved challenge ID, the original purpose and
`status: verified`. The application persists `verification_state: verified`
and `verified_at`. It does not issue a login token or claim SMS delivery. A
repeat local verify command is blocked instead of making another POST.

## 5. Handle rejected and uncertain attempts

A wrong code returns HTTP `422` with `otp_verification_failed`. The application
records that error and permits a deliberate new recipient submission. The
platform still enforces its attempt limit; there is no automatic code retry.

Expiry returns HTTP `422` with `otp_challenge_expired`. Lockout returns HTTP
`429` with `otp_challenge_locked`. Invalidation and an already consumed code
return HTTP `409` with their distinct codes. These block verification locally;
none are treated as a new successful check. For other `429` responses, inspect
the error code and respect `Retry-After`. See
[Error handling](/getting-started/error-handling).

If verification times out or the process exits during the request, the database
retains `verification_state: uncertain`. Inspect it and retrieve current state:

```bash theme={null}
python3 src/app.py inspect login-1001 user-42
python3 src/app.py status login-1001 user-42
```

The status command requires HTTP `200` from
[Retrieve OTP challenge status](/api-reference/otp/retrieve-otp-challenge-status).
A GET reporting `verified` is an observation, not evidence that a fresh code
submission succeeded in this application. The example keeps local proof
uncertain and issues no token. Apply your product's reviewed recovery policy;
do not blindly retry verification or edit local state to manufacture success.

After a definitively expired or blocked flow, a user can explicitly begin a new
attempt under your product policy with a new local session ID. Do not do this
just to bypass an uncertain response, cooldown or attempt limit. Follow
[Resend and invalidate challenges](/guides/otp/resend-invalidate) for those
separate actions; the application performs neither automatically.

## Operational boundaries

Use a separate database for each API host/business, on persistent local disk.
It stores the destination and original request but no actual code, hash or access
token. Protect directory and backup access and define retention. The README
explains the stored fields and recovery states.

This terminal exercise exposes no browser routes. Adapting it to a web login
requires trusted session binding, abuse controls, CSRF protection and secure
session issuance with its own failure recovery. Keep expiry and attempt policy
authoritative at OneFunnel; do not infer a fixed TTL from example timestamps.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.