Partner API Documentation
On this page
Overview
The Amical Partner API is exposed as a Supabase Edge Function (api-router). Requests must be authenticated with a client ID, a fresh timestamp, and an HMAC-SHA256 signature over the exact HTTP body and those values.
This design means:
- Intercepting one request does not let an attacker forge new ones without the secret key.
- Tampering with the JSON body or headers used in the signing string breaks the signature.
- The timestamp window limits replay of captured requests.
Quick start
- Generate an API key in the Amical admin portal. You will receive a client_id, a secret_key (shown once), and, if a device is in scope, its amical_id.
- Store those values in environment variables or a secret manager. Import the Postman collection (see bottom of this page) or wire up one of the code examples below.
- Send a POST request with the three required headers (client_id, timestamp, signature) and a JSON body containing an action and amical_id. That's it: no JWT or API key header needed.
Endpoint
The production API URL is below. Send an HTTP POST with Content-Type application/json.
POST https://supa.amical-ai.com/functions/v1/api-routerNo JWT is required; authentication is signature-based.
Headers
Three custom headers are required in addition to Content-Type:
client_id- UUID of the API key row, shown in the Amical app after you generate a key (same value you send as client_id when signing).
timestamp- Unix time in milliseconds as a decimal string (same value must appear in the signing string). Requests older or newer than 5 minutes (server clock) are rejected.
signature- Standard base64 encoding of the 32-byte HMAC-SHA256 digest of the UTF-8 signing string (see below).
CORS preflight (OPTIONS) is supported for browser-based tools; production clients should call the API from a server you control.
Key management
In the Amical admin app, generating an API key creates a random 32-byte HMAC secret in the browser. The secret is stored server-side and shown to you once as standard base64. Store it in a secret manager or environment variable, never in client-side code shipped to browsers.
The portal also shows your client_id (UUIDv7). Use it in both the client_id header and the signing string. If a device is associated with the API key's scope, its amical_id is displayed as well; use it in the amical_id field of your request body.
Signature generation
- Serialize the JSON body to a string. That exact string must be sent as the HTTP body and used in the signing string (same bytes, same Unicode normalization).
- Choose timestamp (ms) and client_id matching the headers.
- Build the signing string by concatenating timestamp, a dot, client_id, a dot, then the raw JSON body with no extra characters.
- Compute the HMAC-SHA256 of the UTF-8 bytes of that string using your secret key (32-byte key, standard base64 as provided by the portal). Put the resulting 32-byte digest in the signature header as standard base64.
Signing string format
signing_string = timestamp + "." + client_id + "." + raw_json_bodytimestamp: exact string sent in the timestamp header (trimmed).client_id: exact string sent in the client_id header (trimmed).raw_json_body: exact raw POST body (the JSON string), not a canonicalized or pretty-printed variant unless that is what you send on the wire.
The server verifies using the secret key stored for your client_id. If your JSON serializer adds spaces or reorders keys differently between signing and sending, verification will fail.
JSON body
Required fields:
{
"action": "string (required)",
"amical_id": "string (required)",
"context_block": "string (optional)",
"first_message": "string (optional)",
"webhook_url": "string (optional)",
"external_id": "string (optional)"
}Additional fields may be added later; unknown fields should be ignored by clients until documented.
Available actions
The list below describes supported action values. More actions will be added over time.
device_get_status
Returns the current Voicyn connection status for a device.
Required: amical_id must identify a device your API key is allowed to access (scope is tied to residence, resident, or operator when the key was created).
{
"action": "device_get_status",
"amical_id": "AMI-XXX-XXX"
}{
"success": true,
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"device": {
"amical_id": "AMI-XXX-XXX",
"status": "OFFLINE"
}
}
}Possible status values include ONLINE and OFFLINE (Voicyn may add others).
device_call_agent
Starts an outbound Voicyn call so the agent rings the device (the resident answers and talks to the agent). The server builds a temporary Voicyn agent from the same configuration as the device's usual agent, optionally overriding the opening line and appending extra context.
Required: amical_id must identify a device your API key is allowed to access (same scope rules as device_get_status).
Optional fields:
| Field | Type | Description |
|---|---|---|
first_message | string | Overrides the agent's default opening line. |
context_block | string | Appended after the assembled system prompt. |
webhook_url | string | Stored on the logged request for future use. |
external_id | string | Your correlation ID, stored on the logged request. |
{
"action": "device_call_agent",
"amical_id": "AMI-XXX-XXX",
"first_message": "Optional override for the agent opening line",
"context_block": "Optional extra context appended to the assembled system prompt",
"webhook_url": "https://example.com/webhook (optional, stored for future use)",
"external_id": "your-correlation-id (optional)"
}{
"success": true,
"requestId": "c209e76f-027b-4fe3-9f45-36c367c666da",
"data": {
"id": "018f1234-5678-7abc-8def-0123456789ab"
}
}On success, data.id is the id of the incoming_calls row created for this request (UUIDv7). The Voicyn call id is stored server-side on that row.
Examples
The secret key below is for illustration only. Replace it with the base64 secret from your Amical portal. The client_id example must be replaced with yours.
jzrFmr/ASj+Xlq1IM5X3QImppmUzQFvfPuUKV9RNIQI=// Node.js 19+ (global Web Crypto). Replace env vars with your values.
const crypto = globalThis.crypto;
const SUPABASE_FUNCTIONS_BASE = process.env.SUPABASE_FUNCTIONS_BASE ?? "https://supa.amical-ai.com/functions/v1";
const SECRET_KEY_BASE64 = process.env.AMICAL_API_SECRET_KEY_B64 ?? "jzrFmr/ASj+Xlq1IM5X3QImppmUzQFvfPuUKV9RNIQI=";
const CLIENT_ID = process.env.AMICAL_API_CLIENT_ID ?? "018f1234-5678-7abc-8def-0123456789ab";
function b64ToBytes(b64) {
return Buffer.from(b64.trim(), "base64");
}
async function signRequest(bodyObject) {
const body = JSON.stringify(bodyObject);
const timestamp = String(Date.now());
const signingString = `${timestamp}.${CLIENT_ID}.${body}`;
const key = await crypto.subtle.importKey(
"raw",
b64ToBytes(SECRET_KEY_BASE64),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signatureBuffer = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(signingString)
);
const signature = Buffer.from(signatureBuffer).toString("base64");
const res = await fetch(`${SUPABASE_FUNCTIONS_BASE}/api-router`, {
method: "POST",
headers: {
"Content-Type": "application/json",
timestamp,
client_id: CLIENT_ID,
signature,
},
body,
});
const result = await res.json();
if (!result.success) {
throw new Error(`API error: ${result.error?.title} (${res.status})`);
}
return result;
}
const result = await signRequest({
action: "device_get_status",
amical_id: "your-device-amical-id",
});
console.log(result);
# Python 3.10+ (stdlib only — no extra dependencies)
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
SECRET_KEY_B64 = os.environ.get(
"AMICAL_API_SECRET_KEY_B64",
"jzrFmr/ASj+Xlq1IM5X3QImppmUzQFvfPuUKV9RNIQI=",
)
CLIENT_ID = os.environ.get("AMICAL_API_CLIENT_ID", "018f1234-5678-7abc-8def-0123456789ab")
BASE = os.environ.get(
"SUPABASE_FUNCTIONS_BASE",
"https://supa.amical-ai.com/functions/v1",
)
def sign_and_post(body: dict) -> dict:
# Body string must be byte-identical to the HTTP body and to the signing input.
body_str = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
timestamp = str(int(time.time() * 1000))
signing_string = f"{timestamp}.{CLIENT_ID}.{body_str}"
secret = base64.b64decode(SECRET_KEY_B64)
sig_bytes = hmac.new(secret, signing_string.encode("utf-8"), hashlib.sha256).digest()
signature = base64.b64encode(sig_bytes).decode("ascii")
req = urllib.request.Request(
f"{BASE}/api-router",
data=body_str.encode("utf-8"),
method="POST",
headers={
"Content-Type": "application/json",
"timestamp": timestamp,
"client_id": CLIENT_ID,
"signature": signature,
},
)
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
result = json.loads(e.read().decode("utf-8"))
if not result.get("success"):
raise RuntimeError(f"API error: {result['error']['title']} ({result['error']['status']})")
return result
HMAC-SHA256 signing requires computing a digest before sending the request, which makes cURL impractical for direct use. We recommend Postman (see below) for manual testing or one of the code examples above for scripted integrations.
Error responses
Errors return a JSON envelope with success: false, an error object, and a requestId. The error.title field is a stable machine-readable code you can match on.
{
"success": false,
"error": {
"title": "signature_invalid",
"message": "signature_invalid",
"status": 401
},
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}Error codes by HTTP status:
400
timestamp_required— Missing timestamp header.timestamp_invalid— Timestamp is not a valid number.invalid_json— Body is not valid JSON.invalid_body— Missing or empty action or amical_id.client_id_required— Missing client_id header.signature_required— Missing signature header.
401
timestamp_expired— Outside the allowed time window.client_id_invalid— No API key for this client_id.signature_invalid— Signature does not match (wrong key, wrong signing string, or wrong body).
403
secret_key_not_configured— Key exists but has no secret configured (regenerate in the portal).secret_key_invalid— Stored secret key is not a valid 32-byte HMAC key (base64).device_access_denied— Device exists but is outside this API key's scope.
404
device_not_found— No device with this amical_id.device_not_provisioned— Device has no Voicyn provisioning (missing voicyn_device_id).
405
method_not_allowed— Only POST is allowed.
500
internal_error— Unexpected server error.incoming_call_update_failed— Call started but saving the Voicyn call id on the logged row failed.
501
action_not_supported— Authenticated request logged; action not implemented yet.
502
voicyn_error— Voicyn GraphQL request failed or returned no device status.agent_config_error— Could not resolve agent configuration for the device (e.g. missing locale or agent template).voicyn_create_agent_error— Voicyn createAgent failed or returned no agent id.voicyn_call_error— Voicyn callDevice failed or returned no call id.
Postman collection
A ready-to-use Postman collection is available with pre-configured requests and automatic HMAC-SHA256 signing via a pre-request script. Import it into Postman, then set your client_id, secret_key, and amical_id in the collection variables.
Download the JSON file and import it via File > Import, or copy the raw JSON to your clipboard and paste it into Postman's Import dialog.