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.
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:
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 double-charges 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'] },
});
await client.startVerification({
destination: '+5511987654321',
deliveryMethod: 'callout',
callout: { languages: ['pt-BR', 'pt-PT'] },
});
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.
Note
app_hash is deliberately not part of SmsOptions. It identifies the running Android
build rather than a value a server chooses, so it is supplied by
@didww/verification-react-native on the device.
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; // 120
verification.sms?.appHash; // 'A1b2C3d4E5f', or null
verification.callout?.language; // 'de-DE'
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 is not the verification expiration time; manual code entry remains
available until expiresAt.
appHash is returned only when an app hash was stored for the verification.
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.
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. |
|
|
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.
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'}.
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'],
});
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 8 KiB are rejected before anything is hashed.
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:02:00.000Z',
sms: { template: 'Your code is {{CODE}}', language: 'en-US', interception_timeout: 120 },
},
});
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
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.