PHP SDK#

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

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


Before you begin#

Complete 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 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.

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.

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.

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.

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 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.

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.

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 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.

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.

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.