Code examples: Node.js
Requirements: Node.js 18 or later; no packages.
A minimal client
Reads the base URL and the key from the environment and retries network errors, 5xx answers and retryable errors with the same Idempotency-Key. The other examples use it.
File: client.mjs
// UPPU API v1: a minimal client (Node.js 18+, no packages).
//
// Configuration comes from the environment, never from the source code:
// UPPU_API_BASE https://messaging-staging.uppu.net/v1 (production: https://messaging.uppu.net/v1)
// UPPU_API_KEY uppu_test_... on staging, uppu_live_... in production. Never log it.
const BASE = (process.env.UPPU_API_BASE || 'https://messaging-staging.uppu.net/v1').replace(/\/+$/, '');
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
/**
* Calls the API and returns { status, data, headers }. Network errors, 5xx
* answers and errors marked retryable are retried with the same
* Idempotency-Key, so a retry never sends a message twice.
*/
export async function uppuRequest(method, path, { body, idempotencyKey, attempts = 3 } = {}) {
const headers = {
Authorization: `Bearer ${process.env.UPPU_API_KEY}`,
Accept: 'application/json',
};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey;
}
for (let attempt = 1; ; attempt++) {
let response;
try {
response = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
} catch (error) {
if (attempt >= attempts) {
throw error;
}
await sleep(attempt);
continue;
}
const text = await response.text();
const data = text ? JSON.parse(text) : null;
const retryable = response.status >= 500 || data?.error?.retryable === true;
if (!retryable || attempt >= attempts) {
return { status: response.status, data, headers: response.headers };
}
// Retry-After when UPPU sends it, otherwise 1, 2, ... seconds; at most 10
await sleep(Math.min(Number(response.headers.get('retry-after')) || attempt, 10));
}
}
/** The error code of an answer, for logs and decisions (never the message). */
export function uppuErrorCode({ status, data }) {
return data?.error?.code ?? `http_${status}`;
}Send a text message
POST /v1/messages with the messages:send scope. One Idempotency-Key per logical send, reused for every retry.
File: send-text.mjs
// Send a text message: POST /v1/messages, scope messages:send.
import { uppuErrorCode, uppuRequest } from './client.mjs';
const channelId = '01j9z0ch5k2m4n6p8q0r2s4t6v'; // from GET /v1/channels
const contactId = '01j8x7ct5k2m4n6p8q0r2s4t6v'; // from message.received: data.contact.id
const result = await uppuRequest('POST', '/messages', {
body: {
channel_id: channelId,
to: { contact_id: contactId },
content: { type: 'text', text: { body: 'Your order has shipped.' } },
client_reference: 'order-1001',
},
idempotencyKey: 'order-1001-shipped', // the same key for every retry of this send
});
if (result.status === 202 || result.status === 200) {
// 202: queued; 200: a replay of the same send (Idempotent-Replayed: true)
console.log(`Message ${result.data.id} is ${result.data.status}`);
} else {
// For example window_closed: the 24-hour window is closed, send a template instead
console.log(`Not sent: ${uppuErrorCode(result)}`);
}Send a template message
An approved WhatsApp template, also outside the 24-hour window. The example uses a template of the staging simulator.
File: send-template.mjs
// Send an approved WhatsApp template, also outside the 24-hour window:
// POST /v1/messages, scope messages:send. GET /v1/templates shows each
// template's parameters and a ready send_example.
import { uppuErrorCode, uppuRequest } from './client.mjs';
const result = await uppuRequest('POST', '/messages', {
body: {
channel_id: '01j9z0ch5k2m4n6p8q0r2s4t6v',
to: { contact_id: '01j8x7ct5k2m4n6p8q0r2s4t6v' },
content: {
type: 'template',
template: {
name: 'uppu_sim_order_update', // a template of the staging simulator
language: 'tr',
body: { parameters: ['Deniz', 'A-1001'] }, // {{1}} and {{2}}
},
},
client_reference: 'order-A-1001',
},
idempotencyKey: 'order-A-1001-shipped',
});
if (result.status === 202 || result.status === 200) {
console.log(`Template message ${result.data.id} is ${result.data.status}`);
} else {
// For example consent_required: a MARKETING template needs a recorded consent
console.log(`Not sent: ${uppuErrorCode(result)}`);
}List messages
GET /v1/messages with the messages:read scope, page by page with the cursor, for example to reconcile after a webhook outage.
File: list-messages.mjs
// List messages page by page: GET /v1/messages, scope messages:read. Use it
// to reconcile after a webhook outage.
import { uppuErrorCode, uppuRequest } from './client.mjs';
const query = new URLSearchParams({
channel_id: '01j9z0ch5k2m4n6p8q0r2s4t6v',
created_after: '2026-10-20T00:00:00Z',
order: 'asc',
limit: '100',
});
for (;;) {
const result = await uppuRequest('GET', `/messages?${query}`);
if (result.status !== 200) {
throw new Error(`Listing failed: ${uppuErrorCode(result)}`);
}
for (const message of result.data.data) {
console.log(message.id, message.direction, message.status);
}
// The cursor carries the order; repeat the filters on every page
if (result.data.next_cursor === null) {
break;
}
query.set('cursor', result.data.next_cursor);
}Mark a message as read
POST /v1/messages/{id}/read with the messages:send scope; WhatsApp only. No Idempotency-Key: a repeat answers 200.
File: mark-read.mjs
// Mark a message of the person as read (WhatsApp only):
// POST /v1/messages/{id}/read, scope messages:send. No Idempotency-Key is
// needed: a repeat answers 200.
import { uppuErrorCode, uppuRequest } from './client.mjs';
const messageId = '01jab3t6ms2m4n6p8q0r2s4t6v'; // from message.received: data.message.id
const result = await uppuRequest('POST', `/messages/${encodeURIComponent(messageId)}/read`, { body: {} });
if (result.status === 202 || result.status === 200) {
console.log(`Read receipt ${result.data.state}`);
} else if (uppuErrorCode(result) === 'not_inbound') {
// Nothing to mark (your own message, a reaction, a notice): final, do not retry
console.log(`Nothing to mark: ${result.data.error.details?.reason ?? 'unknown'}`);
} else {
console.log(`Not marked: ${uppuErrorCode(result)}`);
}Verify a webhook signature
Check the signature over the raw body, drop repeats by webhook-id, answer 2xx at once and process the event afterwards.
File: verify-webhook.mjs
// Receive UPPU webhooks (Node.js 18+, no packages): check the signature over
// the raw body, drop repeats by webhook-id, answer 2xx at once and process
// the event later. The signing secret comes from the environment:
// UPPU_WEBHOOK_SECRET, and during a secret rotation the previous one in
// UPPU_WEBHOOK_SECRET_PREVIOUS.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
/**
* Standard Webhooks v1 signature check: base64(HMAC-SHA256(key, id.timestamp.body)),
* the key being the base64-decoded secret after "whsec_".
*
* @param {string[]} secrets the current secret and, during a rotation, the previous one
* @param {Record<string, string | string[] | undefined>} headers lower-case header names
* @param {Buffer} rawBody the body exactly as received
*/
export function verifyUppuSignature(secrets, headers, rawBody, now = Date.now() / 1000, tolerance = 300) {
const id = String(headers['webhook-id'] ?? '');
const timestamp = String(headers['webhook-timestamp'] ?? '');
const header = String(headers['webhook-signature'] ?? '');
if (!id || !/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > tolerance) {
return false;
}
for (const secret of secrets) {
if (!secret || !secret.startsWith('whsec_')) {
continue;
}
const key = Buffer.from(secret.slice(6), 'base64');
if (key.length === 0) {
continue;
}
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
for (const part of header.split(' ')) {
const comma = part.indexOf(',');
if (comma < 0 || part.slice(0, comma) !== 'v1') {
continue;
}
const given = Buffer.from(part.slice(comma + 1), 'base64');
if (given.length === expected.length && timingSafeEqual(given, expected)) {
return true;
}
}
}
return false;
}
// A minimal receiver: `node verify-webhook.mjs` behind your HTTPS proxy
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const secrets = [process.env.UPPU_WEBHOOK_SECRET, process.env.UPPU_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
const seen = new Set(); // use your database: keep every webhook-id for at least 35 days
createServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
const body = Buffer.concat(chunks);
if (request.method !== 'POST' || !verifyUppuSignature(secrets, request.headers, body)) {
response.writeHead(401).end(); // anything but 2xx: the wrong-signature probe expects it
return;
}
const event = JSON.parse(body.toString('utf8'));
// Answer within 10 seconds; process afterwards
response.writeHead(event.test || seen.has(event.id) ? 200 : 204).end();
if (event.test || seen.has(event.id)) {
return; // a test event of the panel, or a repeat
}
seen.add(event.id);
setImmediate(() => processEvent(event));
});
}).listen(Number(process.env.PORT || 8080));
}
function processEvent(event) {
switch (event.type) {
case 'message.received': // data.message, data.contact and data.service_window
break;
case 'message.status': // data.message.id or data.message.client_reference, data.status
break;
case 'contact.erased': // delete your copy of data.contact_id
break;
default: // ignore event types you do not know
break;
}
}