UPPU API documentation

Code examples: C#

Requirements: .NET 6 or later; System.Text.Json and HttpClient.

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: UppuClient.cs

// UPPU API v1: a minimal client (.NET 6+, System.Text.Json and HttpClient).
//
// 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.
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

public sealed class UppuClient
{
    private readonly HttpClient _http;

    public UppuClient(HttpClient http)
    {
        var baseUrl = Environment.GetEnvironmentVariable("UPPU_API_BASE") ?? "https://messaging-staging.uppu.net/v1";
        _http = http;
        _http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
        _http.Timeout = TimeSpan.FromSeconds(20);
        _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("UPPU_API_KEY"));
        _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    }

    /// <summary>
    /// Calls the API (path relative to the base URL, for example "messages") and returns the
    /// status and the decoded body. Network errors, 5xx answers and errors marked retryable are
    /// retried with the same Idempotency-Key, so a retry never sends a message twice.
    /// </summary>
    public async Task<(int Status, JsonElement? Body)> SendAsync(HttpMethod method, string path, object? body = null, string? idempotencyKey = null, int attempts = 3)
    {
        for (var attempt = 1; ; attempt++)
        {
            using var request = new HttpRequestMessage(method, path.TrimStart('/'));

            if (body is not null)
            {
                request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
            }

            if (idempotencyKey is not null)
            {
                request.Headers.Add("Idempotency-Key", idempotencyKey);
            }

            HttpResponseMessage response;

            try
            {
                response = await _http.SendAsync(request);
            }
            catch (Exception error) when ((error is HttpRequestException || error is TaskCanceledException) && attempt < attempts)
            {
                await Task.Delay(TimeSpan.FromSeconds(attempt));
                continue;
            }

            using (response)
            {
                var text = await response.Content.ReadAsStringAsync();
                JsonElement? json = text.Length > 0 ? JsonSerializer.Deserialize<JsonElement>(text) : null;
                var status = (int)response.StatusCode;
                var retryable = status >= 500
                    || (json is { } root && root.ValueKind == JsonValueKind.Object
                        && root.TryGetProperty("error", out var error)
                        && error.TryGetProperty("retryable", out var flag)
                        && flag.ValueKind == JsonValueKind.True);

                if (!retryable || attempt >= attempts)
                {
                    return (status, json);
                }

                // Retry-After when UPPU sends it, otherwise 1, 2, ... seconds; at most 10
                var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(attempt);
                await Task.Delay(wait > TimeSpan.FromSeconds(10) ? TimeSpan.FromSeconds(10) : wait);
            }
        }
    }

    /// <summary>The error code of an answer, for logs and decisions (never the message).</summary>
    public static string ErrorCode(int status, JsonElement? body)
    {
        if (body is { } root && root.ValueKind == JsonValueKind.Object
            && root.TryGetProperty("error", out var error)
            && error.TryGetProperty("code", out var code))
        {
            return code.GetString() ?? $"http_{status}";
        }

        return $"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: SendText.cs

// Send a text message: POST /v1/messages, scope messages:send.
using System;
using System.Net.Http;
using System.Threading.Tasks;

public static class SendTextExample
{
    public static async Task RunAsync(UppuClient uppu)
    {
        var (status, message) = await uppu.SendAsync(HttpMethod.Post, "messages", new
        {
            channel_id = "01j9z0ch5k2m4n6p8q0r2s4t6v", // from GET /v1/channels
            to = new { contact_id = "01j8x7ct5k2m4n6p8q0r2s4t6v" }, // from message.received: data.contact.id
            content = new { type = "text", text = new { body = "Your order has shipped." } },
            client_reference = "order-1001",
        }, idempotencyKey: "order-1001-shipped"); // the same key for every retry of this send

        if (status is 200 or 202 && message is { } sent)
        {
            // 202: queued; 200: a replay of the same send (Idempotent-Replayed: true)
            Console.WriteLine($"Message {sent.GetProperty("id")} is {sent.GetProperty("status")}");
        }
        else
        {
            // For example window_closed: the 24-hour window is closed, send a template instead
            Console.WriteLine($"Not sent: {UppuClient.ErrorCode(status, message)}");
        }
    }
}

Send a template message

An approved WhatsApp template, also outside the 24-hour window. The example uses a template of the staging simulator.

File: SendTemplate.cs

// 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.
using System;
using System.Net.Http;
using System.Threading.Tasks;

public static class SendTemplateExample
{
    public static async Task RunAsync(UppuClient uppu)
    {
        var (status, message) = await uppu.SendAsync(HttpMethod.Post, "messages", new
        {
            channel_id = "01j9z0ch5k2m4n6p8q0r2s4t6v",
            to = new { contact_id = "01j8x7ct5k2m4n6p8q0r2s4t6v" },
            content = new
            {
                type = "template",
                template = new
                {
                    name = "uppu_sim_order_update", // a template of the staging simulator
                    language = "tr",
                    body = new { parameters = new[] { "Deniz", "A-1001" } }, // {{1}} and {{2}}
                },
            },
            client_reference = "order-A-1001",
        }, idempotencyKey: "order-A-1001-shipped");

        if (status is 200 or 202 && message is { } sent)
        {
            Console.WriteLine($"Template message {sent.GetProperty("id")} is {sent.GetProperty("status")}");
        }
        else
        {
            // For example consent_required: a MARKETING template needs a recorded consent
            Console.WriteLine($"Not sent: {UppuClient.ErrorCode(status, message)}");
        }
    }
}

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: ListMessages.cs

// List messages page by page: GET /v1/messages, scope messages:read. Use it
// to reconcile after a webhook outage.
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

public static class ListMessagesExample
{
    public static async Task RunAsync(UppuClient uppu)
    {
        // The cursor carries the order; repeat the filters on every page
        const string filters = "channel_id=01j9z0ch5k2m4n6p8q0r2s4t6v&created_after=2026-10-20T00%3A00%3A00Z&order=asc&limit=100";
        string? cursor = null;

        do
        {
            var path = "messages?" + filters + (cursor is null ? "" : "&cursor=" + Uri.EscapeDataString(cursor));
            var (status, page) = await uppu.SendAsync(HttpMethod.Get, path);

            if (status != 200 || page is not { } list)
            {
                throw new InvalidOperationException($"Listing failed: {UppuClient.ErrorCode(status, page)}");
            }

            foreach (var message in list.GetProperty("data").EnumerateArray())
            {
                Console.WriteLine($"{message.GetProperty("id")} {message.GetProperty("direction")} {message.GetProperty("status")}");
            }

            var next = list.GetProperty("next_cursor");
            cursor = next.ValueKind == JsonValueKind.String ? next.GetString() : null;
        }
        while (cursor is not null);
    }
}

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: MarkRead.cs

// 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.
using System;
using System.Net.Http;
using System.Threading.Tasks;

public static class MarkReadExample
{
    public static async Task RunAsync(UppuClient uppu)
    {
        const string messageId = "01jab3t6ms2m4n6p8q0r2s4t6v"; // from message.received: data.message.id

        var (status, receipt) = await uppu.SendAsync(HttpMethod.Post, $"messages/{Uri.EscapeDataString(messageId)}/read", new { });

        if (status is 200 or 202 && receipt is { } queued)
        {
            Console.WriteLine($"Read receipt {queued.GetProperty("state")}");
        }
        else if (UppuClient.ErrorCode(status, receipt) == "not_inbound")
        {
            // Nothing to mark (your own message, a reaction, a notice): final, do not retry
            Console.WriteLine("Nothing to mark");
        }
        else
        {
            Console.WriteLine($"Not marked: {UppuClient.ErrorCode(status, receipt)}");
        }
    }
}

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: UppuWebhook.cs

// Receive UPPU webhooks (.NET 6+): 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.
//
// In an ASP.NET Core minimal API, read the raw body before anything else:
//
//   app.MapPost("/uppu/webhook", async (HttpRequest request) =>
//   {
//       using var buffer = new MemoryStream();
//       await request.Body.CopyToAsync(buffer);
//       var secrets = new[] { Environment.GetEnvironmentVariable("UPPU_WEBHOOK_SECRET"), Environment.GetEnvironmentVariable("UPPU_WEBHOOK_SECRET_PREVIOUS") };
//       if (!UppuWebhook.Verify(secrets, request.Headers["webhook-id"], request.Headers["webhook-timestamp"], buffer.ToArray(), request.Headers["webhook-signature"]))
//       {
//           return Results.StatusCode(401); // anything but 2xx: the wrong-signature probe expects it
//       }
//       // Store the event once per webhook-id (keep ids for at least 35 days), queue it, answer at once
//       return Results.NoContent();
//   });
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

public static class UppuWebhook
{
    /// <summary>
    /// Standard Webhooks v1 signature check: base64(HMAC-SHA256(key, id.timestamp.body)), the key
    /// being the base64-decoded secret after "whsec_". Secrets: the current one and, during a
    /// rotation, the previous one.
    /// </summary>
    public static bool Verify(IEnumerable<string?> secrets, string? id, string? timestamp, byte[] body, string? header, long? now = null, int tolerance = 300)
    {
        if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || header is null
            || !long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds))
        {
            return false;
        }

        if (Math.Abs((now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds()) - seconds) > tolerance)
        {
            return false;
        }

        var signed = Encoding.UTF8.GetBytes(id + "." + timestamp + ".").Concat(body).ToArray();

        foreach (var secret in secrets)
        {
            if (secret is null || !secret.StartsWith("whsec_", StringComparison.Ordinal))
            {
                continue;
            }

            byte[] key;

            try
            {
                key = Convert.FromBase64String(secret.Substring(6));
            }
            catch (FormatException)
            {
                continue;
            }

            if (key.Length == 0)
            {
                continue;
            }

            byte[] expected;

            using (var hmac = new HMACSHA256(key))
            {
                expected = hmac.ComputeHash(signed);
            }

            foreach (var part in header.Split(' ', StringSplitOptions.RemoveEmptyEntries))
            {
                var comma = part.IndexOf(',');

                if (comma < 0 || part.Substring(0, comma) != "v1")
                {
                    continue;
                }

                byte[] given;

                try
                {
                    given = Convert.FromBase64String(part.Substring(comma + 1));
                }
                catch (FormatException)
                {
                    continue;
                }

                if (CryptographicOperations.FixedTimeEquals(expected, given))
                {
                    return true;
                }
            }
        }

        return false;
    }
}