> ## Documentation Index
> Fetch the complete documentation index at: https://torpedo.co.mz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify phone numbers on WhatsApp

> Send one-time codes over WhatsApp, with optional SMS fallback, and check them with Torpedo Verify

Torpedo Verify sends a one-time code to a phone number over WhatsApp and checks the code your user
types back. Torpedo generates the code, sends it from an approved WhatsApp authentication template,
counts wrong guesses, and expires the code for you. If WhatsApp can't deliver the message, Verify can
fall back to SMS.

The flow takes two calls:

**1. Start a verification**

`POST /api/v1/verify` with the user's phone number. Torpedo sends the code and returns a
verification `id`.

**2. Check the code**

`POST /api/v1/verify/{id}/check` with the code the user typed. `valid: true` means the number
is verified.

## Requirements

* A WhatsApp balance in your workspace. Top it up from **WhatsApp → Buy credits** in the dashboard.
  See [Pricing and balance](#pricing-and-balance).
* For SMS fallback: SMS credits, and a Mozambican mobile number as the recipient.

You don't need your own WhatsApp Business account. By default, codes go out from Torpedo's shared
WhatsApp sender with an approved authentication template. You can also connect your own number in the
dashboard; see [Templates and your own number](#templates-and-your-own-number).

***

## Start a verification

```bash cURL theme={null}
curl -X POST https://api.torpedo.co.mz/api/v1/verify \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+258841234567",
    "locale": "pt"
  }'
```

```js Node.js theme={null}
const res = await fetch('https://api.torpedo.co.mz/api/v1/verify', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.TORPEDO_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ to: '+258841234567', locale: 'pt' }),
})
const { data } = await res.json()
console.log(data.id) // keep this to check the code
console.log(data.expiresAt)
```

```ts TypeScript SDK theme={null}
import { Torpedo } from 'torpedo-sdk'

const torpedo = new Torpedo({ apiKey: process.env.TORPEDO_API_KEY! })

const verification = await torpedo.verify.start({ to: '+258841234567', locale: 'pt' })
console.log(verification.id) // keep this to check the code
```

```java Java SDK theme={null}
var torpedo = TorpedoClient.builder()
    .apiKey(System.getenv("TORPEDO_API_KEY"))
    .build();

var verification = torpedo.verify().start(
    StartVerificationRequest.builder()
        .to("+258841234567")
        .locale("pt")
        .build()
);
System.out.println(verification.id()); // keep this to check the code
```

```python Python theme={null}
import httpx

res = httpx.post(
    'https://api.torpedo.co.mz/api/v1/verify',
    headers={'X-API-Key': TORPEDO_API_KEY},
    json={'to': '+258841234567', 'locale': 'pt'},
)
data = res.json()['data']
print(data['id'], data['expiresAt'])
```

```go Go theme={null}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	payload, _ := json.Marshal(map[string]string{
		"to":     "+258841234567",
		"locale": "pt",
	})

	req, _ := http.NewRequest("POST", "https://api.torpedo.co.mz/api/v1/verify", bytes.NewBuffer(payload))
	req.Header.Set("X-API-Key", os.Getenv("TORPEDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, _ := http.DefaultClient.Do(req)
	defer resp.Body.Close()
	fmt.Println(resp.Status)
}
```

```csharp C# theme={null}
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("TORPEDO_API_KEY"));

var payload = new { to = "+258841234567", locale = "pt" };
var response = await client.PostAsJsonAsync("https://api.torpedo.co.mz/api/v1/verify", payload);
Console.WriteLine(await response.Content.ReadAsStringAsync());
```

The response is `202 Accepted`. The code is sent in the background:

```json theme={null}
{
  "data": {
    "id": "0b6d1f3e-7a2c-4f51-9d8e-3c4b2a1f0e9d",
    "status": "pending",
    "to": "+258841234567",
    "channel": "whatsapp",
    "fallback": null,
    "locale": "pt",
    "codeSource": "generated",
    "isTest": false,
    "clientRef": null,
    "checkAttempts": 0,
    "maxCheckAttempts": 5,
    "sendCount": 1,
    "maxSends": 5,
    "expiresAt": "2026-10-14T10:05:00.000Z",
    "resendAvailableAt": "2026-10-14T10:00:30.000Z",
    "approvedAt": null,
    "createdAt": "2026-10-14T10:00:00.000Z",
    "attempts": [
      {
        "id": "5a9e2c4d-1b3f-4e6a-8c7d-9f0e1a2b3c4d",
        "channel": "whatsapp",
        "trigger": "initial",
        "status": "queued",
        "errorCode": null,
        "errorCategory": null,
        "createdAt": "2026-10-14T10:00:00.000Z"
      }
    ]
  }
}
```

### Request fields

| Field | Type | Description |
| - | - | - |
| `to` | string | **Required.** The phone number in E.164 format, for example `+258841234567` |
| `channel` | string | `whatsapp` (default) or `sms`. `sms` only works for Mozambican mobile numbers |
| `fallback` | string | `sms` to send the code by SMS if WhatsApp fails, or `null` for no fallback. Defaults to your workspace setting |
| `locale` | string | `pt` or `en`. Picks the template language. Defaults to your workspace setting |
| `code` | string | Your own code, 4–10 letters or digits with at least one digit. Omit it to let Torpedo generate one. See [Bring your own code](#bring-your-own-code) |
| `codeLength` | number | Length of the generated code, 6–10. Defaults to your workspace setting (6). Can't be combined with `code` |
| `template` | string | Name of a specific approved WhatsApp authentication template. Omit it to use the default template for `locale` |
| `clientRef` | string | Your own reference, up to 128 characters. Returned on the verification and in its webhooks |
| `ip` | string | Your end user's IP address. Torpedo rate-limits starts per IP when you pass it |

Pass an `Idempotency-Key` header to make retries safe. A repeated request with the same key returns
the original verification instead of sending a new code.

### One pending verification per number

A number can only have one `pending` verification at a time. Starting another one returns `409`
with the existing ID in the message:

```json theme={null}
{
  "errors": [
    {
      "code": "VERIFICATION_PENDING",
      "message": "A verification is already pending for this number (0b6d1f3e-...); resend or cancel it",
      "field": "to"
    }
  ]
}
```

[Resend](#resend-the-code) the code on the existing verification, or [cancel](#cancel-a-verification)
it first. An expired verification doesn't block a new one.

***

## Check the code

```bash cURL theme={null}
curl -X POST https://api.torpedo.co.mz/api/v1/verify/0b6d1f3e-7a2c-4f51-9d8e-3c4b2a1f0e9d/check \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "482910" }'
```

```js Node.js theme={null}
const res = await fetch(`https://api.torpedo.co.mz/api/v1/verify/${verificationId}/check`, {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.TORPEDO_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ code: userInput }),
})
const { data } = await res.json()

if (data.valid) {
  // the phone number is verified
}
```

```python Python theme={null}
res = httpx.post(
    f'https://api.torpedo.co.mz/api/v1/verify/{verification_id}/check',
    headers={'X-API-Key': TORPEDO_API_KEY},
    json={'code': user_input},
)
data = res.json()['data']
if data['valid']:
    ...  # the phone number is verified
```

```ts TypeScript SDK theme={null}
const result = await torpedo.verify.check(verificationId, userInput)

if (result.valid) {
  // the phone number is verified
}
```

```java Java SDK theme={null}
var result = torpedo.verify().check(verificationId, userInput);

if (result.valid()) {
  // the phone number is verified
}
```

A check always returns `200` while the verification is pending:

```json theme={null}
{ "data": { "id": "0b6d1f3e-...", "status": "approved", "valid": true, "attemptsRemaining": 4 } }
```

| `status` | `valid` | Meaning |
| - | - | - |
| `approved` | `true` | The code is correct. The number is verified |
| `pending` | `false` | Wrong code. The user can try again while `attemptsRemaining` is above zero |
| `max_attempts_reached` | `false` | That was the last allowed guess. Start a new verification |

By default a verification allows 5 guesses. You can lower this to 3 or 4 in **WhatsApp → Settings**.

Checking a verification that has already ended returns `410 Gone`:

| `code` | Why |
| - | - |
| `VERIFICATION_ALREADY_APPROVED` | The code was already checked successfully |
| `VERIFICATION_EXPIRED` | The code expired before it was checked |
| `VERIFICATION_CANCELED` | You canceled the verification |
| `MAX_ATTEMPTS_REACHED` | Too many wrong guesses |

> [!NOTE]
>
> Torpedo stores only a hash of the code. Once a verification ends, the code is deleted and can't be
> recovered.

***

## Code lifetime and verification statuses

A code expires after the time set by the template, which is **5 minutes** for Torpedo's shared
templates. `expiresAt` on the verification tells you exactly when.

```
pending → approved
   ├──→ expired
   ├──→ max_attempts_reached
   └──→ canceled
```

| Status | Meaning |
| - | - |
| `pending` | A code was sent and hasn't been checked successfully |
| `approved` | The user entered the right code |
| `expired` | The code expired before it was checked |
| `max_attempts_reached` | The user used up all their guesses |
| `canceled` | You canceled it, or replaced it with your own code |

Each send is recorded in `attempts`, with its own status (`queued`, `sent`, `delivered` or
`failed`) and a `trigger` of `initial`, `resend` or `fallback`.

***

## Resend the code

If the message didn't arrive, send the same code again on the same verification:

```bash theme={null}
curl -X POST https://api.torpedo.co.mz/api/v1/verify/0b6d1f3e-7a2c-4f51-9d8e-3c4b2a1f0e9d/resend \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms" }'
```

`channel` is optional. Omit it to resend on the verification's channel, or set it to switch, for
example from WhatsApp to SMS. The response is `202` with the updated verification.

* You can resend once **30 seconds** after the last send. `resendAvailableAt` tells you when.
* A verification can be sent at most **5 times** in total, including the first send and any fallback.

Both limits return `429` with a `Retry-After` header (`PREMATURE_RETRY` or `MAX_SEND_ATTEMPTS`).
Resending doesn't extend `expiresAt`.

***

## SMS fallback

With `"fallback": "sms"`, Torpedo sends the same code by SMS when the WhatsApp message fails, for
example because the number isn't on WhatsApp. The fallback is only triggered by a failure, never by
a timer, and happens at most once per verification.

```bash theme={null}
curl -X POST https://api.torpedo.co.mz/api/v1/verify \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+258841234567", "fallback": "sms" }'
```

Fallback is off by default. Turn it on for every verification in **WhatsApp → Settings**, or per
request with `fallback`. It needs SMS credits and only reaches Mozambican mobile numbers. The SMS is
sent from your default [sender ID](https://torpedo.co.mz/docs/guides/send-sms.md#sender-id-from), or the one chosen in Verify
settings.

When a fallback is sent, you receive a `verification.fallback` webhook.

### SMS autofill

Phones can fill in a code from an SMS automatically when the message is formatted for it. Set these
in **WhatsApp → Settings** and Torpedo formats every Verify SMS to match:

| Setting | Platform | What Torpedo adds to the SMS |
| - | - | - |
| **Web OTP domain** | Browsers that support the [WebOTP API](https://developer.mozilla.org/docs/Web/API/WebOTP_API), such as Chrome on Android | A last line `@your-domain.com #482910` |
| **Android app hash** | Android apps using the [SMS Retriever API](https://developers.google.com/identity/sms-retriever/overview) | Your app's 11-character hash on its own line |

With both set, a Portuguese SMS looks like this:

```
482910 e o seu codigo de verificacao. Nao o partilhe. Expira em 5 min.
FA+9qCX9VSu

@app.example.com #482910
```

The web OTP domain must be the domain of the page asking for the code. iOS suggests codes from SMS
without any setup.

***

## Bring your own code

If your system already generates codes, pass yours in `code`. Torpedo delivers it, and you can either
check it with `/check` as usual, or check it yourself and tell Torpedo the result:

```bash theme={null}
curl -X POST https://api.torpedo.co.mz/api/v1/verify/0b6d1f3e-7a2c-4f51-9d8e-3c4b2a1f0e9d/report \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "approved" }'
```

Reporting marks the verification `approved` so your conversion stats stay accurate. Only
verifications started with your own `code` can be reported.

Starting a verification with your own `code` while another one is pending for the same number
cancels the old one instead of returning `409`, since your system has already replaced the old code.

***

## Cancel a verification

```bash theme={null}
curl -X DELETE https://api.torpedo.co.mz/api/v1/verify/0b6d1f3e-7a2c-4f51-9d8e-3c4b2a1f0e9d \
  -H "X-API-Key: YOUR_API_KEY"
```

The verification becomes `canceled` and its code stops working. Cancel when the user abandons the
flow or changes their number, so you can start a fresh verification right away.

***

## Get and list verifications

```bash theme={null}
# One verification
curl https://api.torpedo.co.mz/api/v1/verify/0b6d1f3e-7a2c-4f51-9d8e-3c4b2a1f0e9d \
  -H "X-API-Key: YOUR_API_KEY"

# Newest first, filtered
curl "https://api.torpedo.co.mz/api/v1/verify?status=approved&channel=whatsapp&limit=20" \
  -H "X-API-Key: YOUR_API_KEY"
```

The list accepts `status`, `channel`, `to`, `limit` and `cursor`, and returns `{ data, meta }` with
the same cursor pagination as the other list endpoints.

***

## Putting it together

A typical sign-up or login screen:

1. **Start.** When the user submits their number, call `POST /api/v1/verify` from your server with
   their number, your `clientRef` and their `ip`. Store the returned `id` in their session. Never
   send the code or the verification to the browser.
2. **Ask for the code.** Show a code input. Disable the "Send again" button until
   `resendAvailableAt`, then call `/resend` when the user presses it. Offer "Send by SMS instead" as
   a resend with `"channel": "sms"` for Mozambican numbers.
3. **Check.** Call `/check` with what the user typed. On `valid: true`, mark the number as verified.
   On `valid: false`, show how many `attemptsRemaining` are left.
4. **Handle the ends.**
   * `409 VERIFICATION_PENDING` on start: the user already has a code on its way. Keep the existing
     verification and show the code input, or cancel it and start again if they changed numbers.
   * `410` on check or resend: the verification ended (expired, too many guesses or canceled). Ask
     the user to request a new code.
   * `429`: wait for `Retry-After` before trying again, and tell the user how long.

```ts TypeScript SDK theme={null}
import { Torpedo, TorpedoError } from 'torpedo-sdk'

const torpedo = new Torpedo({ apiKey: process.env.TORPEDO_API_KEY! })

export async function sendCode(session: Session, phone: string, ip: string) {
  const verification = await torpedo.verify.start({
    to: phone,
    fallback: 'sms',
    clientRef: session.userId,
    ip,
  })

  session.verificationId = verification.id

  return { resendAvailableAt: verification.resendAvailableAt }
}

export async function confirmCode(session: Session, code: string) {
  try {
    const result = await torpedo.verify.check(session.verificationId, code)

    return result.valid
      ? { verified: true }
      : { verified: false, attemptsRemaining: result.attemptsRemaining }
  } catch (error) {
    if (error instanceof TorpedoError && error.status === 410) {
      return { verified: false, expired: true }
    }

    throw error
  }
}
```

***

## Webhooks

Subscribe a [webhook](https://torpedo.co.mz/docs/guides/webhooks.md) to these events to follow a verification without polling:

| Event | When it fires |
| - | - |
| `verification.sent` | WhatsApp or the SMS operator accepted a message |
| `verification.delivered` | The message reached the phone |
| `verification.delivery_failed` | A message couldn't be delivered |
| `verification.fallback` | WhatsApp failed and the code was sent by SMS |
| `verification.approved` | The code was checked or reported as correct |
| `verification.expired` | The code expired without being approved |
| `verification.max_attempts_reached` | The user used up all their guesses |

See [the payload fields](https://torpedo.co.mz/docs/guides/webhooks.md#data-by-event) for each event.

***

## Testing

Create a **test API key** in the dashboard to try Verify without paying for messages. With a test
key, `to` must be either your own verified phone number or one of these sandbox numbers:

| Number | What happens |
| - | - |
| `+258840000000` | The code is delivered |
| `+258840000001` | WhatsApp fails as if the number isn't on WhatsApp. SMS works, so try `fallback` |
| `+258840000002` | Every channel fails |
| `+258840000003` | The message stays sent, with no delivery confirmation |

Sandbox numbers send nothing and are free. The code is always **`000000`**, and all webhooks fire as
they would for a real number.

Each workspace gets 20 test verifications, sandbox ones included. After that, a test key can still
use sandbox numbers, but sending to your own phone number needs a live key.

***

## Rate limits

To stop attackers from pumping messages to a number (SMS pumping and toll fraud), Verify limits how
often a number can receive codes, on top of the [API rate limit](https://torpedo.co.mz/docs/errors.md#rate-limiting):

* At most **5 starts per hour** per number by default (configurable from 1 to 20 in **WhatsApp →
  Settings**), and twice that per day.
* At most **3 starts in 10 minutes** per number, whatever your setting.
* At most **20 starts in 10 minutes** per end-user IP, when you pass `ip`.
* At most **15 wrong guesses per hour** per number, across all its verifications.

Going over a limit returns `429` with code `RATE_LIMITED` and a `Retry-After` header.

You can also restrict which countries can receive codes in **WhatsApp → Settings**. A number from a
country that isn't allowed returns `422` with code `COUNTRY_NOT_ALLOWED`.

***

## Templates and your own number

WhatsApp only allows codes to be sent with an approved **authentication template**. Torpedo provides
approved templates in Portuguese and English on its shared sender, so you can start right away.

To send codes from your own business number and name, connect your number in **WhatsApp** in the
dashboard (**WhatsApp → Number**) and create authentication templates under **WhatsApp → Templates**. Once your number is
connected and a template for the verification's `locale` is approved, Verify uses it instead of the
shared sender. Pass `template` to pick a specific approved template by name.

If no approved template matches, the start returns `422` with code `TEMPLATE_UNAVAILABLE`.

***

## Pricing and balance

WhatsApp messages are charged from your workspace's **WhatsApp balance**, which is separate from SMS
credits. The price depends on the recipient's country and follows Meta's rate card. See the current
rates on the [pricing page](https://torpedo.co.mz/pricing#whatsapp), or fetch them without an API key:

```bash theme={null}
curl https://api.torpedo.co.mz/api/v1/billing/whatsapp-rates
```

The response lists a price per country (values below are only an example):

```json theme={null}
{
  "data": {
    "rateDate": "2026-10-01",
    "rates": [{ "country": "MZ", "dialCode": "+258", "priceMzn": "1.90", "priceUsd": "0.0297" }]
  }
}
```

* The balance is held in MZN or USD, chosen on your first top-up. Top up with M-Pesa or a card from
  **WhatsApp → Buy credits**.
* Each send is charged when the verification starts or the code is resent. A WhatsApp message that
  fails is refunded, since WhatsApp only bills delivered authentication messages.
* An SMS fallback or SMS-channel verification uses one [SMS credit](https://torpedo.co.mz/docs/guides/sms-credits.md).
* Torpedo emails you when your WhatsApp balance runs low. Set the threshold in **WhatsApp → Settings**.

To read your balance from code, call `GET /api/v1/workspace/usage` with your API key:

```bash theme={null}
curl https://api.torpedo.co.mz/api/v1/workspace/usage \
  -H "X-API-Key: YOUR_API_KEY"
```

`data.usage.whatsappBalance` is the balance as a decimal string, for example `"412.5000"`, and
`data.usage.whatsappBalanceCurrency` is `MZN`, `USD`, or `null` before your first top-up.

When the balance can't cover a message, the start returns `402`:

```json theme={null}
{
  "errors": [
    {
      "code": "PAYMENT_REQUIRED",
      "message": "Not enough WhatsApp balance for this destination. Top up your WhatsApp balance to continue."
    }
  ]
}
```

***

## Errors

| Status | `code` | Why |
| - | - | - |
| `402` | `PAYMENT_REQUIRED` | Not enough WhatsApp balance or SMS credits |
| `404` | `NOT_FOUND` | No verification with this ID in your workspace |
| `409` | `VERIFICATION_PENDING` | The number already has a pending verification |
| `410` | See [Check the code](#check-the-code) | The verification has already ended |
| `422` | `VALIDATION_ERROR` | Invalid field, [suppressed recipient](https://torpedo.co.mz/docs/guides/manage-suppressions.md#whatsapp-suppressions), or a test key sending to another number |
| `422` | `COUNTRY_NOT_ALLOWED` | The number's country isn't enabled in Verify settings |
| `422` | `CHANNEL_UNAVAILABLE` | SMS was requested for a number outside Mozambique |
| `422` | `TEMPLATE_UNAVAILABLE` | No approved WhatsApp template for this locale or name |
| `429` | `RATE_LIMITED`, `PREMATURE_RETRY`, `MAX_SEND_ATTEMPTS`, `TEST_QUOTA_EXCEEDED` | A limit was reached. Wait for `Retry-After` |
| `503` | `SERVICE_UNAVAILABLE` | WhatsApp sending isn't available right now |


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