UPPU API belgesi

Kod örnekleri: PHP

Gereksinimler: PHP 8.1 ya da sonrası, curl eklentisiyle; 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.php

<?php

/*
 * UPPU API v1: a minimal client (PHP 8.1+, curl extension, 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.
 */

/**
 * Calls the API and returns [HTTP status, decoded body, response headers].
 * Network errors, 5xx answers and errors marked retryable are retried with
 * the same Idempotency-Key, so a retry never sends a message twice. The
 * status is 0 when the network failed on every attempt.
 *
 * @param  array<string, mixed>|object|null  $body
 * @return array{0: int, 1: array<string, mixed>|null, 2: array<string, string>}
 */
function uppu_request(string $method, string $path, array|object|null $body = null, ?string $idempotencyKey = null, int $attempts = 3): array
{
    $base = rtrim((string) getenv('UPPU_API_BASE') ?: 'https://messaging-staging.uppu.net/v1', '/');
    $headers = ['Authorization: Bearer '.getenv('UPPU_API_KEY'), 'Accept: application/json'];
    $payload = null;

    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        $payload = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    }

    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: '.$idempotencyKey;
    }

    $attempt = 0;

    while (true) {
        $attempt++;
        $responseHeaders = [];
        $curl = curl_init($base.$path);
        curl_setopt_array($curl, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 20,
            CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$responseHeaders): int {
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return strlen($line);
            },
        ]);

        if ($payload !== null) {
            curl_setopt($curl, CURLOPT_POSTFIELDS, $payload);
        }

        $raw = curl_exec($curl);
        $status = $raw === false ? 0 : (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        $decoded = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
        $decoded = is_array($decoded) ? $decoded : null;
        $retryable = $status === 0 || $status >= 500 || ($decoded['error']['retryable'] ?? false) === true;

        if (! $retryable || $attempt >= $attempts) {
            return [$status, $decoded, $responseHeaders];
        }

        // Retry-After when UPPU sends it, otherwise 1, 2, ... seconds; at most 10
        sleep(max(1, min((int) ($responseHeaders['retry-after'] ?? $attempt), 10)));
    }
}

/**
 * The error code of an answer, for logs and decisions (never the message).
 *
 * @param  array<string, mixed>|null  $body
 */
