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.
Source: GitHub repository
Requires: PHP
>= 8.2with thejsonextension
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.
BasicAuthrequires the application key and secret.ApplicationAuthalso requires both and signs each request.PublicAuthuses 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
allowordeny. 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 |
|---|---|---|
|
|
Start a verification for one phone number. |
|
|
Submit a code for a specific verification. |
|
|
Retrieve a specific verification. |
|
|
Submit a code without providing a verification ID. |
|
|
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 |
|---|---|---|
|
|
|
|
|
|
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 |
|---|---|
|
Preferred message-template languages as BCP 47 tags, ordered from most to least preferred. See Supported languages. |
|
Android SMS Retriever application hash. It must contain exactly 11 characters from
|
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 |
|---|---|
|
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 |
|---|---|---|
|
|
Verification identifier. |
|
|
Destination number normalized without a leading |
|
|
Delivery method used for the verification. |
|
|
Quoted verification fee, as a decimal string. |
|
|
Current verification status. |
|
|
Machine-readable reason for a failed, expired, or denied verification. |
|
|
Human-readable text associated with |
|
|
Verification expiration time. |
|
|
SMS-specific response fields. It is |
|
|
Phone call response fields. It is |
|
|
Method. |
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 |
|---|---|
|
|
|
|
|
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 |
|---|---|---|
|
|
Which published environment to address. |
|
|
An origin that overrides |
|
|
Total request timeout in seconds. |
|
|
Connection timeout in seconds. |
|
|
Passed to Guzzle’s |
|
|
Passed to Guzzle’s |
|
|
A Guzzle handler or |
|
|
Read-retry policy. See Retries. |
|
|
A PSR-20 clock used for the |
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 |
|---|---|---|---|
|
|
Required |
Server-to-server integrations. |
|
|
Not used |
Untrusted clients controlled through a request callback. |
|
|
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.comandhttps://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 |
|---|---|---|
|
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. |
|
|
Authentication failed or the authentication mode is not allowed. |
|
|
The account balance is insufficient. |
|
|
The verification could not be found, either because no such verification exists for the OTP application or because it passed the retention period. |
|
|
The request is invalid. |
|
|
|
|
|
The API, or infrastructure in front of it, returned a server error. |
|
Other |
Any other unsuccessful status. Base class of the six above. |
|
Not applicable |
No response at all: connection failure, timeout, or TLS error. |
|
|
A successful response, or a callback body, this SDK could not read. |
Every ApiException carries the following:
Member |
Description |
|---|---|
|
HTTP response status. |
|
List of |
|
Machine-readable codes from all returned errors, in order. |
|
|
|
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.