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. 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

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 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

sms.autofill

Autofill marker added to the message (SmsAutofillOption). { type: 'app_hash', value } adds the Android SMS Retriever hash: exactly 11 characters from A-Z, a-z, 0-9, +, and /. { type: 'domain' } adds the domain-bound line for the application’s SMS domain. { type: 'none' } adds no marker. Leave it unset for the application default.

sms.templateGroup, callout.templateGroup

Name of one of your template groups, used instead of the application’s default templates.

custom

Top-level free text of up to 4096 characters. It is forwarded unchanged to your callback server as data.custom and is not returned on the verification.

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

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; // 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 denied with application_paused: the OTP application is paused.

  • Status denied with destination_country_disabled: the destination country is blocked for your account.

  • Status failed with destination_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 a 422 error.

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.

RateLimitedError

429

A limit was reached; the error code says which one (see below). retryAfterSeconds holds the Retry-After header, or null. A start is never retried automatically.

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.

RateLimitedError covers every 429 code:

Code

Meaning

destination_in_cooldown

A start for the same number came too soon after the previous one. Wait retryAfterSeconds before starting again for that number.

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. retryAfterSeconds runs to the next UTC day or month, so show it to the user instead of retrying sooner.

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

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'}. 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#