Authentication
API keys, OAuth2 client credentials, scopes and rotation.
Two ways in, both server-to-server. Neither is meant for a browser or a mobile app: a credential in front-end code is a credential everyone has.
API keys
The simplest option. An administrator issues a key from the developer portal; it is shown once. Send it as a bearer token on every request:
Authorization: Bearer omni_live_<32 characters>
Keys look like omni_{live|test}_{32 characters}. Only the prefix and the last four characters are kept in the portal for display; the key itself is stored hashed, so if it is lost it is re-issued, not recovered.
A key carries every scope its application has. If your integration only ever sends messages, give the application only messages.send - a leaked key can then do only that.
OAuth2 client credentials
For systems that already manage tokens, or when you want short-lived credentials on the wire. Exchange a client id and secret for an access token that lasts an hour; request only the scopes this token needs.
curl -X POST "https://communication-api.artofluminaire.com/api/v1/oauth/token" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "omni_ci_YOUR_CLIENT_ID",
"client_secret": "omni_sk_live_YOUR_CLIENT_SECRET",
"scope": "messages.send messages.read"
}'// Node.js 18+ (built-in fetch), saved as request.mjs for top-level await. Replace the key with your own.
const response = await fetch("https://communication-api.artofluminaire.com/api/v1/oauth/token", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
"grant_type": "client_credentials",
"client_id": "omni_ci_YOUR_CLIENT_ID",
"client_secret": "omni_sk_live_YOUR_CLIENT_SECRET",
"scope": "messages.send messages.read"
}),
});
const payload = await response.json();
if (!response.ok) {
// Every error is { error: { code, message, details, request_id } }.
throw new Error(`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`);
}
console.log(payload.data);# pip install requests
import requests
response = requests.post(
"https://communication-api.artofluminaire.com/api/v1/oauth/token",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
json={
"grant_type": "client_credentials",
"client_id": "omni_ci_YOUR_CLIENT_ID",
"client_secret": "omni_sk_live_YOUR_CLIENT_SECRET",
"scope": "messages.send messages.read",
},
timeout=30,
)
payload = response.json()
if not response.ok:
# Every error is {"error": {"code", "message", "details", "request_id"}}.
error = payload["error"]
raise RuntimeError(f"{error['code']}: {error['message']} (request {error['request_id']})")
print(payload["data"])<?php
// composer require guzzlehttp/guzzle
require 'vendor/autoload.php';
$client = new GuzzleHttp\Client(['http_errors' => false, 'timeout' => 30]);
$response = $client->post('https://communication-api.artofluminaire.com/api/v1/oauth/token', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'grant_type' => 'client_credentials',
'client_id' => 'omni_ci_YOUR_CLIENT_ID',
'client_secret' => 'omni_sk_live_YOUR_CLIENT_SECRET',
'scope' => 'messages.send messages.read',
],
]);
$payload = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() >= 400) {
// Every error is ['error' => ['code', 'message', 'details', 'request_id']].
throw new RuntimeException(sprintf('%s: %s (request %s)', $payload['error']['code'], $payload['error']['message'], $payload['error']['request_id']));
}
echo json_encode($payload['data'], JSON_PRETTY_PRINT), PHP_EOL;// Java 17+, no dependencies (java.net.http). Parse the JSON with your usual library.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Example {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://communication-api.artofluminaire.com/api/v1/oauth/token"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"grant_type": "client_credentials",
"client_id": "omni_ci_YOUR_CLIENT_ID",
"client_secret": "omni_sk_live_YOUR_CLIENT_SECRET",
"scope": "messages.send messages.read"
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
// Every error is {"error": {"code", "message", "details", "request_id"}}.
throw new RuntimeException("Request failed: " + response.body());
}
System.out.println(response.body());
}
}// .NET 7+ (C# 11 raw string literals). Parse the JSON with System.Text.Json.
using System;
using System.Net.Http;
using System.Text;
using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "https://communication-api.artofluminaire.com/api/v1/oauth/token");
request.Headers.TryAddWithoutValidation("Accept", "application/json");
request.Content = new StringContent("""
{
"grant_type": "client_credentials",
"client_id": "omni_ci_YOUR_CLIENT_ID",
"client_secret": "omni_sk_live_YOUR_CLIENT_SECRET",
"scope": "messages.send messages.read"
}
""", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var payload = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
// Every error is {"error": {"code", "message", "details", "request_id"}}.
throw new Exception($"Request failed: {payload}");
}
Console.WriteLine(payload);// Go 1.20+, standard library only. Decode the JSON with encoding/json.
package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
body := strings.NewReader(`{
"grant_type": "client_credentials",
"client_id": "omni_ci_YOUR_CLIENT_ID",
"client_secret": "omni_sk_live_YOUR_CLIENT_SECRET",
"scope": "messages.send messages.read"
}`)
req, err := http.NewRequest("POST", "https://communication-api.artofluminaire.com/api/v1/oauth/token", body)
if err != nil {
panic(err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
payload, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
// Every error is {"error": {"code", "message", "details", "request_id"}}.
panic(fmt.Sprintf("request failed: %s", payload))
}
fmt.Println(string(payload))
}Try it - send it and see the response
{
"access_token": "omni_at_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "messages.send messages.read"
}Send the token as Authorization: Bearer <access_token>. When it expires you get 401 unauthenticated; request a new one - there is no refresh token, because client credentials are the refresh token. The secret is shown once, like a key.
The token endpoint is rate limited per IP (30 a minute). A legitimate client asks once an hour; cache the token.
Scopes
Every endpoint declares the scopes it accepts. A request without one of them is refused with 403 forbidden before anything is read - it costs nothing and reveals nothing.
| Scope | Allows |
|---|---|
messages.send | Send free-form messages inside the service window. |
messages.read | Read conversations and messages. |
messages.status | Read the delivery status of messages you sent. |
templates.read | List message templates and their approval status. |
templates.send | Send approved templates, including outside the service window. |
contacts.read | Search and read contacts. |
contacts.write | Create and update contacts. |
leads.create | Create leads for contacts. |
leads.read | Read leads. |
webhooks.receive | Register endpoints and receive signed deliveries. |
events.send | Send business events that start automations. |
Channel allow-list
Beside scopes, an application has a channel allow-list: the connections it may send through. GET /channels returns exactly those, and a send naming any other connection_id is refused with 403 forbidden. A message-only integration for the sales number cannot post from the support number.
Rotation and revocation
- Rotate a key from the portal and the old one keeps working for a grace window (24 hours by default) so you can redeploy without a gap. After that it is dead.
- Revoke a key and it stops immediately - within the same second, on every host. Opaque credentials have no cached validity to wait out.
- Suspend an application and every credential it has stops, with
403 account_suspended.
Every issue, rotation and revocation is in the workspace's audit log with who did it and from where.
Environments
omni_test_ credentials belong to a test application and can only use the channels the business marked as test. The API, the envelope and the webhooks are identical; only the key changes when you go live.
https://communication-api.artofluminaire.com
Every response carries X-Request-ID; quote it when you write to support.