function uppu_error_code(int $status, ?array $body): string
{
    return (string) ($body['error']['code'] ?? ($status === 0 ? 'network_error' : '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.php

<?php

// Send a text message: POST /v1/messages, scope messages:send.
require __DIR__.'/client.php';

$channelId = '01j9z0ch5k2m4n6p8q0r2s4t6v'; // from GET /v1/channels
$contactId = '01j8x7ct5k2m4n6p8q0r2s4t6v'; // from message.received: data.contact.id

[$status, $message] = uppu_request('POST', '/messages', [
    '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 ($status === 202 || $status === 200) {
    // 202: queued; 200: a replay of the same send (Idempotent-Replayed: true)
    echo 'Message ', $message['id'], ' is ', $message['status'], PHP_EOL;
} else {
    // For example window_closed: the 24-hour window is closed, send a template instead
    echo 'Not sent: ', uppu_error_code($status, $message), PHP_EOL;
}

Ş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.php

<?php

// 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.
require __DIR__.'/client.php';

[$status, $message] = uppu_request('POST', '/messages', [
    '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 ($status === 202 || $status === 200) {
    echo 'Template message ', $message['id'], ' is ', $message['status'], PHP_EOL;
} else {
    // For example consent_required: a MARKETING template needs a recorded consent
    echo 'Not sent: ', uppu_error_code($status, $message), PHP_EOL;
}

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.php

<?php

// List messages page by page: GET /v1/messages, scope messages:read. Use it
// to reconcile after a webhook outage.
require __DIR__.'/client.php';

$query = [
    'channel_id' => '01j9z0ch5k2m4n6p8q0r2s4t6v',
    'created_after' => '2026-10-20T00:00:00Z',
    'order' => 'asc',
    'limit' => 100,
];

do {
    [$status, $page] = uppu_request('GET', '/messages?'.http_build_query($query));

    if ($status !== 200) {
        throw new RuntimeException('Listing failed: '.uppu_error_code($status, $page));
    }

    foreach ($page['data'] as $message) {
        echo $message['id'], ' ', $message['direction'], ' ', $message['status'], PHP_EOL;
    }

    // The cursor carries the order; repeat the filters on every page
    $query['cursor'] = $page['next_cursor'];
} while ($query['cursor'] !== null);

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.php

<?php

// 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.
require __DIR__.'/client.php';

$messageId = '01jab3t6ms2m4n6p8q0r2s4t6v'; // from message.received: data.message.id

[$status, $receipt] = uppu_request('POST', '/messages/'.rawurlencode($messageId).'/read', new stdClass);

if ($status === 202 || $status === 200) {
    echo 'Read receipt ', $receipt['state'], PHP_EOL;
} elseif (uppu_error_code($status, $receipt) === 'not_inbound') {
    // Nothing to mark (your own message, a reaction, a notice): final, do not retry
    echo 'Nothing to mark: ', $receipt['error']['details']['reason'] ?? 'unknown', PHP_EOL;
} else {
    echo 'Not marked: ', uppu_error_code($status, $receipt), PHP_EOL;
}

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.php

<?php

/*
 * Receive UPPU webhooks: 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.
 */

/**
 * Standard Webhooks v1 signature check: base64(HMAC-SHA256(key, id.timestamp.body)),
 * the key being the base64-decoded secret after "whsec_".
 *
 * @param  list<string>  $secrets  the current secret and, during a rotation, the previous one
 */
function uppu_verify_signature(array $secrets, string $id, string $timestamp, string $body, string $header, ?int $now = null, int $tolerance = 300): bool
{
    if ($id === '' || ! ctype_digit($timestamp) || abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }

    foreach ($secrets as $secret) {
        if (! str_starts_with($secret, 'whsec_')) {
            continue;
        }

        $key = base64_decode(substr($secret, 6), true);

        if ($key === false || $key === '') {
            continue;
        }

        $expected = base64_encode(hash_hmac('sha256', $id.'.'.$timestamp.'.'.$body, $key, true));

        foreach (explode(' ', $header) as $part) {
            [$version, $signature] = array_pad(explode(',', $part, 2), 2, '');

            if ($version === 'v1' && hash_equals($expected, $signature)) {
                return true;
            }
        }
    }

    return false;
}

// The endpoint itself (PHP-FPM or Apache): the raw body, never a re-encoded one
$body = (string) file_get_contents('php://input');
$header = fn (string $name): string => trim((string) ($_SERVER['HTTP_'.strtoupper(str_replace('-', '_', $name))] ?? ''));
$secrets = array_values(array_filter([(string) getenv('UPPU_WEBHOOK_SECRET'), (string) getenv('UPPU_WEBHOOK_SECRET_PREVIOUS')]));

if (! uppu_verify_signature($secrets, $header('webhook-id'), $header('webhook-timestamp'), $body, $header('webhook-signature'))) {
    http_response_code(401); // anything but 2xx: the wrong-signature probe expects it

    exit;
}

$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);

// Test events of the panel: answer 2xx and do nothing else
if (($event['test'] ?? false) === true) {
    http_response_code(204);

    exit;
}

// Drop repeats: remember every webhook-id for at least 35 days, for example
// in a table with the id as its primary key, and store the body with it
if (! store_event_once($event['id'], $event['type'], $body)) {
    http_response_code(200);

    exit;
}

// Answer within 10 seconds; process the stored event afterwards
http_response_code(204);

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

process_event($event);

/**
 * Your storage: true when the id is new (INSERT ... ON DUPLICATE KEY or the like).
 */
function store_event_once(string $id, string $type, string $body): bool
{
    return true;
}

/**
 * @param  array<string, mixed>  $event
 */
function process_event(array $event): void
{
    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;
    }
}