# One Time Password

Every platform reaches a point where a simple login isn't enough. A customer wants to approve a high-value transaction, a user needs to confirm a sensitive action, or your compliance team requires an extra verification step before releasing funds. That's where one-time passwords come in.

The Tendar OTP feature gives you a complete, out-of-the-box system for generating, delivering, and verifying one-time passwords —  without having to build or manage any of the underlying infrastructure yourself. You tell Tendar *who* to send to, *how* to send it (email, SMS, or both), and Tendar handles the rest: generating a secure code, delivering it through your chosen channel, and verifying it when the user submits it back.

It's a two-endpoint flow. One to send, one to verify. But there's a lot of flexibility packed into those two calls, so let's walk through it.

### Before you begin

***

Make sure you have:

* Your **secret key** from the [Tendar dashboard](https://app.tendar.co/services?service-level=api-keys-and-webhooks).
* A server-side environment to make API calls (never expose your secret key in client-side code).
* An **active subscription** — OTP delivery is gated behind your subscription plan.
* A funded **wallet** — each OTP delivery channel (email and SMS) is billed separately per your plan's pricing.

Every request must include the `Authorization` header:

```bash
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
```

### How it works

***

At a high level, the OTP flow looks like this:

```
┌──────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│   Your Server    │     │      Tendar      │     │    Your User     │
│                  │     │                  │     │                  │
│  1. Call /send   │────▶│  Generate code   │     │                  │
│                  │     │  Hash & store    │     │                  │
│                  │     │  Deliver via     │────▶│  Receives code   │
│                  │     │  email / SMS     │     │  (email or SMS)  │
│                  │     │                  │     │                  │
│                  │     │                  │     │  Enters code in  │
│  2. Call /verify │────▶│  Compare code    │◀─── │  your app        │
│                  │     │  Return result   │     │                  │
│                  │◀────│                  │     │                  │
│  ✅ Proceed      │     │  Delete code     │     │                  │
└──────────────────┘     └──────────────────┘     └──────────────────┘
```

{% hint style="info" %}
The key thing to understand is that OTPs are **single-use and time-bound**. Once verified, the code is immediately invalidated. If the user doesn't submit the code before it expires, they'll need to request a new one.
{% endhint %}

### The identifier concept

***

Unlike the built-in email and phone verification flows (which are tied to a specific user), the OTP feature is **identifier-based**. The `identifier` is a string you choose to associate the OTP with — it could be an email address, a phone number, a user ID, an order ID, or really anything that makes sense for your use case.

This design is intentional. It makes the OTP feature flexible enough to handle scenarios beyond simple user verification:

* **Transaction confirmation** — Use the transaction ID as the identifier.
* **BVN/NIN verification** — Use the BVN or NIN number as the identifier.
* **Password reset** — Use the user's email as the identifier.
* **Device authorization** — Use a device fingerprint as the identifier.

The `identifier` and `type` together form a unique key. This means you can have multiple active OTPs for the same identifier, as long as they have different types. For example, a user could have both a `bvn_otp` and a `login_otp` active at the same time.

### Sending an OTP

***

To send an OTP, make a POST request to the send endpoint with the recipient details and your delivery preferences.

**Endpoint**

```bash
POST /api/v1/otp/send
```

**Request body**

```json
{
  "identifier": "johndoe@example.com",
  "channels": ["email", "sms"],
  "email": "johndoe@example.com",
  "phone": "+2348012345678",
  "type": "bvn_otp",
  "exp": 10,
  "length": 6,
  "metadata": {
    "name": "John",
    "body": "Use the token below to verify your BVN",
    "header": "BVN Verification"
  }
}
```

Let's break down each field:

| Field        | Type      | Required    | Description                                                                                                                                                               |
| ------------ | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identifier` | string    | Yes         | A unique string that ties this OTP to a specific context — an email, user ID, transaction ID, etc.                                                                        |
| `channels`   | string\[] | Yes         | How to deliver the OTP. Accepts `"email"`, `"sms"`, or both `["email", "sms"]`. At least one channel is required.                                                         |
| `email`      | string    | Conditional | The email address to send the OTP to. **Required** if `channels` includes `"email"`. Must be a valid email format.                                                        |
| `phone`      | string    | Conditional | The phone number to send the OTP to. **Required** if `channels` includes `"sms"`. Must be in E.164 international format (e.g. `+2348012345678`).                          |
| `type`       | string    | No          | A label for this OTP's purpose — `"bvn_otp"`, `"login_otp"`, `"transaction_otp"`, etc. Defaults to `"otp"` if not provided. Used to namespace OTPs so they don't collide. |
| `exp`        | integer   | No          | Time-to-live in **minutes**. How long the OTP remains valid. Defaults to **5 minutes**. Minimum: 1, Maximum: 60.                                                          |
| `length`     | integer   | No          | Number of digits in the generated code. Defaults to **6**. Minimum: 4, Maximum: 8.                                                                                        |
| `metadata`   | object    | No          | Customizes the message the user receives. See [Customizing the message](#customizing-the-message) below.                                                                  |

**Response** `200 OK`

```json
{
  "error": false,
  "message": "OTP sent successfully"
}
```

That's it. Tendar generates the code, delivers it, and stores it. You don't receive the plaintext code in the response — it goes directly to the user via the channel(s) you specified.

### Customizing the message

***

When an OTP is delivered, Tendar wraps it in a message — an HTML email or an SMS body. The `metadata` object lets you customize what that message looks like:

| Field    | Type   | Description                                                                                                                             |
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `name`   | string | The recipient's name. Used in the greeting (e.g. "Hello John,"). If omitted, the greeting defaults to "Hello,".                         |
| `body`   | string | The main message body. Explains what the OTP is for (e.g. "Use the token below to verify your BVN"). If omitted, Tendar uses a default. |
| `header` | string | The email subject line and heading text (e.g. "BVN Verification"). Defaults to "One Time Token".                                        |

**For email delivery**, Tendar renders a branded HTML template that includes your company's logo (pulled from your company profile), the custom header, body text, the OTP code, and the expiration time.

**For SMS delivery**, the message is formatted as plain text:

```
Hello John,

Use the token below to verify your BVN

483921

This token expires in 10 minutes
```

If you don't pass a `metadata` object at all, Tendar uses sensible defaults: the header is "One Time Token", the body is "Below is your {Company Name} one time token", and the greeting is "Hello,".

### Choosing delivery channels

***

The `channels` array controls how the OTP reaches the user. You have three options:

#### Email only

```json
{
  "channels": ["email"],
  "email": "johndoe@example.com"
}
```

The OTP is sent to the specified email address. The `phone` field is not required. You're billed for one email OTP delivery.

#### SMS only

```json
{
  "channels": ["sms"],
  "phone": "+2348012345678"
}
```

The OTP is sent via SMS to the specified phone number. The `email` field is not required. You're billed for one SMS OTP delivery.

#### Both email and SMS

```json
{
  "channels": ["email", "sms"],
  "email": "johndoe@example.com",
  "phone": "+2348012345678"
}
```

The **same code** is sent through both channels simultaneously. This is useful when you want to maximize the chance that the user receives the code. You're billed for both an email OTP and an SMS OTP delivery.

{% hint style="info" %}
Regardless of how many channels you use, only one code is generated. The user can verify using the code from either channel.
{% endhint %}

### Verifying an OTP

***

Once the user has received the code and entered it in your application, you verify it by calling the verify endpoint.

**Endpoint**

```bash
POST /api/v1/otp/verify
```

**Request body**

```json
{
  "identifier": "johndoe@example.com",
  "type": "bvn_otp",
  "token": "483921"
}
```

| Field        | Type   | Required | Description                                              |
| ------------ | ------ | -------- | -------------------------------------------------------- |
| `identifier` | string | Yes      | The same identifier you used when sending the OTP.       |
| `type`       | string | No       | The same type you used when sending the OTP. Must match. |
| `token`      | string | Yes      | The code the user entered.                               |

**Response — success** `200 OK`

```json
{
  "error": false,
  "message": "OTP verified successfully"
}
```

**Response — failure** `400 Bad Request`

```json
{
  "error": true,
  "message": "invalid or expired otp provided"
}
```

{% hint style="warning" %}
An OTP can only be verified once. After a successful verification, the same code cannot be submitted again. If the user needs another code, you must call `/otp/send` again.
{% endhint %}

### Expiration and security

***

OTPs are stored in a temporary cache with a strict time-to-live (TTL). Here's what you need to know:

* **Default expiration** is **5 minutes**. You can set it anywhere from 1 to 60 minutes using the `exp` field.
* **Codes are hashed** before storage. Tendar never stores plaintext OTP codes. Even if the cache were compromised, the codes would not be recoverable.
* **Single-use by design.** A successful verification immediately deletes the code. There's no window for replay attacks.
* **Scoped to your company.** OTP keys in the cache include your company ID, so there's no chance of collision with another company's OTPs.

The cache key follows a pattern unique to your company, which means:

* Two different companies can independently send OTPs to the same identifier.
* The same identifier can have multiple active OTPs as long as they use different `type` values.
* Sending a new OTP with the same `identifier` and `type` **overwrites** the previous one. The old code becomes invalid.

### Billing

***

Each OTP delivery is a billable action, charged separately per channel:

| Channel | Pricing Item | Description                             |
| ------- | ------------ | --------------------------------------- |
| Email   | `otp.email`  | Charged when `"email"` is in `channels` |
| SMS     | `otp.sms`    | Charged when `"sms"` is in `channels`   |

If you send an OTP via both email and SMS, you're billed for both. Verification is free — only the delivery is charged.

Charges are deducted from your company's wallet based on your subscription's pricing plan. If your wallet doesn't have sufficient balance, the API returns an error and the OTP is not sent.

### Use cases

***

The OTP feature is intentionally generic, which makes it useful across a wide range of scenarios. Here are some common patterns:

#### Transaction confirmation

Before processing a high-value disbursement, send an OTP to the user's phone and require them to confirm:

```bash
# Send OTP for transaction confirmation
curl -X POST {{base_url}}/api/v1/otp/send \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "txn-9f8e7d6c5b4a",
    "channels": ["sms"],
    "phone": "+2348012345678",
    "type": "transaction_confirmation",
    "exp": 5,
    "length": 6,
    "metadata": {
      "name": "John",
      "body": "You are about to approve a disbursement of ₦500,000. Use the code below to confirm.",
      "header": "Transaction Confirmation"
    }
  }'

