Quickstart
From a key to a delivered message in four steps, in curl, PHP, Node.js and Python.
Developers
REST API v1 with a public OpenAPI document, a required Idempotency-Key on sends and events signed with Standard Webhooks. Examples in curl, PHP, Node.js and Python.
Request
curl -sS -X POST "https://messaging.uppu.net/v1/messages" \
-H "Authorization: Bearer $UPPU_API_KEY" \
-H "Idempotency-Key: order-1001-shipped" \
-H "Content-Type: application/json" \
-d '{"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"to": {"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v"},
"content": {"type": "text",
"text": {"body": "Your order is on its way."}},
"client_reference": "order-1001"}'Response
HTTP/1.1 202 Accepted
Content-Type: application/json
X-Request-Id: 01jab3a1k9f2m4n6p8q0r2s4t6
{"object": "message",
"id": "01jab3a1k9f2m4n6p8q0r2s4t6",
"channel_type": "whatsapp",
"direction": "out",
"status": "queued",
"client_reference": "order-1001",
"provider_message_id": null}Webhook
POST /uppu/webhook HTTP/1.1
Host: api.your-company.example
webhook-id: 01jace0e1v2m4n6p8q0r2s4t6b
webhook-timestamp: 1792488003
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Uppu-Event-Type: message.status
{"type": "message.status",
"data": {"status": "delivered", "previous_status": "sent",
"message": {"id": "01jab3a1k9f2m4n6p8q0r2s4t6",
"client_reference": "order-1001"}}}message.status to your webhookFrom a key to a delivered message in four steps, in curl, PHP, Node.js and Python.
Signed events for messages, statuses and contacts, with a test vector for your verifier.
Every endpoint, parameter and schema, generated from the OpenAPI document.
One error envelope, the most common codes and what to do about each.
UPPU runs two separate environments, each with its own panel, keys and data. Staging is for testing integrations; nothing in it reaches your live workspace.
| Environment | Panel | API base URL | Key prefix |
|---|---|---|---|
| Live | https://app.uppu.net | https://messaging.uppu.net/v1 | uppu_live_ |
| Staging | https://staging.uppu.net | https://messaging-staging.uppu.net/v1 | uppu_test_ |
A key used in the other environment gets 401 wrong_environment. The API answers only on these two hosts, and the panel is never served there.
The API is off for every workspace until the UPPU team turns it on. Tell us what you want to build and we enable it for your workspace.
The workspace owner creates keys under team settings, «Geliştiriciler» (Developers). Creating a key asks for the owner's password, and the key is shown only once.
Send the key as Authorization: Bearer … on every request. A request that carries an Origin header gets 403 browser_request_refused and no CORS headers are sent, so keep keys out of browsers and mobile apps.
uppu_live_, 12 characters, _, 43 characters (uppu_test_ on staging). The prefix lets secret scanners spot a leaked key.| Scope | Allows |
|---|---|
| any key | GET /v1/channels and GET /v1/channels/{id}, limited to the key's channels |
messages:send | POST /v1/messages and POST /v1/messages/{id}/read |
messages:read | GET /v1/messages and GET /v1/messages/{id} |
templates:read | GET /v1/templates and GET /v1/templates/{id} |
contacts:read | POST /v1/contacts/lookup |
media:write | POST /v1/media |
media:read | GET /v1/media/{id} and GET /v1/media/{id}/content |
events:read | GET /v1/events |
consents:write | An inline consent on a template send |
conversations:write | Can be granted; no endpoint uses it today |
| Preset | Scopes |
|---|---|
bot | messages:send, messages:read, conversations:write, media:read, media:write, templates:read, contacts:read |
«Bildirim gönderici» (notifier) | messages:send, messages:read, media:write, templates:read, contacts:read, consents:write, events:read |
«Salt okuma» (read_only) | messages:read, media:read, templates:read, contacts:read, events:read |
A preset is copied into the key when you create it. If a bot key also reads the event backlog, add events:read.
From a key to a delivered message. The examples use the staging base URL and a uppu_test_ key; for live, change the two variables. The IDs are made up: take yours from step 1.
cURL
# Staging; for live use https://messaging.uppu.net/v1 and a uppu_live_ key.
# Never commit or log the key.
export UPPU_API_BASE="https://messaging-staging.uppu.net/v1"
export UPPU_API_KEY="uppu_test_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"PHP
<?php
// PHP 8+, cURL extension. Returns ['status' => int, 'body' => array|null].
function uppu_request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
$base = rtrim(getenv('UPPU_API_BASE') ?: 'https://messaging-staging.uppu.net/v1', '/');
$headers = ['Authorization: Bearer ' . getenv('UPPU_API_KEY'), 'Accept: application/json'];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
}
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init($base . '/' . ltrim($path, '/'));
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 0: network error, retry with the same key
return ['status' => $status, 'body' => is_string($raw) && $raw !== '' ? json_decode($raw, true) : null];
}Node.js
// Node.js 18+, built-in fetch. Returns { status, data, retryAfter }.
const BASE = process.env.UPPU_API_BASE ?? 'https://messaging-staging.uppu.net/v1';
export async function uppuRequest(method, path, body, idempotencyKey) {
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;
const res = await fetch(`${BASE}/${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20000),
});
const data = await res.json().catch(() => null);
return { status: res.status, data, retryAfter: res.headers.get('retry-after') };
}Python
# Python 3.9+, standard library only. Returns (status, parsed JSON or None).
import json
import os
import urllib.error
import urllib.request
BASE = os.environ.get("UPPU_API_BASE", "https://messaging-staging.uppu.net/v1")
def uppu_request(method, path, body=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {os.environ['UPPU_API_KEY']}", "Accept": "application/json"}
data = None
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(f"{BASE}/{path}", data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=20) as response:
return response.status, json.loads(response.read() or b"null")
except urllib.error.HTTPError as error: # 4xx and 5xx carry the error envelope
try:
return error.code, json.loads(error.read() or b"null")
except ValueError:
return error.code, NoneKeep the key in your secret store or environment. Never put it in code, logs, tickets or a browser.
cURL
curl -sS "$UPPU_API_BASE/channels" \
-H "Authorization: Bearer $UPPU_API_KEY" \
-H "Accept: application/json"PHP
$r = uppu_request('GET', 'channels');
foreach ($r['body']['data'] ?? [] as $channel) {
echo $channel['id'], ' ', $channel['type'], ' ', $channel['sending'], PHP_EOL;
}Node.js
const { data: page } = await uppuRequest('GET', 'channels');
for (const channel of page.data) {
console.log(channel.id, channel.type, channel.sending);
}Python
status, page = uppu_request("GET", "channels")
for channel in page["data"]:
print(channel["id"], channel["type"], channel["sending"]){"object": "list",
"data": [{"object": "channel",
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp",
"handling": "inbox",
"sending": "active",
"capabilities": {"content_types": ["text", "interactive", "image", "video", "audio",
"document", "template", "reaction", "location"],
"limits": {"text_body": 4000, "buttons": 3},
"window_hours": 24,
"phone_addressing": true}}],
"next_cursor": null}capabilities tells you what each channel can send: content types, field limits and the reply window. A key bound to one channel sees only that channel.
cURL
curl -sS "$UPPU_API_BASE/templates?channel_id=01j9z0ch5k2m4n6p8q0r2s4t6v&status=APPROVED" \
-H "Authorization: Bearer $UPPU_API_KEY"PHP
$query = http_build_query(['channel_id' => '01j9z0ch5k2m4n6p8q0r2s4t6v', 'status' => 'APPROVED']);
$r = uppu_request('GET', 'templates?' . $query);
foreach ($r['body']['data'] ?? [] as $template) {
// sendable, unsendable_reason, parameters and a ready send_example
echo $template['name'], ' ', $template['language'], ' ', $template['sendable'] ? 'ok' : $template['unsendable_reason'], PHP_EOL;
}Node.js
const query = new URLSearchParams({ channel_id: '01j9z0ch5k2m4n6p8q0r2s4t6v', status: 'APPROVED' });
const { data: page } = await uppuRequest('GET', `templates?${query}`);
for (const t of page.data) {
console.log(t.name, t.language, t.sendable ? 'ok' : t.unsendable_reason);
}Python
from urllib.parse import urlencode
query = urlencode({"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v", "status": "APPROVED"})
status, page = uppu_request("GET", f"templates?{query}")
for t in page["data"]:
print(t["name"], t["language"], "ok" if t["sendable"] else t["unsendable_reason"])Templates belong to the WhatsApp Business account behind a channel; they're synced with Meta every 6 hours and when you ask in the panel. Each template carries sendable, unsendable_reason, a parameter schema and a send_example: a ready content object built from the template's own example values.
On WhatsApp an approved template can go out at any time. Everything else needs the 24-hour customer service window, which is counted by Meta's clock from the person's last message.
cURL
curl -sS -X POST "$UPPU_API_BASE/messages" \
-H "Authorization: Bearer $UPPU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-shipped" \
-d '{"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"to": {"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v"},
"content": {"type": "template",
"template": {"name": "order_update", "language": "en",
"body": {"parameters": ["Ayşe", "1001"]}}},
"client_reference": "order-1001"}'PHP
// One logical send = one key. Retry with the SAME key and the SAME body.
$r = uppu_request('POST', 'messages', [
'channel_id' => '01j9z0ch5k2m4n6p8q0r2s4t6v',
'to' => ['contact_id' => '01j8x7ct5k2m4n6p8q0r2s4t6v'],
'content' => ['type' => 'template', 'template' => [
'name' => 'order_update', 'language' => 'en',
'body' => ['parameters' => ['Ayşe', '1001']],
]],
'client_reference' => 'order-1001',
], 'order-1001-shipped');Node.js
// One logical send = one key. Retry with the SAME key and the SAME body.
const { status, data } = await uppuRequest('POST', 'messages', {
channel_id: '01j9z0ch5k2m4n6p8q0r2s4t6v',
to: { contact_id: '01j8x7ct5k2m4n6p8q0r2s4t6v' },
content: { type: 'template', template: {
name: 'order_update', language: 'en', body: { parameters: ['Ayşe', '1001'] },
} },
client_reference: 'order-1001',
}, 'order-1001-shipped');Python
# One logical send = one key. Retry with the SAME key and the SAME body.
status, message = uppu_request("POST", "messages", {
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"to": {"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v"},
"content": {"type": "template", "template": {
"name": "order_update", "language": "en",
"body": {"parameters": ["Ayşe", "1001"]},
}},
"client_reference": "order-1001",
}, idempotency_key="order-1001-shipped")422 template_category_not_allowed.["Ayşe", "1001"], the first value is {{1}}), named templates an object of names and values. Values are text only.For a WhatsApp number that has never written to you, address it with "to": {"phone": "+905550000001"} and add an inline consent (scope consents:write):
"consent": {"purpose": "utility",
"state": "granted",
"source_text": "The exact text the person agreed to (at most 2000 characters)",
"source_ref": "web-form:7781",
"collected_at": "2026-10-15T12:00:00Z"}cURL
curl -sS -X POST "$UPPU_API_BASE/messages" \
-H "Authorization: Bearer $UPPU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-01jab3t6ms2m4n6p8q0r2s4t6v" \
-d '{"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"to": {"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v"},
"content": {"type": "text", "text": {"body": "Your order is on its way."}},
"client_reference": "order-1001"}'PHP
$r = uppu_request('POST', 'messages', [
'channel_id' => '01j9z0ch5k2m4n6p8q0r2s4t6v',
'to' => ['contact_id' => '01j8x7ct5k2m4n6p8q0r2s4t6v'],
'content' => ['type' => 'text', 'text' => ['body' => 'Your order is on its way.']],
'client_reference' => 'order-1001',
], 'reply-01jab3t6ms2m4n6p8q0r2s4t6v');
if ($r['status'] === 202 || $r['status'] === 200) {
$messageId = $r['body']['id']; // store it; statuses arrive as message.status webhooks
} else {
$code = $r['body']['error']['code'] ?? 'http_' . $r['status']; // e.g. window_closed
}Node.js
const { status, data } = await uppuRequest('POST', 'messages', {
channel_id: '01j9z0ch5k2m4n6p8q0r2s4t6v',
to: { contact_id: '01j8x7ct5k2m4n6p8q0r2s4t6v' },
content: { type: 'text', text: { body: 'Your order is on its way.' } },
client_reference: 'order-1001',
}, 'reply-01jab3t6ms2m4n6p8q0r2s4t6v');
if (status === 202 || status === 200) {
console.log('stored', data.id); // statuses arrive as message.status webhooks
} else {
console.log('refused', data?.error?.code); // e.g. window_closed
}Python
status, message = uppu_request("POST", "messages", {
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"to": {"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v"},
"content": {"type": "text", "text": {"body": "Your order is on its way."}},
"client_reference": "order-1001",
}, idempotency_key="reply-01jab3t6ms2m4n6p8q0r2s4t6v")
if status in (200, 202):
print("stored", message["id"]) # statuses arrive as message.status webhooks
else:
print("refused", message["error"]["code"]) # e.g. window_closed{"object": "message",
"id": "01jab3a1k9f2m4n6p8q0r2s4t6",
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"conversation_id": "01jab39zcv2m4n6p8q0r2s4t6v",
"direction": "out",
"author": {"type": "api", "label": "Order bot", "api_key_prefix": "k7Hq2mP0aZ1x"},
"content": {"type": "text", "text": {"body": "Your order is on its way.", "preview_url": false}},
"status": "queued",
"error": null,
"client_reference": "order-1001",
"provider_message_id": null,
"created_at": "2026-10-20T09:20:00.120Z"}Store the message id. Statuses follow as message.status webhooks (accepted, sent, delivered, read or failed) carrying your client_reference. A send to a contact without an open conversation is recorded in a new, already resolved conversation; their reply opens a conversation as usual.
| Situation | What UPPU does | What you do |
|---|---|---|
| Same key, same body | 200 with the current state of the first message and Idempotent-Replayed: true | Nothing: no duplicate was sent |
| Same key, different body | 409 idempotency_key_reused | Fix the code: one logical send gets one key and one body |
Network error, 429 or 5xx | The first attempt may or may not have been stored | Retry with the same key and body, after Retry-After |
A policy refusal (for example window_closed) | Nothing is sent and the key isn't kept | Fix the cause; the same key is evaluated again |
Status failed arrives later | The send is final | A new send needs a new key |
client_reference and consent.collected_at, from the business event, never from the attempt or the current time.window_closed (422) is an expected outcome, not an alarm: on WhatsApp send a template, on Messenger and Instagram wait for the person to write again.These are all the endpoints available today, relative to the base URL. The API reference has every parameter and schema.
| Method and path | Scope | Purpose |
|---|---|---|
GET /v1/channelsGET /v1/channels/{id} | any key | Channels, their status and capabilities |
POST /v1/messages | messages:send | Send a message; Idempotency-Key required |
GET /v1/messagesGET /v1/messages/{id} | messages:read | Reconcile after a webhook gap; read one message |
POST /v1/messages/{id}/read | messages:send | Read receipt for an inbound WhatsApp message |
POST /v1/contacts/lookup | contacts:read | Find a contact by phone or identity; personal data stays out of URLs |
GET /v1/templatesGET /v1/templates/{id} | templates:read | WhatsApp templates with sendable and send_example |
GET /v1/events | events:read | The last 7 days of webhook events |
POST /v1/media | media:write | Upload a file to send (multipart, at most 25 MB) |
GET /v1/media/{id}GET /v1/media/{id}/content | media:read | File metadata; download a file |
GET /v1/openapi.json | no key | This API's OpenAPI document |
^[0-9a-hjkmnp-tv-z]{26}$), and every object carries object.2026-10-20T09:20:00.120Z). Null fields are always present.+905550000001). Personal data never goes into a URL: look contacts up with POST /v1/contacts/lookup.422 validation_failed with the field code unknown_field.content.type as unsupported.limit (1 to 100, default 25), cursor, order=asc|desc, created_after and created_before, and return {"object":"list","data":[…],"next_cursor":…}. The cursor carries the order; repeat the time filters on every page.X-Request-Id (yours is echoed if it's at most 64 visible ASCII characters). Look for RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After and, on a replay, Idempotent-Replayed: true.Accept-Language: en or tr changes only error.message. error.code is the stable contract.| Applies to | Limit |
|---|---|
| All endpoints | 50 requests per second on average (3,000 per minute), bursts up to 100 |
| Sends and read receipts together | 20 per second |
GET /v1/events | 2 per second |
POST /v1/media | 2 per second and 1 GB per day |
GET /v1/media/{id}/content | 10 per second and 2 GB per hour |
| A channel's send queue | 429 channel_backlog with Retry-After: 30 while 10,000 or more sends wait, or the oldest is older than 15 minutes |
413 payload_too_large). POST /v1/media takes up to 26 MB with a file of at most 25 MB.401 in a loop.Every error uses the same envelope. code is the stable contract; message follows Accept-Language and is meant for people.
{"error": {"type": "policy",
"code": "window_closed",
"status": 422,
"retryable": false,
"message": "Human-readable text (follows Accept-Language; do not parse)",
"request_id": "01jace0e1v2m4n6p8q0r2s4t6a",
"doc_url": "https://messaging.uppu.net/docs/errors#window_closed",
"details": {"window_closed_at": "2026-10-19T08:58:00.000Z", "hint": "send_template"},
"provider_error": null}}retryable is true only for media_not_ready, rate_limited, channel_backlog, internal_error and service_unavailable. Retry those with the same Idempotency-Key and body after Retry-After.
| Code | HTTP | What to do |
|---|---|---|
missing_api_key | 401 | Send Authorization: Bearer … on every request. |
invalid_api_key | 401 | The key is unknown, revoked or expired, or the workspace is closed. Use an active key; don't retry in a loop. |
wrong_environment | 401 | A uppu_test_ key on live or a uppu_live_ key on staging. Use the key of that environment. |
insufficient_scope | 403 | details.required names the missing scope. Add it to the key or use another key. |
idempotency_key_required | 400 | POST /v1/messages needs an Idempotency-Key of 1 to 64 characters (A-Z a-z 0-9 . _ : -). |
idempotency_key_reused | 409 | Same key, different body. The first send already exists; rebuild the retry byte for byte, or give a new logical send a new key. |
validation_failed | 422 | details.fields lists each field and its code (for example unknown_field, too_long). Fix the request. |
window_closed | 422 | The 24-hour window is closed. details.hint says send_template (WhatsApp) or wait_for_contact (Messenger, Instagram, live chat). |
consent_required | 422 | The template's category needs a recorded consent. Send the template with an inline consent collected from the person. |
template_not_approved | 422 | The template isn't APPROVED (details.status). Pick a template with sendable: true. |
rate_limited | 429 | Wait for Retry-After, then retry with the same key and body. |
channel_backlog | 429 | The channel's send queue is full. Wait 30 seconds (Retry-After) and retry with the same key. |
The full list of error codes also covers the asynchronous codes that arrive later in message.status.
Meta's answers arrive later as a message.status event with status failed and an error.code, for example window_closed, marketing_opt_out or recipient_unreachable. Only provider_rate_limited and provider_unavailable are retryable, and a resend needs a new Idempotency-Key. UPPU itself retries temporary Meta errors before giving up. Never resend on unknown: after 10 minutes the message shows error.code delivery_unverified.
Each channel has a handling mode, set by the workspace owner in the panel. In API mode your own bot or system runs the channel, and your team can still step in from the inbox.
| Feature | Inbox mode | API mode |
|---|---|---|
| Inbound messages, contacts and conversations | Recorded | Recorded |
| Webhook events | If you subscribe | If you subscribe |
| Auto-assignment to agents | Live chat only, as set for the channel | Off |
| Email about an unanswered conversation | When nobody is online in the inbox | Off |
| Agents can reply from the inbox | Yes | Yes |
| An idle open conversation | Stays open | Resolved after 24 hours |
Messages your system sends appear in the conversation as “API · key label”. Subscribe to message.created if your bot should also see what agents write.
Set-up, request format, event types, signature verification in three languages and delivery rules.
Generated from the OpenAPI document: every endpoint, parameter, schema and the webhook event.
The live document, served without a key at messaging.uppu.net. Load it into your API client or code generator.
Every synchronous and asynchronous error code with its meaning and the retry flag.
We turn the API on for your workspace and walk you through it with your own scenario: notifications, your own bot or your CRM.