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

    1. 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.
    2. 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.
    3. 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.

    Production endpoint
    POST https://supa.amical-ai.com/functions/v1/api-router

    No 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

    1. 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).
    2. Choose timestamp (ms) and client_id matching the headers.
    3. Build the signing string by concatenating timestamp, a dot, client_id, a dot, then the raw JSON body with no extra characters.
    4. 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

    Pseudocode:
    signing_string = timestamp + "." + client_id + "." + raw_json_body
    • timestamp: 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:

    JSON body (schema)
    {
      "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).

    Example request body
    {
      "action": "device_get_status",
      "amical_id": "AMI-XXX-XXX"
    }
    Example success response
    {
      "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:

    FieldTypeDescription
    first_messagestringOverrides the agent's default opening line.
    context_blockstringAppended after the assembled system prompt.
    webhook_urlstringStored on the logged request for future use.
    external_idstringYour correlation ID, stored on the logged request.
    Example request body
    {
      "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)"
    }
    Example success response
    {
      "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.

    Example secret key (illustration only, use your key from the Amical portal)
    jzrFmr/ASj+Xlq1IM5X3QImppmUzQFvfPuUKV9RNIQI=
    Node.js (Web Crypto)
    // 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 (stdlib)
    # 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.

    Example error response
    {
      "success": false,
      "error": {
        "title": "signature_invalid",
        "message": "signature_invalid",
        "status": 401
      },
      "requestId": "550e8400-e29b-41d4-a716-446655440000"
    }

    Error codes by HTTP status:

    400

    • timestamp_requiredMissing timestamp header.
    • timestamp_invalidTimestamp is not a valid number.
    • invalid_jsonBody is not valid JSON.
    • invalid_bodyMissing or empty action or amical_id.
    • client_id_requiredMissing client_id header.
    • signature_requiredMissing signature header.

    401

    • timestamp_expiredOutside the allowed time window.
    • client_id_invalidNo API key for this client_id.
    • signature_invalidSignature does not match (wrong key, wrong signing string, or wrong body).

    403

    • secret_key_not_configuredKey exists but has no secret configured (regenerate in the portal).
    • secret_key_invalidStored secret key is not a valid 32-byte HMAC key (base64).
    • device_access_deniedDevice exists but is outside this API key's scope.

    404

    • device_not_foundNo device with this amical_id.
    • device_not_provisionedDevice has no Voicyn provisioning (missing voicyn_device_id).

    405

    • method_not_allowedOnly POST is allowed.

    500

    • internal_errorUnexpected server error.
    • incoming_call_update_failedCall started but saving the Voicyn call id on the logged row failed.

    501

    • action_not_supportedAuthenticated request logged; action not implemented yet.

    502

    • voicyn_errorVoicyn GraphQL request failed or returned no device status.
    • agent_config_errorCould not resolve agent configuration for the device (e.g. missing locale or agent template).
    • voicyn_create_agent_errorVoicyn createAgent failed or returned no agent id.
    • voicyn_call_errorVoicyn 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.

    Download collection