UPPU API belgesi

Kod örnekleri: Python

Gereksinimler: Python 3.9 ya da sonrası; yalnızca standart kitaplık.

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: uppu_client.py

"""UPPU API v1: a minimal client (Python 3.9+, standard library only).

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

import json
import os
import time
import urllib.error
import urllib.request

BASE = os.environ.get("UPPU_API_BASE", "https://messaging-staging.uppu.net/v1").rstrip("/")


def uppu_request(method, path, body=None, idempotency_key=None, attempts=3):
    """Call the API; return (status, decoded body or None, headers).

    Network errors, 5xx answers and errors marked retryable are retried with
    the same Idempotency-Key, so a retry never sends a message twice.
    """
    headers = {"Authorization": "Bearer " + os.environ["UPPU_API_KEY"], "Accept": "application/json"}
    data = None

    if body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(body, ensure_ascii=False).encode("utf-8")

    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    attempt = 0

    while True:
        attempt += 1
        request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)

        try:
            with urllib.request.urlopen(request, timeout=20) as response:
                status, raw, response_headers = response.status, response.read(), response.headers
        except urllib.error.HTTPError as error:
            status, raw, response_headers = error.code, error.read(), error.headers
        except OSError:
            # Every network error: URLError, a timeout while waiting for the
            # answer (socket.timeout on Python 3.9), a reset connection
            if attempt >= attempts:
                raise
            time.sleep(attempt)
            continue

        decoded = json.loads(raw) if raw else None
        retryable = status >= 500 or bool(((decoded or {}).get("error") or {}).get("retryable"))

        if not retryable or attempt >= attempts:
            return status, decoded, response_headers

        # Retry-After when UPPU sends it, otherwise 1, 2, ... seconds; at most 10
        retry_after = response_headers.get("Retry-After") or ""
        time.sleep(min(int(retry_after) if retry_after.isdigit() else attempt, 10))


def uppu_error_code(status, body):
    """The error code of an answer, for logs and decisions (never the message)."""
    return ((body or {}).get("error") or {}).get("code") or "http_%d" % 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.py

"""Send a text message: POST /v1/messages, scope messages:send."""

from uppu_client import uppu_error_code, uppu_request

channel_id = "01j9z0ch5k2m4n6p8q0r2s4t6v"  # from GET /v1/channels
contact_id = "01j8x7ct5k2m4n6p8q0r2s4t6v"  # from message.received: data.contact.id

status, message, _ = uppu_request(
    "POST",
    "/messages",
    {
        "channel_id": channel_id,
        "to": {"contact_id": contact_id},
        "content": {"type": "text", "text": {"body": "Your order has shipped."}},
        "client_reference": "order-1001",
    },
    idempotency_key="order-1001-shipped",  # the same key for every retry of this send
)

if status in (200, 202):
    # 202: queued; 200: a replay of the same send (Idempotent-Replayed: true)
    print("Message", message["id"], "is", message["status"])
else:
    # For example window_closed: the 24-hour window is closed, send a template instead
    print("Not sent:", uppu_error_code(status, message))

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

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

from uppu_client import uppu_error_code, uppu_request

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",
    },
    idempotency_key="order-A-1001-shipped",
)

if status in (200, 202):
    print("Template message", message["id"], "is", message["status"])
else:
    # For example consent_required: a MARKETING template needs a recorded consent
    print("Not sent:", uppu_error_code(status, message))

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

"""List messages page by page: GET /v1/messages, scope messages:read.

Use it to reconcile after a webhook outage.
"""

import urllib.parse

from uppu_client import uppu_error_code, uppu_request

query = {
    "channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
    "created_after": "2026-10-20T00:00:00Z",
    "order": "asc",
    "limit": 100,
}

while True:
    status, page, _ = uppu_request("GET", "/messages?" + urllib.parse.urlencode(query))

    if status != 200:
        raise RuntimeError("Listing failed: " + uppu_error_code(status, page))

    for message in page["data"]:
        print(message["id"], message["direction"], message["status"])

    # The cursor carries the order; repeat the filters on every page
    if page["next_cursor"] is None:
        break

    query["cursor"] = page["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.py

"""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 urllib.parse