# After the user enters the code
curl -X POST {{base_url}}/api/v1/otp/verify \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "txn-9f8e7d6c5b4a",
    "type": "transaction_confirmation",
    "token": "629481"
  }'
```

#### BVN verification

After a BVN lookup, confirm the user owns the BVN by sending an OTP to the phone number returned in the lookup results:

```bash
curl -X POST {{base_url}}/api/v1/otp/send \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "22832753455",
    "channels": ["email", "sms"],
    "email": "user@example.com",
    "phone": "+2348181093644",
    "type": "bvn_otp",
    "exp": 10,
    "length": 6,
    "metadata": {
      "name": "John",
      "body": "Use the token below to verify your BVN",
      "header": "BVN Verification"
    }
  }'
```

#### Password reset

When a user requests a password reset, send a code to their email and verify it before allowing the reset:

```bash
curl -X POST {{base_url}}/api/v1/otp/send \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "johndoe@example.com",
    "channels": ["email"],
    "email": "johndoe@example.com",
    "type": "password_reset",
    "exp": 15,
    "metadata": {
      "name": "John",
      "body": "You requested a password reset. Use the code below to proceed.",
      "header": "Password Reset"
    }
  }'
```

#### Two-factor authentication

Add an extra layer of security to your login flow by requiring an OTP after the user enters their password:

```bash
curl -X POST {{base_url}}/api/v1/otp/send \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "usr-lyfriztafj",
    "channels": ["sms"],
    "phone": "+2348012345678",
    "type": "2fa_login",
    "exp": 3,
    "length": 4,
    "metadata": {
      "name": "John",
      "body": "Your login verification code is below. Do not share this with anyone.",
      "header": "Login Verification"
    }
  }'
