UPPU API belgesi

Kod örnekleri: Node.js

Gereksinimler: Node.js 18 ya da sonrası; paket gerekmez.

Küçük bir istemci

Taban adresi ve anahtarı ortamdan okur; ağ hatalarını, 5xx yanıtları ve yeniden denenebilir hataları aynı Idempotency-Key ile yeniden dener. Öteki örnekler onu kullanır.

Dosya: 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}`;
}

Metin mesajı gönderin

messages:send kapsamıyla POST /v1/messages. Her mantıksal gönderime bir Idempotency-Key; her yeniden denemede aynısı.

Dosya: 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)}`);
}

Şablon mesajı gönderin

Onaylı bir WhatsApp şablonu; 24 saatlik pencerenin dışında da gider. Örnek, sahne simülatörünün bir şablonunu kullanır.

Dosya: 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)}`);
}

Mesajları listeleyin

messages:read kapsamıyla GET /v1/messages, imleçle sayfa sayfa; örneğin bir webhook kesintisinden sonra uzlaşmak için.

Dosya: 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);
}

Mesajı okundu yapın

messages:send kapsamıyla POST /v1/messages/{id}/read; yalnızca WhatsApp. Idempotency-Key gerekmez: tekrarı 200 döner.

Dosya: 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)}`);
}

Webhook imzasını doğrulayın

İmzayı ham gövde üzerinden denetleyin, tekrarları webhook-id ile ayıklayın, hemen 2xx dönün ve olayı sonra işleyin.

Dosya: 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;
    }
}