from uppu_client import uppu_error_code, uppu_request

message_id = "01jab3t6ms2m4n6p8q0r2s4t6v"  # from message.received: data.message.id

status, receipt, _ = uppu_request("POST", "/messages/" + urllib.parse.quote(message_id) + "/read", {})

if status in (200, 202):
    print("Read receipt", receipt["state"])
elif uppu_error_code(status, receipt) == "not_inbound":
    # Nothing to mark (your own message, a reaction, a notice): final, do not retry
    print("Nothing to mark:", receipt["error"]["details"].get("reason", "unknown"))
else:
    print("Not marked:", uppu_error_code(status, receipt))

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

"""Receive UPPU webhooks (Python 3.9+, standard library only).

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. Run the receiver under any WSGI server,
for example: gunicorn verify_webhook:application
"""

import base64
import binascii
import hashlib
import hmac
import json
import os
import time


def verify_uppu_signature(secrets, headers, raw_body, now=None, tolerance=300):
    """Standard Webhooks v1 check: base64(HMAC-SHA256(key, id.timestamp.body)).

    secrets: the current secret and, during a rotation, the previous one ("whsec_...").
    headers: lower-case header names; raw_body: the body exactly as received (bytes).
    """
    webhook_id = headers.get("webhook-id") or ""
    timestamp = headers.get("webhook-timestamp") or ""
    header = headers.get("webhook-signature") or ""
    current = time.time() if now is None else now

    if not webhook_id or not (timestamp.isascii() and timestamp.isdigit()) or abs(current - int(timestamp)) > tolerance:
        return False

    signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body

    for secret in secrets:
        if not secret or not secret.startswith("whsec_"):
            continue

        try:
            key = base64.b64decode(secret[6:], validate=True)
        except (binascii.Error, ValueError):
            continue

        if not key:
            continue

        expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

        for part in header.split(" "):
            version, _, signature = part.partition(",")

            if version == "v1" and hmac.compare_digest(expected, signature.encode()):
                return True

    return False


def application(environ, start_response):
    """A minimal WSGI receiver."""
    length = int(environ.get("CONTENT_LENGTH") or 0)
    body = environ["wsgi.input"].read(length)
    headers = {
        "webhook-id": environ.get("HTTP_WEBHOOK_ID", ""),
        "webhook-timestamp": environ.get("HTTP_WEBHOOK_TIMESTAMP", ""),
        "webhook-signature": environ.get("HTTP_WEBHOOK_SIGNATURE", ""),
    }
    secrets = [os.environ.get("UPPU_WEBHOOK_SECRET", ""), os.environ.get("UPPU_WEBHOOK_SECRET_PREVIOUS", "")]

    if environ.get("REQUEST_METHOD") != "POST" or not verify_uppu_signature(secrets, headers, body):
        # Anything but 2xx: the wrong-signature probe expects it
        start_response("401 Unauthorized", [("Content-Type", "text/plain")])
        return [b"invalid signature"]

    event = json.loads(body)

    # Test events of the panel and repeats: answer 2xx and do nothing else
    if not event.get("test") and store_event_once(event["id"], event["type"], body):
        enqueue(event)  # process outside the request: answer within 10 seconds

    start_response("204 No Content", [])
    return []


def store_event_once(event_id, event_type, body):
    """Your storage: True when the id is new. Keep ids for at least 35 days."""
    return True


def enqueue(event):
    """Hand the event to your queue or background worker."""
    if event["type"] == "message.received":
        pass  # data.message, data.contact and data.service_window
    elif event["type"] == "message.status":
        pass  # data.message.id or data.message.client_reference, data.status
    elif event["type"] == "contact.erased":
        pass  # delete your copy of data.contact_id
    # ignore event types you do not know