Skip to content
NishchitDOCS
Integrations

Node.js

Send SMS from Node with Nishchit — a typed client over fetch, idempotent retries, an Express webhook route with constant-time signature verification, and types generated from the published OpenAPI document.

Nishchit is a small HTTP API and Node has fetch built in, so there is nothing to install. This page is the client you would otherwise have written, plus the three things that are easy to get wrong: idempotency, raw-body signature verification, and treating accepted as delivered.

Requirements

Node 18 or newer, for global fetch and crypto.timingSafeEqual. The examples are TypeScript; strip the types and they are valid JavaScript.

A client

const BASE_URL = process.env.NISHCHIT_BASE_URL ?? 'https://api.nishchit.tech';
const API_KEY = process.env.NISHCHIT_KEY!;

export class NishchitError extends Error {
  constructor(
    readonly code: string,
    readonly status: number,
    readonly docUrl: string | undefined,
    readonly requestId: string | undefined,
    message: string,
  ) {
    super(message);
    this.name = 'NishchitError';
  }
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
    signal: AbortSignal.timeout(15_000),
  });

  const payload = await res.json();

  if (!res.ok) {
    const error = payload.error ?? {};
    throw new NishchitError(
      error.code ?? 'unknown',
      res.status,
      error.doc_url,
      error.request_id,
      error.message ?? 'Request failed',
    );
  }

  return payload as T;
}

export interface Message {
  id: string;
  status: 'accepted' | 'sending' | 'sent' | 'failed';
  delivery_status: 'unknown' | 'delivered' | 'undelivered' | 'rejected';
  segments: number;
  encoding: 'gsm7' | 'ucs2';
  credits: number;
}

export function sendMessage(input: {
  to: string;
  body: string;
  from?: string;
  idempotencyKey: string;
}): Promise<Message> {
  const { idempotencyKey, ...rest } = input;

  return request<Message>('/v1/messages', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify(rest),
  });
}

Using it:

const message = await sendMessage({
  to: '+8801712345678',
  body: 'Your Nishchit OTP is 482913',
  idempotencyKey: 'signup-otp-user-9281',
});

console.log(message.id, message.status); // msg_01JBX… accepted

accepted is not delivered

accepted means Nishchit has taken the message and charged it, nothing more. On the primary Bangladesh route delivery_status stays unknown permanently, because that route returns no delivery receipts. Read delivery status before you render a tick.

Retry safely

Any retry of a POST — yours, a queue's, a load balancer's — can send a second SMS. The idempotency key is what makes it safe, so make the key deterministic and derive it from the thing you are notifying about rather than generating a random one per attempt.

export async function sendWithRetry(input: Parameters<typeof sendMessage>[0], attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await sendMessage(input);
    } catch (error) {
      if (!(error instanceof NishchitError)) throw error;

      // Only these are worth another attempt. Everything else is a decision, not a blip.
      const retryable = ['gateway_unavailable', 'internal_error', 'rate_limit_exceeded'];
      if (!retryable.includes(error.code) || attempt === attempts) throw error;

      await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
    }
  }

  throw new Error('unreachable');
}

A replay of the same key returns the stored first response with Idempotency-Replayed: true rather than sending again. The same key with a different body is a 409 — deliberately, because silently returning the old message would hide your bug. See errors.

Count segments before a campaign

One Bangla character moves the whole body to UCS-2 at 70 characters per segment instead of 160, so cost can triple without the copy looking longer. Ask before you send:

const preview = await request<{
  encoding: 'gsm7' | 'ucs2';
  segments: number;
  total_credits: number;
}>('/v1/pricing/preview', {
  method: 'POST',
  body: JSON.stringify({
    body: 'হ্যালো করিম, আপনার অর্ডার ৮৮৪১ পাঠানো হয়েছে।',
    recipients: 5000,
  }),
});

console.log(preview.encoding, preview.segments, preview.total_credits);

Bangla SMS has the full rules, and the segment calculator runs the same engine in a browser.

Verify webhooks

Nishchit signs each delivery with HMAC-SHA256 over {timestamp}.{raw body}, sent as Nishchit-Signature: t=…,v1=….

import crypto from 'node:crypto';

export function verifySignature(rawBody: string, header: string, secret: string): boolean {
  const parts = new Map(
    header.split(',').map((pair) => {
      const [key, value] = pair.split('=', 2);
      return [key, value] as const;
    }),
  );

  const timestamp = Number(parts.get('t'));
  const signature = parts.get('v1');
  if (!timestamp || !signature) return false;

  // Without this a captured request stays valid forever.
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // timingSafeEqual throws on a length mismatch, so check that first — a malformed
  // signature should be rejected, not crash the handler.
  if (expected.length !== signature.length) return false;

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

In Express, the body must reach you unparsed:

import express from 'express';

const app = express();

app.post(
  '/webhooks/nishchit',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body.toString('utf8');
    const header = req.get('Nishchit-Signature') ?? '';

    if (!verifySignature(raw, header, process.env.NISHCHIT_WEBHOOK_SECRET!)) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(raw);
    void queue.add('nishchit-event', event);

    res.status(204).end();
  },
);

express.json() will break the signature

If express.json() runs before this route, the raw bytes are gone and re-serialising the parsed object changes whitespace and key order. Mount express.raw() on the webhook path, or register the webhook route before the global JSON parser.

In a Next.js route handler use await request.text() and verify that string before parsing it.

Return fast and process on a queue — a non-2xx or a timeout means Nishchit redelivers, and your handler will see the same event again. Webhooks has the retry schedule.

Types from the OpenAPI document

The spec is published at /openapi.json and generated from the server's own route schemas, so it cannot drift from the running API. Generate types from it rather than hand-maintaining them:

npx openapi-typescript https://nishchit.tech/openapi.json -o src/nishchit.d.ts

What is generated and what is available today is covered on SDKs and tooling.

Test without sending

A key beginning nk_test_ never reaches a carrier and never spends a credit:

NISHCHIT_KEY=nk_test_your_key_here

Test mode also provides magic recipient numbers that deterministically produce failures and gateway outages, so your error branches can be exercised properly — see test mode. When you stub the API instead, keep delivery_status as unknown in the fixture; a stub that returns delivered lets you ship a screen that cannot work against the real route.

Next

Ready to send one?

Signup is self-serve and a test key never touches a carrier or a credit.

NISHCHIT / SMS INFRASTRUCTURE · BANGLADESH/LLMS.TXTPRICINGSTATUSTERMSPRIVACY