```

Notice how the `type` field changes in each example. This keeps OTPs for different purposes from interfering with each other — a login OTP won't accidentally satisfy a transaction confirmation, and vice versa.

### Putting it all together

***

Here's a complete example — a BVN verification flow where you look up a BVN, then send an OTP to confirm ownership:

```bash
# Step 1 — Look up the BVN
curl -X POST {{base_url}}/api/v1/kyc/nigeria/bvn/lookup \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "usr-lyfriztafj",
    "bvn": "12345678901",
    "send_otp": false
  }'
# The response contains the BVN owner's details (name, phone, email, etc.)

# Step 2 — Send an OTP to verify ownership
curl -X POST {{base_url}}/api/v1/otp/send \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "67b368cd67c47c8ddc55012c",
    "channels": ["sms"],
    "phone": "+2348012345678",
    "type": "bvn_otp",
    "exp": 10,
    "metadata": {
      "name": "John",
      "body": "Use the code below to verify your BVN",
      "header": "BVN Verification"
    }
  }'

# Step 3 — User receives the SMS and enters the code in your app

# Step 4 — Verify the OTP
curl -X POST {{base_url}}/api/v1/otp/verify \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identifier": "22832753455",
    "type": "bvn_otp",
    "token": "672806"
  }'

# ✅ BVN ownership confirmed — proceed with your business logic
```

### OTP vs. built-in verification

***

You might notice that the Onboarding Service also has dedicated endpoints for [email verification](https://docs.tendar.co/documentation/user-management#verify-a-users-email) and [phone verification](https://docs.tendar.co/documentation/user-management#verify-a-users-phone-number) under User Management. So when should you use the OTP feature instead?

| Feature                      | Built-in Verification                                     | OTP                                       |
| ---------------------------- | --------------------------------------------------------- | ----------------------------------------- |
| **Purpose**                  | Confirm a user's email or phone belongs to them           | Any verification or confirmation scenario |
| **Tied to a user**           | Yes — requires a `user_id`                                | No — uses a freeform `identifier`         |
| **One-time**                 | Yes — once verified, the flag stays `true`                | Yes — but you can send again anytime      |
| **Updates user record**      | Yes — sets `email_verified` or `phone_verified` to `true` | No — doesn't modify any user data         |
| **Customizable message**     | No                                                        | Yes — via `metadata`                      |
| **Configurable expiry**      | Fixed at 10 minutes                                       | 1–60 minutes (your choice)                |
| **Configurable code length** | Fixed at 6 digits                                         | 4–8 digits (your choice)                  |
| **Multi-channel**            | Single channel (email *or* SMS)                           | One or both channels simultaneously       |

**Use built-in verification** when you want to confirm that a user's email or phone number is real and update their profile accordingly.

**Use the OTP feature** for everything else — transaction confirmations, custom verification flows, two-factor authentication, or any scenario where you need a disposable code that isn't tied to a specific user's profile.

OTP endpoints can return several types of errors. Here are the most common:

### Error handling

***

| Scenario                                                 | HTTP Status | Message                             |
| -------------------------------------------------------- | ----------- | ----------------------------------- |
| Missing `identifier` field when sending an OTP           | 400         | `"identifier is required"`          |
| Missing `channels` array when sending an OTP             | 400         | `"channels is required"`            |
| `"email"` channel included but no `email` field provided | 400         | `"email is required"`               |
| `"sms"` channel included but no `phone` field provided   | 400         | `"phone is required"`               |
| Missing `token` field when verifying an OTP              | 400         | `"token is required"`               |
| OTP has expired, was already used, or code doesn't match | 400         | `"invalid or expired otp provided"` |
| Insufficient wallet balance for OTP delivery             | 400         | Charge-related error                |

All errors follow the standard Tendar error format:

```json
{
  "error": true,
  "message": "A description of what went wrong"
}
```

### Next steps

***

Now that you understand how to send and verify OTPs, explore the rest of the Onboarding Service:

* [User Management](https://docs.tendar.co/documentation/onboarding/user-management) — Create and manage users, including built-in email and phone verification.
* [KYC — Identity Verification](https://docs.tendar.co/documentation/onboarding/know-your-customer) — Run BVN, NIN, driver's license, passport, and other identity lookups.
* [UID Management](https://docs.tendar.co/documentation/know-your-customer#uid-management) — Verify and manage identity documents after a KYC lookup.

Explore other Tendar services:

* [**Credit Score Service**](https://docs.tendar.co/documentation/credit-scoring) — Calculate risk scores and credit reports for your users.
* [**Disbursement Service**](https://docs.tendar.co/documentation/disbursement) — Create and manage loans, disburse funds, and track repayments.
* [**Recollection Service**](https://docs.tendar.co/documentation/recollection) — Automate repayment collection from your borrowers.
