# PHP SDK

The PHP SDK (`didww/didww-verification-php-sdk`) is a server-side client for the
[Verification API](../api-reference/index.html#otp-verification-api-reference). It provides methods for starting,
reporting, and retrieving verifications by ID or phone number. The PHP SDK supports all three
[authentication modes](../authentication.html#otp-verification-authentication) and includes helpers for
verifying inbound [callback](../callbacks.html#otp-verification-callbacks) signatures.

- **Source:** [GitHub repository](https://github.com/didww/didww-verification-php-sdk)
- **Requires:** PHP `>= 8.2` with the `json` extension

The SDK is built on Guzzle 7. Every class lives under the `Didww\Verification` namespace.

---

## Before you begin

Complete [Getting Started](../getting-started.html#otp-verification-getting-started) in the environment where
the PHP client will send verification requests. Create the OTP application and copy its
credentials from that environment.

- Choose the [authentication mode](../authentication.html#otp-verification-authentication) for the integration.
  `BasicAuth` requires the application key and secret. `ApplicationAuth` also requires
  both and signs each request. `PublicAuth` uses only the application key but requires a
  callback URL so your server can approve each start.
- Store the application secret in a server-side environment variable or secret manager. Never
  include it in browser or mobile application code.
- If the OTP application has a callback URL, expose a server endpoint that can receive the
  request and verify its signature before returning `allow` or `deny`. See
  [Callbacks](../callbacks.html#otp-verification-callbacks).

## Installation

```
composer require didww/didww-verification-php-sdk
```

The package autoloads through Composer under the `Didww\Verification\` namespace. It
installs Guzzle 7 and the PSR HTTP message and clock interfaces as runtime dependencies.

The callback verifier needs no web framework. It accepts plain strings or a PSR-7 request, so
it works with any framework you already use.

## Quick start

Create a client, start an SMS verification, submit the code entered by the user, and read the
result. This example uses the sandbox environment and HTTP Basic authentication:

```
use Didww\Verification\Auth\BasicAuth;
use Didww\Verification\ClientOptions;
use Didww\Verification\Environment;
use Didww\Verification\Exception\ValidationException;
use Didww\Verification\Model\DeliveryMethod;
use Didww\Verification\Request\SmsOptions;
use Didww\Verification\VerificationClient;

$auth = new BasicAuth((string) getenv('DIDWW_KEY'), (string) getenv('DIDWW_SECRET'));

$client = new VerificationClient($auth, new ClientOptions(environment: Environment::Sandbox));

$verification = $client->startVerification(
    '+4915112345678',
    DeliveryMethod::Sms,
    sms: new SmsOptions(languages: ['en-US']),
);

$verification->id;           // "0f9c8b7a-1e2d-4c3b-9a8f-7e6d5c4b3a21"
$verification->status;       // "pending"
$verification->isFinished(); // false

$code = trim((string) fgets(STDIN));

// One attempt is consumed whatever the outcome, so never loop on this.
try {
    $verification = $client->reportVerification($verification->id, DeliveryMethod::Sms, $code);
} catch (ValidationException $e) {
    if (!$e->hasCode('code_invalid')) {
        throw $e;
    }
    askAgain(); // still pending; the user may try again
}

if ($verification->isVerified()) {
    grantAccess();
}
```

Create the client once and reuse it. It creates and manages its own Guzzle HTTP client, so
you do not need to close anything.

Each method returns a new `Verification` object showing the state returned by that request.
The object is read-only and does not update automatically. Call `getVerification(...)` to
get the latest state.

Reporting an incorrect code throws a `ValidationException` while attempts remain. The
response has HTTP status `422` and error code `code_invalid`. The verification stays
`pending`, so the user can try again.

After all three attempts have been used, the next report returns HTTP status `200`,
with `status` set to `failed` and `errorCode` set to `too_many_attempts`. See
[Reporting a code](#reporting-a-code).

## Available methods

| SDK method | HTTP request | Purpose |
| --- | --- | --- |
| `startVerification($destination, $method, ...)` | `POST /verifications` | Start a verification for one phone number. |
| `reportVerification($id, $method, $code)` | `PATCH /verifications/{id}` | Submit a code for a specific verification. |
| `getVerification($id)` | `GET /verifications/{id}` | Retrieve a specific verification. |
| `reportVerificationByNumber($number, $method, $code)` | `PATCH /verifications/by_number/{number}` | Submit a code without providing a verification ID. |
| `getVerificationByNumber($number)` | `GET /verifications/by_number/{number}` | Retrieve the latest verification associated with a phone number. |

The PHP SDK adds the `/api/v1` base path to these requests.

`startVerification(...)` takes the destination and the `DeliveryMethod` positionally, and
the delivery-method options as the named arguments `sms:` and `callout:`. Every method
returns a `Didww\Verification\Model\Verification` or throws.

Two further methods, `reportVerificationRaw(...)` and `reportVerificationByNumberRaw(...)`,
take the delivery method as an open string and perform no client-side check. Use them only
when a verification you can read reports a delivery method that this release does not model.

## Delivery methods

The delivery method determines which value your application must submit when reporting a
verification:

| Delivery method | Report parameter | Example |
| --- | --- | --- |
| `DeliveryMethod::Sms` | `$code` | `'123456'` |
| `DeliveryMethod::Callout` | `$code` | `'123456'` |

The delivery method supplied when reporting must match the method used to start the
verification. `DeliveryMethod` is a string-backed enum whose values are `sms` and
`callout`. `$verification->deliveryMethodEnum()` returns the matching case for a value
read back from the API, or `null` when this release does not know it.

## Delivery-method options

Pass method-specific options in the object named after the delivery method. Use `sms:` when
starting an SMS verification and `callout:` when starting a phone call verification. The
SDK sends only the block matching the selected delivery method. Options supplied for the
other method are ignored.

### SMS options

| Field | Description |
| --- | --- |
| `languages` | Preferred message-template languages as BCP 47 tags, ordered from most to least preferred. See [Supported languages](../verification-methods/sms.html#otp-verification-sms-languages). |
| `appHash` | Android SMS Retriever application hash. It must contain exactly 11 characters from `A-Z`, `a-z`, `0-9`, `+`, and `/`. Omit this field when automatic Android SMS capture is not used. |

```
use Didww\Verification\Request\SmsOptions;

$verification = $client->startVerification(
    '+4915112345678',
    DeliveryMethod::Sms,
    sms: new SmsOptions(languages: ['en-US'], appHash: 'A1b2C3d4E5f'),
);
```

The app hash is derived from the Android package name and signing certificate. It is not a
secret and does not authenticate the application. A PHP backend can pass the value supplied
by the Android application to the Verification API. A malformed hash rejects the whole start
with `422` and `app_hash_invalid`.

`SmsOptions` takes named constructor arguments, so a misspelled field name is an `Error`
at the call site rather than a silently dropped key.

### Phone call options

| Field | Description |
| --- | --- |
| `languages` | Preferred announcement languages as BCP 47 tags, ordered from most to least preferred. See [Supported languages](../verification-methods/phone-call.html#otp-verification-callout-languages). |

```
use Didww\Verification\Request\CalloutOptions;

$verification = $client->startVerification(
    '+5511987654321',
    DeliveryMethod::Callout,
    callout: new CalloutOptions(languages: ['pt-BR', 'pt-PT']),
);
```

Warning

Send the region subtag. Template lookup is an exact match on the canonical tag, so a bare
primary subtag such as `pl` passes validation and then silently falls back to
`en-US`. Send `pl-PL`.

The two catalogues are separate. A tag that has an SMS template may still have no
announcement recording, so one language list can resolve differently per delivery method.

## Verification responses

Every successful SDK request returns a `Verification`. Its properties are `readonly`.

| Property | PHP type | Description |
| --- | --- | --- |
| `id` | `string` | Verification identifier. |
| `destination` | `string` | Destination number normalized without a leading `+`. |
| `deliveryMethod` | `string` | Delivery method used for the verification. |
| `fee` | `?string` | Quoted verification fee, as a decimal string. |
| `status` | `string` | Current verification status. |
| `errorCode` | `?string` | Machine-readable reason for a failed, expired, or denied verification. |
| `errorDetail` | `?string` | Human-readable text associated with `errorCode`. |
| `expiresAt` | `?DateTimeImmutable` | Verification expiration time. |
| `sms` | `?SmsInfo` | SMS-specific response fields. It is `null` for phone call. |
| `callout` | `?CalloutInfo` | Phone call response fields. It is `null` for SMS. |
| `isFinished()` | `bool` | Method. `true` once the verification reached a terminal state. |

`isPending()` and `isVerified()` test for the two statuses you branch on most often.

`raw` holds the decoded response `data` object for debugging. Its shape is not covered by
semantic versioning.

### Reading SMS response details

An SMS verification carries an `sms` block:

```
$verification->sms?->template;            // "Your code is {{CODE}}"
$verification->sms?->language;            // "en-US"
$verification->sms?->interceptionTimeout; // 300
$verification->sms?->appHash;             // "A1b2C3d4E5f", or null
$verification->sms?->codeLength;          // 6
```

`$verification->sms` is `null` for a phone call verification, so guard the access.

`interceptionTimeout` is the number of seconds an on-device client should continue
listening for automatic SMS capture. It equals the application's **Code timeout** (60-600 seconds,
300 by default). It is not the verification expiration time. Manual code entry remains
available until `expiresAt`.

`codeLength` is the length of this verification's code: 4 to 8 digits, set per application
(6 by default).

`appHash` is echoed back only when a hash was stored for the verification. Comparing it
with the value you sent is the only confirmation that it was accepted.

`language` is the language the API **selected**, which is not necessarily the first one
requested. Compare it with the list you sent to detect a fallback to `en-US`.

### Reading phone call response details

A phone call verification carries a `callout` block:

```
$verification->callout?->language;   // "de-DE"
$verification->callout?->codeLength; // 6
```

`language` is the language the announcement was played in. The announcement recordings are
a different set from the SMS templates, so a tag that is honored for SMS can still fall back
for a phone call. `codeLength` is the length of this verification's code, as for SMS.

### Outcomes are results, not exceptions

A verification that ends `failed`, `expired`, or `denied` is a **successful** API call.
It is returned, not thrown. Read the result instead of catching something:

```
$verification = $client->getVerification($verificationId);

if ($verification->isVerified()) {
    grantAccess();
} elseif ($verification->isFinished()) {
    showVerificationError($verification->errorCode, $verification->errorDetail);
} else {
    keepPolling();
}
```

`isFinished()` is the signal to stop polling. It is `true` for every status other than
`pending`, so a status added after this release is treated as finished rather than polled
forever.

Statuses and error codes are open sets. `status`, `errorCode`, and `deliveryMethod` are
plain strings, so a value added after this release decodes instead of failing. The
`statusEnum()`, `errorCodeEnum()`, and `deliveryMethodEnum()` methods return the
matching enum case, or `null` for a value this release does not know:

```
use Didww\Verification\Model\VerificationStatus;

VerificationStatus::cases(); // Pending, Verified, Failed, Expired, Denied

if (null === $verification->statusEnum()) {
    reportUnknownStatus($verification->status);
}
```

`ErrorCode::outcomeCodes()` lists the codes a finished verification can carry.

`errorCode` and `errorDetail` describe an unsuccessful verification result. They are
`null` while the verification is pending and when it is verified. These fields are different
from the errors carried by a thrown `ApiException`.

Note

The `fee` value is the quoted verification fee, not an immediate charge. It is billed
only when the verification becomes `verified`. SMS or call delivery costs are billed
separately.

## Reporting a code

Each report consumes one of three attempts. While attempts remain, an incorrect code is
rejected with `422` and `code_invalid`, and the verification stays `pending`. Once all
three are used, the next report returns `200` with `status` `failed` and `errorCode`
`too_many_attempts`. A report sent while the challenge is still being delivered is rejected
with `422` and `not_ready_to_report`. Wait briefly before submitting the value again.

```
use Didww\Verification\Exception\ValidationException;

try {
    $verification = $client->reportVerification($verification->id, DeliveryMethod::Sms, $entered);
} catch (ValidationException $e) {
    if ($e->hasCode('code_invalid')) {
        askAgain(); // still pending
    } elseif ($e->hasCode('not_ready_to_report')) {
        retryShortly(); // the challenge is still being sent
    } else {
        throw $e;
    }
}
```

## Using phone numbers instead of IDs

Use the by-number methods when your application retained the destination number but not the
verification ID:

```
$current = $client->getVerificationByNumber('+4915112345678');

$result = $client->reportVerificationByNumber('+4915112345678', DeliveryMethod::Sms, '123456');
```

Supply the phone number in E.164 format. The SDK sends only its digits in the URL path, so
`+49 151 1234 5678` and `4915112345678` address the same verification. A number with no
digits throws `ConfigurationException` before anything is sent.

`getVerificationByNumber(...)` returns the **newest** verification associated with the
number, finished ones included. `reportVerificationByNumber(...)` reports against that same
newest verification. If it has already finished, the report does not change its outcome, so
read `status` from the result. If the number has no verification, the SDK throws
`NotFoundException`.

Only one unfinished verification can exist for the same OTP application and phone number. A
new approved start supersedes the previous unfinished verification. The previous verification
changes to `failed` with `errorCode` set to `superseded`.

Warning

A start that is itself denied does not supersede an earlier live verification. A by-number
read can therefore return the denied record while the live verification remains reachable
only by its ID. Keep the ID from the start response whenever you can, and use the ID-based
methods when the report or status check must apply to the exact verification shown to the
user.

## Environments

Choose the environment when creating the client. The SDK uses `Environment::Production` by
default.

| Environment | Base URL |
| --- | --- |
| `Environment::Production` (default) | `https://verification.didww.com` |
| `Environment::Sandbox` | `https://verification-sandbox.didww.com` |
| `baseUrl:` override | Any origin you supply. |

Use the sandbox while building and testing your integration:

```
use Didww\Verification\ClientOptions;
use Didww\Verification\Environment;
use Didww\Verification\VerificationClient;

$client = new VerificationClient($auth, new ClientOptions(environment: Environment::Sandbox));
```

Use credentials from an OTP application created in the same environment as the client. See
[Choose an environment](../getting-started.html#otp-verification-environments) for the corresponding User Panel
and API URLs.

`baseUrl:` overrides `environment:` and is how you point the client at a local test
server:

```
$client = new VerificationClient($auth, new ClientOptions(baseUrl: 'http://localhost:3000'));
```

Warning

`baseUrl` must be an `http` or `https` **origin** with no path. The SDK adds its own
`/api/v1` prefix. A base URL carrying a path, a query, a fragment, or credentials throws
`ConfigurationException` when `ClientOptions` is created.

## Configuration

All client options are named arguments to the `ClientOptions` constructor, passed as the
second argument of `VerificationClient`:

| Option | Default | Description |
| --- | --- | --- |
| `environment` | `Environment::Production` | Which published environment to address. |
| `baseUrl` | `null` | An origin that overrides `environment`. |
| `timeout` | `30.0` | Total request timeout in seconds. |
| `connectTimeout` | `10.0` | Connection timeout in seconds. |
| `proxy` | `null` | Passed to Guzzle's `proxy` request option. |
| `verify` | `true` | Passed to Guzzle's `verify` request option: a boolean or a CA bundle path. |
| `handler` | `null` | A Guzzle handler or `HandlerStack`. A bare handler is wrapped with `HandlerStack::create()`. An existing `HandlerStack` is used as given. |
| `retry` | `new RetryPolicy()` | Read-retry policy. See [Retries](#retries). |
| `clock` | `null` | A PSR-20 clock used for the `x-timestamp` header. The system clock by default. |

The SDK sends `didww-verification-php/<version>` in the `User-Agent` header. It does not
follow redirects or allow default headers. Either behavior could make a request differ from
what was signed, and following a redirect could repeat a write.

```
use Didww\Verification\ClientOptions;
use Didww\Verification\VerificationClient;

$client = new VerificationClient($auth, new ClientOptions(
    timeout: 10.0,
    connectTimeout: 3.0,
    proxy: 'http://proxy.example.com:3128',
));
```

### Custom Guzzle handlers

Pass a Guzzle handler or `HandlerStack` through the `handler` option. Build a custom
stack with `HandlerStack::create()` so Guzzle's standard middleware remains installed:

```
use Didww\Verification\ClientOptions;
use GuzzleHttp\HandlerStack;

$stack = HandlerStack::create();
$stack->push($middleware);

$client = new VerificationClient(
    $auth,
    new ClientOptions(handler: $stack),
);
```

The same option accepts Guzzle's `MockHandler` when a test must run without making network
requests.

Warning

Handler middleware runs **after** the request is signed. Middleware that retries, or that
changes the method, the path, the signed headers (`Authorization`, `x-timestamp`,
`Content-Type`), or the body is unsupported. A changed request fails its signature check
with `401`. A retried start sends another code, and a retried report consumes an attempt.
Use the SDK's own `retry` option instead. Middleware that only observes, such as logging,
metrics, or tracing, is safe.

Warning

The SDK logs nothing, and neither does Guzzle on its own. If you add logging middleware,
such as `GuzzleHttp\Middleware::log()`, it sees each request after signing. Redact the
`Authorization` and `x-timestamp` headers, because `BasicAuth` sends the secret
itself, and redact or skip the `by_number` URLs, because they contain the phone number.

## Authentication

Pass an authentication object as the first argument to the client. The OTP application's
minimum authentication mode must allow the mode you select. See
[Authentication](../authentication.html#otp-verification-authentication).

| Class | Header | Secret | Use |
| --- | --- | --- | --- |
| `BasicAuth($key, $secret)` | `Basic base64(key:secret)` | Required | Server-to-server integrations. |
| `PublicAuth($key)` | `Application <key>` | Not used | Untrusted clients controlled through a request callback. |
| `ApplicationAuth($key, $secret)` | `Application <key>:<signature>` and `x-timestamp` | Required | Signed server-to-server integrations. |

The three classes live in the `Didww\Verification\Auth` namespace.

### HTTP Basic authentication

`BasicAuth` sends the application key as the username and the secret as the password. It is
server-to-server only: the secret is recoverable from anything that ships it.

```
use Didww\Verification\Auth\BasicAuth;
use Didww\Verification\VerificationClient;

$client = new VerificationClient(new BasicAuth($key, $secret));
```

### Public authentication

`PublicAuth` sends only the application key, which identifies rather than authenticates. A
callback URL must be configured on the OTP application so your backend can approve each start
request. With no callback URL registered, a start under this mode is denied outright.

```
use Didww\Verification\Auth\PublicAuth;
use Didww\Verification\VerificationClient;

$client = new VerificationClient(new PublicAuth($key));
```

### Signed application authentication

`ApplicationAuth` signs each request with HMAC-SHA256 and sends an `x-timestamp` header.
It is the only mode whose start requests skip the outbound callback, because a signed caller
is already trusted.

```
use Didww\Verification\Auth\ApplicationAuth;
use Didww\Verification\VerificationClient;

$client = new VerificationClient(new ApplicationAuth($key, $secret));
```

Pass the secret exactly as shown in the DIDWW User Panel. Each authentication class validates
the key, and `BasicAuth` and `ApplicationAuth` also the secret, when it is constructed. A
malformed value throws `ConfigurationException` there, rather than on the first request.

Warning

Treat the application secret as a password. Store it in a secrets manager or a protected
environment variable, never in source control or client-side code.

Every authentication failure returns `401` without further detail. This includes an unknown
key, a wrong secret, a bad signature, a stale timestamp, and a mode below the application's
minimum.

## Handling a denied start

With `PublicAuth`, the Verification API sends a synchronous request to the OTP application's
callback URL before attempting delivery. A denied start can still return `201 Created`,
because the verification record was created successfully. In that case
`startVerification(...)` returns a `Verification` with status `denied` instead of
throwing.

Handle the initial status before asking the user to enter a code:

```
$verification = $client->startVerification('+4915112345678', DeliveryMethod::Sms);

if ($verification->isPending()) {
    showCodeEntry($verification);
} elseif ('denied' === $verification->status) {
    showUnavailable($verification->errorCode, $verification->errorDetail);
} else {
    handleUnexpectedStartStatus($verification->status);
}
```

An explicit callback denial returns `errorCode` `denied_by_callback`. An unusable
callback response returns `denied_invalid_callback_response`. If no callback URL is
configured, the result is `denied_missing_callback_url`. See [Callbacks](../callbacks.html#otp-verification-callbacks)
for the complete flow.

## Verifying inbound callbacks

Use `CallbackVerifier` to verify a signed `verification_request` callback before parsing
its body or deciding whether to allow the verification. The verifier checks the signature and
enforces a 300-second timestamp window by default.

```
use Didww\Verification\Callback\CallbackVerifier;

$verifier = new CallbackVerifier(
    secret: $applicationSecret,
    callbackUrl: 'https://example.com/callbacks/didww', // as registered, verbatim
    toleranceSeconds: 300,
);
```

There is **one request and no retry**. Whatever you answer decides the verification.

Warning

`callbackUrl` must be the URL **registered with DIDWW**, not the path the request
arrives on. An ingress that rewrites paths, or an application mounted under a prefix,
makes the two differ. Two consequences follow:

- A registered URL with no path, such as `https://example.com`, signs the **empty
  string**, not `/`. A verifier that defaults to the received path computes a valid
  signature over `/` and then denies every verification, with correct code on both sides.
- `https://example.com` and `https://example.com/` produce different signatures. Do
  not normalize the trailing slash.

The query string is never signed. `$verifier->signedPath()` returns the path the verifier
signs.

### Laravel

```
use Didww\Verification\Callback\CallbackRequest;
use Didww\Verification\Callback\CallbackResponse;
use Illuminate\Http\Request;

Route::post('/callbacks/didww', function (Request $request) use ($verifier) {
    $valid = $verifier->isValid(
        $request->method(),
        (string) $request->header('Content-Type', ''),
        $request->getContent(),
        $request->header('x-timestamp'),
        $request->header('Authorization'),
    );
    if (!$valid) {
        // No reason in the body: it would be an oracle for which keys exist.
        return response('', 401);
    }

    $callback = CallbackRequest::fromJson($request->getContent());
    $body = isExpected($callback) ? CallbackResponse::allow() : CallbackResponse::deny();

    return response($body, 200, ['Content-Type' => 'application/json']);
});
```

Exclude the route from CSRF protection, or register it in `routes/api.php`, which has none.
The request comes from DIDWW, not from a form, and carries its own signature.

### Symfony

```
use Didww\Verification\Callback\CallbackRequest;
use Didww\Verification\Callback\CallbackResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

public function didwwCallback(Request $request): Response
{
    $valid = $this->verifier->isValid(
        $request->getMethod(),
        (string) $request->headers->get('Content-Type', ''),
        $request->getContent(),
        $request->headers->get('x-timestamp'),
        $request->headers->get('Authorization'),
    );
    if (!$valid) {
        return new Response('', 401);
    }

    $callback = CallbackRequest::fromJson($request->getContent());
    $body = 'verification_request' === $callback->event
        ? CallbackResponse::allow()
        : CallbackResponse::deny();

    return new Response($body, 200, ['Content-Type' => 'application/json']);
}
```

### PSR-7

Pass a PSR-7 `ServerRequestInterface` directly:

```
$reason = $verifier->checkRequest($serverRequest); // or isValidRequest()
```

A seekable body is rewound before and after reading, so it may already have been read. A
non-seekable body that was already consumed cannot be verified.

### Any other framework

Pass the pieces yourself. In plain PHP:

```
use Didww\Verification\Callback\CallbackRequest;
use Didww\Verification\Callback\CallbackResponse;

$body = (string) file_get_contents('php://input'); // the received bytes
$headers = array_change_key_case(getallheaders() ?: [], CASE_LOWER);

$reason = $verifier->check(
    $_SERVER['REQUEST_METHOD'],
    $_SERVER['CONTENT_TYPE'] ?? '',
    $body,
    $headers['x-timestamp'] ?? null,
    $headers['authorization'] ?? null,
);
if (null !== $reason) {
    error_log('DIDWW callback rejected: '.$reason->value);
    http_response_code(401);
    exit;
}

$callback = CallbackRequest::fromJson($body);
header('Content-Type: application/json');
echo isExpected($callback) ? CallbackResponse::allow() : CallbackResponse::deny();
```

The body must be the bytes exactly as received. Parsing the JSON and serializing it again
changes key order, whitespace, and escaping, and an otherwise correct signature will not
match. Some web server setups drop the `Authorization` header before it reaches PHP. Make
sure yours passes it through. If `getallheaders()` is unavailable, read the timestamp and
authorization values from `$_SERVER['HTTP_X_TIMESTAMP']` and
`$_SERVER['HTTP_AUTHORIZATION']`.

`check(...)` is the same call as `isValid(...)`, returning a `RejectionReason`
(`MissingSignature`, `MissingTimestamp`, `MalformedTimestamp`, `StaleTimestamp`, or
`BadSignature`) instead of a boolean. Use it for your logs only. Answer with
`CallbackResponse::allow()` or `CallbackResponse::deny()` as `application/json` and
nothing else. Reporting *why* a request failed tells a prober which check failed.

`CallbackRequest::fromJson(...)` exposes `event`, `id`, `destination`, and
`deliveryMethod`, and throws `DecodingException` for a body it cannot read. Verify the
request before trusting any of them.

## Retries

Only read requests are retried. A retry occurs after a transport fault or a `5xx` response.
By default, the SDK retries once with exponential backoff and full jitter. Each attempt is
signed again with a fresh timestamp.

```
use Didww\Verification\ClientOptions;
use Didww\Verification\RetryPolicy;

new ClientOptions(retry: new RetryPolicy(attempts: 3, baseDelay: 0.5));
new ClientOptions(retry: new RetryPolicy(attempts: 1)); // off
```

`attempts` counts total tries, so the default of `2` means one retry. `baseDelay`
defaults to `0.2` seconds.

Warning

Starts and reports are **never** retried, and you should not add retries around them. The
API has no idempotency key. A repeated start supersedes the live verification and sends
another code. A repeated report consumes one of three attempts. Exceeding the attempt
limit returns a normal `200` response with status `failed`, so read the result instead
of counting attempts yourself.

If a report request times out after it may have reached the API, retrieve the verification
status before deciding whether to submit the value again.

## Error handling

Only transport faults, non-2xx responses, and unreadable bodies throw. Every exception lives
in the `Didww\Verification\Exception` namespace and implements `VerificationException`,
so `catch (VerificationException $e)` catches anything the SDK throws.

| Exception | HTTP status | When it is thrown |
| --- | --- | --- |
| `ConfigurationException` | Not applicable | An invalid key or secret, an unusable base URL, an empty verification ID, or a phone number without digits. Thrown before anything is sent. |
| `UnauthorizedException` | `401` | Authentication failed or the authentication mode is not allowed. |
| `BalanceInsufficientException` | `402` | The account balance is insufficient. |
| `NotFoundException` | `404` | The verification could not be found, either because no such verification exists for the OTP application or because it passed the [retention period](../api-reference/get-verification-status.html#otp-verification-retention). |
| `ValidationException` | `400` or `422` | The request is invalid. |
| `RateLimitedException` | `429` | `destination_in_cooldown`: a start for the same number came too soon after the previous one. `retryAfter` holds the `Retry-After` header in seconds, or `null`. Wait that long before starting again for that number. A start is never retried automatically. |
| `ServerException` | `5xx` | The API, or infrastructure in front of it, returned a server error. |
| `ApiException` | Other | Any other unsuccessful status. Base class of the six above. |
| `TransportException` | Not applicable | No response at all: connection failure, timeout, or TLS error. |
| `DecodingException` | `2xx` | A successful response, or a callback body, this SDK could not read. |

Every `ApiException` carries the following:

| Member | Description |
| --- | --- |
| `status` | HTTP response status. |
| `errors` | List of `ErrorItem` objects from the API response. |
| `codes()` | Machine-readable codes from all returned errors, in order. |
| `hasCode($code)` | `true` when the envelope carries `$code` anywhere. |
| `body` | The first 512 bytes of the response body. |

One response can carry several errors. A validation failure returns one error per field, so
use `codes()` and `hasCode(...)` rather than `errors[0]`. Each `ErrorItem` has `code`
and `detail`. Branch on `code`. Use `detail` only as display text and never parse it.
`codeEnum()` maps the code to an `ErrorCode` case, or `null` for a code this release does
not know.

A non-2xx whose body is not JSON still throws the status-mapped exception with an empty
`errors` list, so an error page produced by a proxy surfaces as the server error it is. See
[Errors and status codes](../api-reference/errors.html#otp-verification-api-errors).

```
use Didww\Verification\Exception\ApiException;
use Didww\Verification\Exception\TransportException;
use Didww\Verification\Exception\UnauthorizedException;
use Didww\Verification\Exception\ValidationException;

try {
    $verification = $client->startVerification($number, DeliveryMethod::Sms);
} catch (ValidationException $e) {
    if ($e->hasCode('destination_invalid')) {
        showNumberError();
    } else {
        showRequestError($e->codes());
    }
} catch (UnauthorizedException) {
    reportConfigurationError();
} catch (ApiException $e) {
    reportApiError($e->status, $e->codes());
} catch (TransportException $e) {
    showNetworkError($e);
}
```

`TransportException` wraps every failure inside the Guzzle handler stack, your own
middleware included, so Guzzle exceptions never appear in your `catch` clauses. It does not
keep the original exception as `previous`, because that one holds the request and its
`Authorization` header.

On this page
