# Node.js SDK

The Node.js SDK is a server-side client for the [Verification
API](../api-reference/index.html#otp-verification-api-reference). It wraps the verification endpoints, supports every
[authentication mode](../authentication.html#otp-verification-authentication) including signed `application`
requests, and verifies the signature on inbound [callbacks](../callbacks.html#otp-verification-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](../authentication.html#otp-verification-browser-cors)
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](../callbacks.html#otp-verification-callbacks) 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](../api-reference/get-verification-status.html#otp-verification-retention), 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'] },
});

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](../verification-methods/sms.html#otp-verification-sms-languages) and
[phone call languages](../verification-methods/phone-call.html#otp-verification-callout-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](react-native.html#otp-verification-sdk-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; // 300
verification.sms?.appHash;                    // 'A1b2C3d4E5f', or null
verification.sms?.codeLength;                 // 6

verification.callout?.language;               // 'de-DE'
verification.callout?.codeLength;             // 6
```

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.

`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. |
| `RateLimitedError` | `429` | `destination_in_cooldown`: a start for the same number came too soon after the previous one. `retryAfterSeconds` holds the `Retry-After` header, or `null`. Wait that long before starting again for that number; 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.

See [Errors and status codes](../api-reference/errors.html#otp-verification-api-errors) 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: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
```

## Next steps

- [Start a verification](../api-reference/start-verification.html#otp-verification-api-start): Review all start-request and
  response fields.
- [Callbacks](../callbacks.html#otp-verification-callbacks): Review the request callback contract the
  handler above implements.
- [Errors and status codes](../api-reference/errors.html#otp-verification-api-errors): Look up machine-readable error
  codes.

On this page
