didww / didww-verification-php-sdk
PHP SDK for the DIDWW Verification API
Package info
github.com/didww/didww-verification-php-sdk
pkg:composer/didww/didww-verification-php-sdk
Requires
- php: >=8.2
- ext-json: *
- guzzlehttp/guzzle: ^7.15.2
- guzzlehttp/psr7: ^2.12.3
- psr/clock: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-22 09:29:16 UTC
README
PHP client for the DIDWW Verification API: start a phone verification, report the code the user entered, and read the outcome. Includes a callback verifier for the requests DIDWW sends to your application.
- Built on Guzzle 7
- Verification API documentation
Installation
composer require didww/didww-verification-php-sdk
Requires PHP 8.2 or newer and the json extension.
Quick start
use Didww\Verification\Auth\BasicAuth; use Didww\Verification\Model\DeliveryMethod; use Didww\Verification\VerificationClient; $client = new VerificationClient(new BasicAuth($key, $secret)); $verification = $client->startVerification('+15555550100', DeliveryMethod::Sms); // The code arrives by SMS; ask the user for it, then report it. // A wrong code throws: see "Reporting a code". $verification = $client->reportVerification($verification->id, DeliveryMethod::Sms, '123456'); echo $verification->status; // "verified", "failed", ...
Every client method returns a Didww\Verification\Model\Verification or throws.
Outcomes are data, not exceptions
A verification that ends failed, expired or denied is a successful API
call. Read the result rather than catching something:
$verification = $client->getVerification($verificationId); if ($verification->isVerified()) { grantAccess(); } elseif ($verification->isFinished()) { // errorCode says why: too_many_attempts, expired, superseded, ... show($verification->errorDetail); } else { keepPolling(); }
isFinished() is the signal to stop polling. status, errorCode and
deliveryMethod are plain strings, so a value added after this release decodes
instead of failing. The statusEnum(), errorCodeEnum() and deliveryMethodEnum()
helpers return the matching VerificationStatus, ErrorCode or DeliveryMethod
case, or null for a value this release does not know. An unknown status counts as
finished. ErrorCode::outcomeCodes() lists the codes a finished verification can carry.
raw holds the decoded response for debugging; its shape is not covered by semantic
versioning.
Only transport faults, non-2xx responses and unreadable bodies throw; see Errors. A wrong code is one of those non-2xx responses.
Reporting a code
Each report consumes one of three attempts. While attempts remain, a wrong code is
rejected with 422 and code_invalid, and the verification stays pending, so the user
can try again. Once all three are used, the next report is answered with a normal 200
whose status is failed and whose errorCode is too_many_attempts.
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; } }
Addressing a verification by phone number
When the id was never persisted, every read and report has a by-number twin:
$client->getVerificationByNumber('+15555550100'); $client->reportVerificationByNumber('+15555550100', DeliveryMethod::Sms, '123456');
The SDK sends only the digits, so +1 555-555-0100 and 15555550100 address the
same verification. A number with no digits throws ConfigurationException before
anything is sent.
"By number" resolves to the newest verification for that number, finished ones included. A start that is itself denied does not supersede an earlier live verification, so a by-number read can return the denied one while the live one is reachable only by its id. Keep the id from the start response when you can.
Per-channel options
Options travel in a block named after the channel. Only the block matching the delivery method is sent: options for the other channel are dropped.
use Didww\Verification\Model\Autofill; use Didww\Verification\Request\CalloutOptions; use Didww\Verification\Request\SmsOptions; $client->startVerification( '+15555550100', DeliveryMethod::Sms, sms: new SmsOptions(languages: ['pl-PL', 'en-US'], autofill: Autofill::appHash('AbCdEfGhIjK')), ); $client->startVerification( '+15555550100', DeliveryMethod::Callout, callout: new CalloutOptions(languages: ['de-DE']), );
Languages are BCP 47 tags, tried in order, falling back to en-US. Send the region
subtag. A bare primary subtag like pl passes validation and then silently falls
back, because the catalogue is matched on the exact canonical tag.
SMS autofill is set with autofill. Autofill::appHash($hash) adds the Android SMS
Retriever app hash, so the app can read the code without the SMS permission; a malformed
hash rejects the whole start with 422 and app_hash_invalid. Autofill::domain() adds a
domain-bound line for the application's configured SMS domain, and is refused with
sms_domain_not_configured when there is none. Autofill::none() sends no marker, and
leaving it unset uses the application default. A malformed autofill is refused with
autofill_invalid.
The appHash: option and SmsInfo::$appHash still work but are deprecated in favour of
autofill. The option is sent as Autofill::appHash(), and passing it together with autofill
throws ConfigurationException.
The response reports what was actually used, so a fallback is detected rather than guessed at:
$verification->sms?->language; // the tag the message was rendered in $verification->sms?->autofill; // echoed back when a hash or domain marker was stored $verification->sms?->interceptionTimeout; // seconds the SMS Retriever stays armed $verification->callout?->language; // the tag the announcement is played in
The two catalogues are separate: a tag with an SMS template may still have no
recording. Both blocks also carry codeLength, the generated code's length. For
domain, autofill->value is the domain.
Template groups
templateGroup picks one of your template groups instead of the application's default.
An unknown name is refused with template_group_not_found.
new SmsOptions(templateGroup: 'checkout'); new CalloutOptions(templateGroup: 'checkout');
Both sms and callout in the response report what happened:
$verification->sms?->templateGroup; // the group applied, or null $verification->sms?->templateGroupUsed; // true when the group's template was used $verification->sms?->templateFallbackReason; // why not: language_missing, version_pending, ...
When no template group applies at all, templateGroupUsed is false and
templateFallbackReason is null. group_missing comes with templateGroup null: the
requested or default group no longer exists.
templateFallbackReason is an open vocabulary: templateFallbackReasonEnum() returns the
matching TemplateFallbackReason case, or null for a reason added later.
Passing data to your callback
custom is a free-text string of up to 4096 characters, sent at the top level of the
request rather than inside a channel block:
$client->startVerification('+15555550100', DeliveryMethod::Sms, custom: 'order-42');
It reaches your callback server unchanged as CallbackRequest::$custom and is not
returned by any response. Longer values are refused with custom_too_long; an empty
string is treated as unset.
Environments
use Didww\Verification\ClientOptions; use Didww\Verification\Environment; new VerificationClient($auth); // production: https://verification.didww.com new VerificationClient($auth, new ClientOptions(environment: Environment::Sandbox)); // https://verification-sandbox.didww.com new VerificationClient($auth, new ClientOptions(baseUrl: 'http://localhost:3000'));
baseUrl overrides environment and must be an http or https origin: the SDK
adds its own /api/v1 path, and a URL with another scheme, a path, a query or
credentials throws ConfigurationException.
Testing
The sandbox environment sends no real SMS and places no real calls. Select it with
Environment::Sandbox:
new VerificationClient($auth, new ClientOptions(environment: Environment::Sandbox));
Only the reserved test numbers give a predefined result, such as delivered, failed or expired, and a delivered one has a fixed code. Any other number is never delivered to, and its verification ends as failed. The test numbers, their outcomes and the code to submit are listed in Testing on sandbox.
Authentication
Three schemes, ranked public < basic < application. Each application has a minimum;
anything below it is rejected with 401.
use Didww\Verification\Auth\ApplicationAuth; use Didww\Verification\Auth\BasicAuth; use Didww\Verification\Auth\PublicAuth; new PublicAuth($key); // Authorization: Application <key> new BasicAuth($key, $secret); // Authorization: Basic base64(key:secret) new ApplicationAuth($key, $secret); // HMAC-signed, plus an x-timestamp header
PublicAuthcarries no secret. The key identifies rather than authenticates, so it is safe in a client users can read. What authorises a start is your registered callback URL: with none registered, a start under this scheme is denied outright.BasicAuthis server-to-server only; the secret is recoverable from anything that ships it.ApplicationAuthsigns every request. It is the only scheme whose starts skip the outbound callback, since a signed caller is already trusted. Use it whenever the secret stays on your server.
A malformed key or secret throws ConfigurationException at construction rather
than failing on the first request. Pass the secret exactly as shown in the DIDWW
panel.
Every authentication failure (unknown key, wrong secret, bad signature, stale timestamp, too weak a scheme) answers 401 with no further detail, by design.
Verifying inbound callbacks
Before creating a verification, the API can call your registered callback URL and wait for you to allow or deny it. There is one request and no retry: whatever you answer decides the verification.
use Didww\Verification\Callback\CallbackVerifier; $verifier = new CallbackVerifier( $applicationSecret, 'https://example.com/callbacks/didww', // as registered, verbatim );
The callback URL must be the URL registered with DIDWW, not the path the request arrives on: an ingress that rewrites, or an app mounted under a prefix, makes these differ. Two consequences:
- A registered URL with no path,
https://example.com, signs the empty string, not/. A verifier that used the received path would compute a valid signature over/and then deny every verification, with correct code on both sides. https://example.comandhttps://example.com/are different signatures. Do not normalise the trailing slash.
The query string is never signed. $verifier->signedPath() shows the path in use.
Requests older or newer than toleranceSeconds (third constructor argument, default
300) are rejected.
The body passed to the verifier must be the received bytes: re-encoding parsed parameters changes them and the signature will not match.
Plain PHP
use Didww\Verification\Callback\CallbackRequest; use Didww\Verification\Callback\CallbackResponse; $body = (string) file_get_contents('php://input'); $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, $headers['x-signature-previous'] ?? 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();
Where getallheaders() is unavailable, read $_SERVER['HTTP_X_TIMESTAMP'],
$_SERVER['HTTP_AUTHORIZATION'] and $_SERVER['HTTP_X_SIGNATURE_PREVIOUS']. Some web
server setups drop Authorization before it reaches PHP; make sure yours passes it
through.
PSR-7
$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.
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'), $request->header('x-signature-previous'), ); if (!$valid) { return response('', 401); } $callback = CallbackRequest::fromJson($request->getContent()); $body = isExpected($callback) ? CallbackResponse::allow() : CallbackResponse::deny(); return response($body, 200, ['Content-Type' => 'application/json']); });
Register the route in routes/api.php, or exclude it from CSRF protection: 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'), $request->headers->get('x-signature-previous'), ); if (!$valid) { return new Response('', 401); } $callback = CallbackRequest::fromJson($request->getContent()); $body = isExpected($callback) ? CallbackResponse::allow() : CallbackResponse::deny(); return new Response($body, 200, ['Content-Type' => 'application/json']); }
Rotating the application secret
While an application secret is being rotated, DIDWW also sends an x-signature-previous
header: the same signature, made with the previous secret. The verifier accepts a callback
when either signature matches the secret it is configured with, so a receiver can switch to
the new secret at any time during the grace period, and one that still has the old secret
keeps working until then. Pass the header as the last argument of check() and isValid();
checkRequest() reads it for you.
Upgrade the SDK before rotating: an older verifier reads only Authorization, which is
signed with the new secret.
Answering
Answer a verified callback with CallbackResponse::allow() or
CallbackResponse::deny() as application/json, and nothing else. check() returns
a RejectionReason (MissingSignature, MissingTimestamp, MalformedTimestamp,
StaleTimestamp, BadSignature) for your logs. Never echo it in the response: it
tells a prober which check failed.
CallbackRequest::fromJson() exposes event, id, destination, deliveryMethod
and custom (null when the start passed none), and throws DecodingException for a
body it cannot read. Verify the request before trusting any of them.
Treat custom as untrusted. When the start request comes from a mobile app, the app
controls it, so check it against your own records instead of acting on it as given.
Errors
use Didww\Verification\Exception\ApiException; use Didww\Verification\Exception\ValidationException; try { $client->startVerification($number, DeliveryMethod::Sms); } catch (ValidationException $e) { if ($e->hasCode('destination_invalid')) { // ... } } catch (ApiException $e) { $logger->warning('didww: '.$e->status.' '.implode(',', $e->codes())); }
All classes live in Didww\Verification\Exception.
| Exception | When |
|---|---|
UnauthorizedException |
401 |
BalanceInsufficientException |
402 |
NotFoundException |
404 |
ValidationException |
400, 422 |
RateLimitedException |
429 |
ServerException |
5xx |
ApiException |
any other non-2xx; base class of the above |
TransportException |
no response: connect failure, timeout, TLS, any other handler failure |
DecodingException |
a 2xx body, or a callback body, this SDK could not read |
ConfigurationException |
a bad key or secret, an unusable base URL, an empty id, a phone number without digits |
Every one implements VerificationException, so catch (VerificationException $e)
catches anything the SDK throws. An exception raised inside the handler stack, your
own middleware included, arrives as TransportException. It does not keep the
original as previous, because that one holds the request and its Authorization
header.
ApiException carries status, body (the first 512 bytes, since a body can hold the
destination number), and errors, a list of ErrorItem
with a code and a detail (codeEnum() maps the code to an ErrorCode case). One
response can carry several errors (a validation failure returns one per field), so use
codes() and hasCode(), not errors[0]. code is a stable slug to switch on;
detail is fixed prose to display, never to parse.
Some starts are refused as an outcome rather than an error: a 201 whose status is
denied or failed, with errorCode set to application_paused,
destination_country_disabled or destination_not_supported_for_channel. An unsupported
destination (no country, no delivery for the channel, or a blocked range) is one of these
failed verifications, not a 422.
A non-2xx whose body is not JSON still throws the status-mapped exception with empty
errors, so an error page from a proxy surfaces as the server error it is.
A 429 throws RateLimitedException, and its code says which limit was hit:
destination_in_cooldown: a verification for this destination was requested too recently.rate_limit_per_ip: too many requests from this IP address.rate_limit_failed_auth: too many failed authentication attempts from this IP address.rate_limit_per_account: too many requests for this account.rate_limit_public_per_account: too many public requests for this account.volume_limit_reached: the application reached its daily or monthly verification limit.Retry-Aftercan be long (days): surface it to the user instead of retrying, or contact support to raise the limit.
The SDK never retries a 429: wait retryAfter seconds (null when the response carried
no usable Retry-After header), then try again yourself.
use Didww\Verification\Exception\RateLimitedException; try { $client->startVerification($number, DeliveryMethod::Sms); } catch (RateLimitedException $e) { tellUserToWait($e->retryAfter); }
Retries
Only reads (getVerification, getVerificationByNumber) are retried, on
TransportException and 5xx, with exponential backoff and full jitter. The default
is 2 attempts in total (one retry) with a 0.2 second base delay. Each attempt is
signed again with a fresh timestamp.
use Didww\Verification\RetryPolicy; new ClientOptions(retry: new RetryPolicy(attempts: 3, baseDelay: 0.5)); new ClientOptions(retry: new RetryPolicy(attempts: 1)); // off
Starts and reports are never retried, and you should not add it. The API has no idempotency key: a repeated start supersedes the live verification and charges again, and a repeated report consumes one of three attempts.
Transport options
The SDK builds and owns its Guzzle client. ClientOptions exposes:
| Option | Default | |
|---|---|---|
timeout |
30.0 |
total request timeout, seconds |
connectTimeout |
10.0 |
connect timeout, seconds |
proxy |
null |
as Guzzle's proxy request option |
verify |
true |
as Guzzle's verify request option: a bool or a CA bundle path |
handler |
null |
a Guzzle handler or HandlerStack |
new ClientOptions( timeout: 10.0, connectTimeout: 3.0, proxy: 'http://proxy.example.com:3128', );
Redirects are never followed and no default headers can be set: either would let a request differ from what was signed, and a followed redirect would repeat a write.
A bare handler is wrapped with HandlerStack::create(); a HandlerStack is used as
given. Build custom stacks with HandlerStack::create() so Guzzle's standard
middleware stays in place:
use GuzzleHttp\HandlerStack; $stack = HandlerStack::create(); $stack->push($yourMiddleware); new ClientOptions(handler: $stack);
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, and a retried start or report charges again or consumes an attempt. Use the SDK's ownretryoption instead. Middleware that only observes (logging, metrics, tracing) is fine.
Logging
The SDK logs nothing, and neither does Guzzle on its own. If you add logging
middleware (for example GuzzleHttp\Middleware::log()), it sees the request after
signing:
- Logging request headers exposes credentials. Redact
Authorization(andx-timestamp) in any logging middleware;BasicAuthsends the secret itself. - The by-number URLs contain the phone number: redact the path or skip those requests.
A channel this release does not model
deliveryMethod is an open vocabulary on read. If a verification you can read reports
a channel this release does not model, deliveryMethodEnum() returns null and the
raw string is kept, for example future_channel. Report it with the raw variants,
which send the method as given without a client-side check:
if (null === $verification->deliveryMethodEnum()) { $verification = $client->reportVerificationRaw($verification->id, $verification->deliveryMethod, $code); } $client->reportVerificationByNumberRaw('+15555550100', 'future_channel', $code);
Starting a verification still takes a DeliveryMethod case.
Development
composer install
composer ci # check-style, analyse (PHPStan), test (PHPUnit)
See CONTRIBUTING.md.
Versions follow semantic versioning. Classes and members marked @internal, and the
Didww\Verification\Internal namespace, are not covered by that promise.
License
MIT. See LICENSE.