Node.js SDK#
The Node.js SDK is a server-side client for the Verification API. It wraps the verification endpoints, supports every
authentication mode including signed application
requests, and verifies the signature on inbound callbacks.
Source: GitHub repository
It ships as two packages:
Package |
Contents |
|---|---|
|
The client, the wire types, and the error tree. Runtime-agnostic: it needs only
|
|
Signed |
Install @didww/verification-node on a server. It brings the client with it.
Requirements#
Runtime |
Node.js 22 or later. |
Module format |
ESM and CommonJS. TypeScript types are included. |
Dependencies |
No third-party dependencies. |
Installation#
npm install @didww/verification-node
For a browser, an edge runtime, or any process that does not need signing or the callback endpoint, install the client alone. See Calling from the browser for the CORS and authentication requirements of a direct browser call:
npm install @didww/verification-core
Quick start#
Create a client, start an SMS verification, report the code entered by the user, and read the outcome. This example uses the sandbox environment and HTTP Basic authentication:
import { VerificationClient, basicAuth } from '@didww/verification-core';
const client = new VerificationClient({
auth: basicAuth(process.env.DIDWW_OTP_KEY!, process.env.DIDWW_OTP_SECRET!),
environment: 'sandbox',
});
const verification = await client.startVerification({
destination: '+4915112345678',
deliveryMethod: 'sms',
sms: { languages: ['en-US'] },
});
verification.id; // '0f9c8b7a-1e2d-4c3b-9a8f-7e6d5c4b3a21'
verification.status; // 'pending'
verification.sms?.language; // 'en-US'
const result = await client.reportVerification(verification.id, {
deliveryMethod: 'sms',
code: '123456',
});
if (result.status === 'verified') {
grantAccess();
}
Every method returns a new decoded object describing the state at that moment. Objects are snapshots and are never updated in place.
Authentication#
Mode |
Constructor |
Use it |
|---|---|---|
|
|
On a device, or wherever no secret may be stored. The start is authorized by a callback to your server. |
|
|
Server-side only. The secret is sent on every request. |
|
|
Server-side only, from |
import { VerificationClient } from '@didww/verification-core';
import { applicationAuth } from '@didww/verification-node';
const client = new VerificationClient({
auth: applicationAuth({
key: process.env.DIDWW_APPLICATION_KEY!,
secret: process.env.DIDWW_APPLICATION_SECRET!,
}),
});
Each signed request carries an Authorization: Application <key>:<signature> header and an
x-timestamp header, both derived from a single reading of the clock.
The application secret is URL-safe base64, and the HMAC key is the bytes it decodes to,
never its characters. applicationAuth validates the secret at construction and throws
ConfigurationError for a value that is not canonical URL-safe base64, rather than failing
later with a valid-looking signature the API rejects.
All three constructors throw ConfigurationError at construction for a blank credential or
for a key containing ":". The API splits on the first colon, so a key containing one
silently becomes a different credential.
Environments#
new VerificationClient({ auth }); // production
new VerificationClient({ auth, environment: 'sandbox' });
new VerificationClient({ auth, baseUrl: 'https://proxy.example.com' }); // wins over environment
baseUrl is an absolute URL that overrides environment; a path on it is used as a
prefix. It is validated at construction. The SDK appends the /api/v1 base path itself.
Other client options: transport, timeoutMs (default 30000), retry,
userAgent, logger, and keepRawPayload.
Methods#
Method |
Endpoint |
|---|---|
|
|
|
|
|
|
|
|
|
|
The *ByNumber variants address the newest verification for a number, whatever its status.
The number is reduced to ASCII digits before it is placed in the path.
getVerification and getVerificationByNumber reject with a 404 ApiError once
the verification they name passes the
retention period, so persist the outcome rather than the
id if your service needs it later.
reportVerificationRaw(id, options) and reportVerificationRawByNumber(number, options)
are escape hatches for a delivery method this release does not model. No client-side channel
guard runs on them.
Note
Only ``GET`` requests are retried. The default policy is two attempts with jittered
backoff on a transport failure or a 5xx. A start or a report that timed out may still
have been carried out, so retrying one sends another code or burns an attempt. When a start
times out, call getVerificationByNumber instead of sending it again.
Delivery-method options#
Method-specific options travel in a property named after the delivery method, and the SDK
sends only the one matching deliveryMethod:
await client.startVerification({
destination: '+4915112345678',
deliveryMethod: 'sms',
sms: { languages: ['de-DE'], autofill: { type: 'domain' } },
custom: 'order-42',
});
await client.startVerification({
destination: '+5511987654321',
deliveryMethod: 'callout',
callout: { languages: ['pt-BR', 'pt-PT'], templateGroup: 'checkout' },
});
SmsOptions.languages and CalloutOptions.languages accept the same tags with the same
semantics, so one language list works for both delivery methods. The catalogues behind them
differ, however: the announcement recordings are a different set from the message templates,
so a tag that is honored for SMS can still fall back for a phone call. See
SMS languages and
phone call languages.
Option |
Description |
|---|---|
|
Autofill marker added to the message ( |
|
Name of one of your template groups, used instead of the application’s default templates. |
|
Top-level free text of up to 4096 characters. It is forwarded unchanged to your callback
server as |
The API rejects a malformed autofill with autofill_invalid, { type: 'domain' } on an
application without an SMS domain with sms_domain_not_configured, an unknown template group
with template_group_not_found, and a longer custom with custom_too_long.
Note
SmsOptions has no flat app_hash field. On the device,
@didww/verification-react-native supplies the app
hash itself and sends it as an app_hash autofill. Setting sms.autofill in the hook
replaces the computed hash. A malformed app_hash value throws ConfigurationError
before the request is sent.
Reporting a value#
Both delivery methods carry the code the user received, and the TypeScript types enforce the pairing:
Delivery method |
Property |
Example |
|---|---|---|
|
|
|
|
|
|
The wrong pairing does not compile. Supplying it from plain JavaScript throws
ChannelMismatchError before any request is sent.
Verification responses#
Every successful request resolves to a decoded Verification:
Property |
Type |
Description |
|---|---|---|
|
|
Verification identifier. |
|
|
Destination number normalized without a leading |
|
|
Delivery method used for the verification. |
|
|
Quoted verification fee, as a decimal string. Never parse it as a number. |
|
|
Current verification status. |
|
|
Machine-readable reason for a failed, expired, or denied verification. |
|
|
Human-readable text associated with |
|
|
Verification expiration time. |
|
|
SMS response fields. It is |
|
|
Phone call response fields. It is |
isPending(verification) is the condition to poll on. isFinished(verification) is its
exact complement, so a status added after this release reads as finished rather than looping
forever.
Reading delivery-method details#
verification.sms?.template; // 'Your code is {{CODE}}'
verification.sms?.language; // 'en-US'
verification.sms?.interceptionTimeoutSeconds; // 300
verification.sms?.autofill; // { type: 'app_hash', value: 'A1b2C3d4E5f' }, or null
verification.sms?.codeLength; // 6
verification.sms?.templateGroup; // 'checkout', or null
verification.sms?.templateGroupUsed; // true
verification.sms?.templateFallbackReason; // null, or e.g. 'language_missing'
verification.callout?.language; // 'de-DE'
verification.callout?.codeLength; // 6
verification.callout?.templateGroup; // 'checkout', or null
verification.callout?.templateGroupUsed; // true
verification.callout?.templateFallbackReason; // null, or e.g. 'audio_not_ready'
Both language values report the language the API selected, which is not necessarily
the first one requested. Compare with the list you sent to detect a fallback to en-US.
interceptionTimeoutSeconds is how long an on-device client should keep 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). Read it rather than hard-coding a length in your UI.
autofill is returned only when an app_hash or domain marker was stored for the
verification. For domain, value is the domain. The deprecated appHash field holds
autofill.value when the type is app_hash and null otherwise.
templateGroupUsed is true when the group’s template was used. When it is false,
the default template was used and templateFallbackReason says why: language_missing,
version_pending, version_rejected, audio_not_ready (phone call), or
group_missing (the application’s default group is not available, and templateGroup is
then null). The reason is null when the group’s template was used or no group applies. The
reasons are an open set; TEMPLATE_FALLBACK_REASONS holds the known ones.
Error handling#
Verification outcomes and thrown errors are different. A request can succeed and return a
verification whose status is failed, expired, or denied. Handle those through
status and errorCode.
Some starts are refused as an outcome. Nothing is sent, no callback request is made, and no cooldown starts for the number:
Status
deniedwithapplication_paused: the OTP application is paused.Status
deniedwithdestination_country_disabled: the destination country is blocked for your account.Status
failedwithdestination_not_supported_for_channel: the number has no supported country, cannot be reached by the requested delivery method, or is in a blocked range. Earlier API versions returned this code as a422error.
Thrown errors describe a transport, decoding, HTTP, or client-side validation failure. Every
one extends DidwwError:
Class |
HTTP status |
Meaning |
|---|---|---|
|
— |
The client or a value it was given is unusable. Thrown before any request. |
|
— |
The reported value does not suit the delivery method. Thrown before any request. |
|
— |
No response: a network failure, a timeout, or an abort. |
|
— |
A response arrived and was not the shape this release expects. |
|
|
Authentication failed, or the mode is below the application’s minimum. |
|
|
The account balance is insufficient. |
|
|
No verification matches the identifier or number. |
|
|
Request validation failed. |
|
|
A limit was reached; the error code says which one (see below).
|
|
|
The API failed to process the request. |
import { isApiError, isDidwwError } from '@didww/verification-core';
try {
await client.reportVerification(id, { deliveryMethod: 'sms', code });
} catch (error) {
if (isApiError(error)) {
console.warn(error.errors[0]?.code, error.status);
} else if (isDidwwError(error)) {
console.warn(error.name, error.message);
} else {
throw error;
}
}
Use isApiError and isDidwwError rather than instanceof. Both hold across two
installed copies of the package, which instanceof does not.
RateLimitedError covers every 429 code:
Code |
Meaning |
|---|---|
|
A start for the same number came too soon after the previous one. Wait
|
|
Too many requests from this IP address. |
|
Too many failed authentication attempts from this IP address. |
|
Too many requests for this account. |
|
Too many |
|
The application reached its daily or monthly verification limit.
|
The per-second rate_limit_* limits return Retry-After: 1. See Limits.
See Errors and status codes for the full list of machine-readable error codes.
Verifying inbound callbacks#
When an application starts a verification with public or basic authentication, the API
asks your server whether to allow it and waits for the answer before creating the
verification. Until this endpoint answers correctly, every such verification is denied.
@didww/verification-node ships an Express handler:
import express from 'express';
import { expressCallbackHandler } from '@didww/verification-node';
const secrets = new Map([['your-app-key', process.env.DIDWW_APPLICATION_SECRET!]]);
const app = express();
app.post(
'/callbacks/didww',
express.raw({ type: '*/*' }),
expressCallbackHandler({
path: '/callbacks/didww',
secret: (key) => secrets.get(key) ?? null,
decide: (payload) => ({
action: payload.data.destination.startsWith('1900') ? 'deny' : 'allow',
}),
onRejected: (reason) => console.warn(`callback rejected: ${reason}`),
}),
);
app.listen(3000);
Important
express.raw({type: '*/*'}) is required, and must be mounted on this route only. The
signature covers the bytes as received. A body parsed by express.json() and
re-serialized differs in whitespace and key order and will not verify.
path is the path of the registered callback URL, not the path the request arrived on.
The two differ whenever a proxy rewrites the path:
Registered callback URL |
|
|---|---|
|
|
|
|
|
|
A registered URL with no path signs the empty string. A handler that assumes '/' there
computes a valid signature over the wrong string and denies every verification for that
application. Pass the literal 'incoming' to use the received pathname instead; in that
mode both '/' and '' are tried.
secret is a resolver, because one endpoint may serve several applications. Return null
for a key you do not know. A fixed string is accepted too, and is decoded at wiring time so a
malformed one fails at startup.
decide runs only after the signature verifies and must return {action: 'allow'} or
{action: 'deny'}. payload.data.custom is the custom text given at start, or null
when none was sent. When a mobile app starts the verification, the client controls that value,
so check it against your own records.
While a previous secret is active, the callback also
carries an x-signature-previous header, and a callback verifies when either signature
matches the configured secret. The Express handler reads the header itself. Deploy this before
you rotate the secret.
Warning
The handler answers with a bare status and no body on purpose: a rejected callback never
reveals why it was rejected. Use onRejected to send the reason to your logs instead.
Without Express#
CallbackVerifier is the same logic with no framework attached. Supply the wire values and
write the response yourself:
import { CallbackVerifier } from '@didww/verification-node';
const verifier = new CallbackVerifier({
secret: (key) => lookupSecret(key),
tolerance: 300, // seconds either side of now; this is the default
});
const result = await verifier.verify({
method: 'POST',
path: '/callbacks/didww', // the registered URL's path
contentType: headers['content-type'] ?? '',
body: rawBody, // the exact received bytes
timestamp: headers['x-timestamp'],
authorization: headers['authorization'],
previousSignature: headers['x-signature-previous'],
});
if (result.ok) {
console.log(result.payload.key, result.payload.data.id);
} else {
console.warn(result.reason, result.key); // log it; do not answer with it
}
Bodies over 32 KiB are rejected before anything is hashed. To change the limit, pass
maxBodyBytes to new CallbackVerifier({...}); expressCallbackHandler always uses
the default.
Testing#
@didww/verification-core/testing exports fakeTransport, a scripted transport double
that records every request:
import { VerificationClient, publicAuth } from '@didww/verification-core';
import { fakeTransport } from '@didww/verification-core/testing';
const body = JSON.stringify({
data: {
id: 'ver-1',
destination: '4915112345678',
delivery_method: 'sms',
fee: '0.06',
status: 'pending',
error_code: null,
error_detail: null,
expires_at: '2026-07-15T10:05:00.000Z',
sms: {
template: 'Your code is {{CODE}}',
language: 'en-US',
interception_timeout: 300,
code_length: 6,
},
},
});
const { transport, requests } = fakeTransport([{ status: 201, headers: {}, body }]);
const client = new VerificationClient({ auth: publicAuth('your-app-key'), transport });
await client.startVerification({ destination: '+4915112345678', deliveryMethod: 'sms' });
requests[0]?.body; // the exact bytes sent
To test against the live API, use the sandbox environment. It sends no real SMS or calls and gives a predefined result only for the reserved test numbers. See Testing in sandbox.
Next steps#
Start a verification: Review all start-request and response fields.
Callbacks: Review the request callback contract the handler above implements.
Errors and status codes: Look up machine-readable error codes.