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

@didww/verification-core

The client, the wire types, and the error tree. Runtime-agnostic: it needs only fetch. Supports the public and basic authentication modes.

@didww/verification-node

Signed application authentication and the inbound callback endpoint. Installs @didww/verification-core with it.

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. @didww/verification-core declares none at all, and @didww/verification-node depends only on it.

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

public

publicAuth(key)

On a device, or wherever no secret may be stored. The start is authorized by a callback to your server.

basic

basicAuth(key, secret)

Server-side only. The secret is sent on every request.

application

applicationAuth({key, secret})

Server-side only, from @didww/verification-node. The secret never goes on the wire, and a signed start is not put to the callback gate.

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

startVerification(options)

POST /verifications

reportVerification(id, options)

PUT /verifications/{id}

getVerification(id)

GET /verifications/{id}

reportVerificationByNumber(number, options)

PUT /verifications/by_number/{number}

getVerificationByNumber(number)

GET /verifications/by_number/{number}

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

sms

code

{deliveryMethod: 'sms', code: '123456'}

callout

code

{deliveryMethod: 'callout', code: '123456'}

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

id

string

Verification identifier.

destination

string

Destination number normalized without a leading +.

deliveryMethod

string

Delivery method used for the verification.

fee

string | null

Quoted verification fee, as a decimal string. Never parse it as a number.

status

string

Current verification status.

errorCode

string | null

Machine-readable reason for a failed, expired, or denied verification.

errorDetail

string | null

Human-readable text associated with errorCode.

expiresAt

Date | null

Verification expiration time.

sms

SmsInfo | null

SMS response fields. It is null for a phone call.

callout

CalloutInfo | null

Phone call response fields. It is null for SMS.

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

ConfigurationError

The client or a value it was given is unusable. Thrown before any request.

ChannelMismatchError

The reported value does not suit the delivery method. Thrown before any request.

TransportError

No response: a network failure, a timeout, or an abort.

DecodingError

A response arrived and was not the shape this release expects.

UnauthorizedError

401

Authentication failed, or the mode is below the application’s minimum.

BalanceInsufficientError

402

The account balance is insufficient.

NotFoundError

404

No verification matches the identifier or number.

ValidationError

400, 422

Request validation failed.

ServerError

5xx

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

path

https://example.com/cb/didww

'/cb/didww'

https://example.com

'' — not '/'

https://example.com?x=1

'' — the query is excluded

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#