How to resolve a LID in Node.js with the Wapito WhatsApp API

Resolve the identities you already know, resolve the ones that arrive in events, and design for the case where no phone number comes back.

A script, a token and an HTTP client is the whole stack. Everything below runs from a file you can execute today, and the same code moves to a worker or a container without changing shape.

In Node.js identity resolution is two fetch calls and a data-model rule: the linked identity is the key, the phone is an optional string, and both live in your store. A Map as a cache is enough for a script, while an Express receiver that sees identities in events should resolve against a table so a restart does not forget them and two workers agree on what is resolved.

Before you start

  • npm install express (fetch is built in from Node 18)
  • process.env.WAPITO_TOKEN
  • A channel with a WhatsApp number linked to it, and its API token. Create one in the dashboard — the authentication guide shows where the token goes.

How it works

  1. Look up the LID for a number you know

    Given a phone number, fetch the linked identity it maps to. Doing this for your own known contacts up front means later group events resolve from cache instead of needing a lookup each time.

    const url = 'https://api.wapito.com/v1/contacts/+15551234567/lid';
    const options = {method: 'GET', headers: {Authorization: 'Bearer wpt_YOUR_TOKEN'}};
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js fetch /contacts/{id}/lid with the E.164 number encoded into the path and store data.lid next to the number; run it once over your known contacts in a for...of loop with await so events later hit your own table rather than the API. Check response.ok, because an unknown number is a 404 that resolves normally and belongs in the table.

    API reference for this step
  2. Resolve a LID that arrived in an event

    Group and channel events increasingly carry a linked identity instead of a number. Resolve it once, store both, and key your own records on the linked identity because that is the value that will keep arriving.

    const url = 'https://api.wapito.com/v1/contacts/lid/187264518273645@lid';
    const options = {method: 'GET', headers: {Authorization: 'Bearer wpt_YOUR_TOKEN'}};
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      console.log(data);
    } catch (error) {
      console.error(error);
    }

    In Node.js resolve an event's identity with a GET on /contacts/lid/{lid}; the parsed object may carry phone: null, and a 404 is a resolved response you detect with response.status. Store the identity with a null phone in either case and treat it as a complete row; the same object carries name, push_name and is_business, worth keeping in the row.

    API reference for this step
  3. Handle the case where it cannot be resolved

    Sometimes there is no mapping to be had, and the event arrives with a null phone number. Design for that: a participant you can address and count but cannot match to a CRM row is still a participant.

    Arrives on your webhook as groups.participants.

    In Node.js the Express handler reads payload.data.participant and uses optional chaining, participant.phone ?? null, so a missing number is stored as null rather than the string 'undefined'. Upsert by identity and leave the phone column alone unless the event carries one, which in SQL is an ON CONFLICT clause setting the phone with COALESCE of new and old.

The whole script

Every step above in one runnable file. Save it as lid-to-phone.mjs, put your token in the environment, and run it.

// LID to Phone with the Wapito WhatsApp API.
//
// Resolve the identities you already know, resolve the ones that arrive in events, and design for the case where no phone number comes back.
//
// Run it with:
//   export WAPITO_TOKEN="wpt_..."
//   node lid-to-phone.mjs

const BASE_URL = 'https://api.wapito.com/v1';
const TOKEN = process.env.WAPITO_TOKEN;

// --- Look up the LID for a number you know ---
{
  const url = BASE_URL + '/contacts/+15551234567/lid';
  const options = {method: 'GET', headers: {Authorization: 'Bearer ' + TOKEN}};

  try {
    const response = await fetch(url, options);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

// --- Resolve a LID that arrived in an event ---
{
  const url = BASE_URL + '/contacts/lid/187264518273645@lid';
  const options = {method: 'GET', headers: {Authorization: 'Bearer ' + TOKEN}};

  try {
    const response = await fetch(url, options);
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

Receive the webhook

Express 5 receiver for groups.participants, messages — it verifies the signature, answers immediately and does the work after. Install it with npm install express and save it as webhook.mjs.

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.WAPITO_WEBHOOK_SECRET;
const EVENTS = new Set(['groups.participants', 'messages']);

/** Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>. */
function verify(raw, header) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() - Number(parts.t)) > 300_000) return false; // 5 minute clock skew
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(raw).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/wapito', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-Wapito-Signature'))) return res.sendStatus(401);
  const payload = JSON.parse(req.body.toString('utf8'));
  if (EVENTS.has(payload.event)) console.log(payload.event, payload.data);
  res.sendStatus(200); // answer 2xx fast; do the real work in a queue
});

app.listen(Number(process.env.PORT ?? 3000));
Every event, its payload and the retry rules

Gotchas

  • fetch never rejects on a 4xx or 5xx. Check response.ok yourself; an await fetch(...) that "worked" can still be a 402 plan_required with a JSON error body.
  • Do not let express.json() run before your webhook route. Signature verification needs the exact raw bytes, so mount express.raw({ type: "application/json" }) on the webhook path and parse the JSON yourself afterwards.
  • Node 18+ has global fetch, but node-fetch v2, undici and axios all disagree about how they surface non-2xx responses. Pick one client per project or your error handling will be inconsistent.
  • Promise.all over a list of recipients defeats the send queue: the API will serialise them anyway and return 429 send_rate_limited for the overflow. Send sequentially with await, or use a queue with concurrency 1 per channel.
  • Large numbers in JSON.parse lose precision. Treat every WhatsApp id, group id and timestamp that arrives as a string as a string - never Number() it.

Where to run it

  • Google Cloud Run (container, min instances 0, one process per channel worker)
  • Railway or Render web service for the Express receiver
  • Vercel or Netlify Functions for the webhook receiver only - they cannot hold the sender queue open
  • Fly.io machine with a persistent volume if you queue sends locally

Pitfalls in Node.js

  • Template literals turn undefined into the string 'undefined', so `${participant.phone}` in an insert writes that word into your phone column; coalesce to null before building the query, and prefer a parameterised query so the driver sends a real NULL. The same driver will round the identity if the column is numeric, so declare it text and never pass it through Number().
  • A Map cache keyed on phone numbers cannot answer the question the events ask, which is keyed on identity; key the cache on the identity and index the phone as a secondary field, and give the Map a size cap so a long-running receiver does not grow without bound.
  • Retrying a 404 from the resolve call with exponential backoff never succeeds for a person the number has not interacted with; a 404 here is an answer, not a transient failure, so record it and move on rather than scheduling another attempt.

Frequently asked questions

Why did WhatsApp introduce linked identities?

To stop group and channel participation from exposing everybody's phone number to everybody else. It is a privacy improvement for users, and a real migration cost for anyone whose automation assumed the sender of a group message is always a number they can look up.

Is a LID stable over time?

It is stable for a given account, which is what makes it useful as a key. Treat it the way you would treat any external identifier: store it, index on it, and do not try to parse meaning out of it. If the account itself goes away, so does the identity.

Can I message someone using only their LID?

In contexts where the identity is the participant - inside a group, for instance - yes. Starting a fresh one-to-one conversation from an identity alone is not something to rely on, so resolve to a phone number when you need to open a new thread, and expect that to sometimes be impossible.

Related

Try it on your own number

Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.