ActessiaBook a 30-day pilot
Documentation

identify(): sign on your server

Actions exist only for identified users. Your server signs the identity with the site's secret; the widget passes it along; a wrong signature is a 401.

Actions ("reschedule Thursday") exist only for identified users, and the identity is the whole authorisation boundary, so a client-asserted user id is never trusted. Your server signs the payload with the site's identify secret (shown once in the dashboard, never sent to the browser):

string_to_sign = "actessia-identify-v1\n" + canonical_json(payload)
hmac           = lower_hex( HMAC-SHA256( site_identify_secret, string_to_sign ) )
signature      = { "version": "v1", "hmac": hmac }

canonical_json is RFC 8785 (JCS): keys sorted, no whitespace, UTF-8 unescaped. payload.issued_at is unix seconds and must be within 5 minutes of our clock and no older than 1 hour; an optional expires_at caps it further. Otherwise the runtime answers 401 invalid_signature and the visitor stays anonymous.

Payload fields: user_id (required), email, name, plan, entitlements[], traits{} (flat scalars; keep keys ASCII and numbers integer), issued_at, expires_at.

Node

@actessia/sdk/server, or the dependency-free version below (verified against the shared test vector in CI):

import { signIdentifyPayload } from "@actessia/sdk/server";
const { payload, signature } = signIdentifyPayload(process.env.ACTESSIA_IDENTIFY_SECRET, {
  user_id: user.id, email: user.email, plan: user.plan,
});
res.json({ payload, signature }); // the page passes both to Actessia.identify()
// Actessia identify() signing: Node.js (≥ 18), no dependencies.
//
//   hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\n" + canonical_json(payload) ) )
//
// canonical_json is RFC 8785 (JCS): keys sorted by UTF-16 code units, no whitespace, JSON
// number/string formatting as in JSON.stringify. Prefer `signIdentifyPayload` from
// "@actessia/sdk/server" when you can add a dependency; this file is the standalone version.
//
// Usage from your request handler:
//   const { payload, signature } = signIdentify(process.env.ACTESSIA_IDENTIFY_SECRET, {
//     user_id: user.id, email: user.email, plan: user.plan, issued_at: Math.floor(Date.now() / 1000),
//   });
//   // hand { payload, signature } to the page → Actessia.identify(payload, signature)
//
// Run as a script to verify against the shared vector:
//   node docs/snippets/identify.mjs packages/schemas/fixtures/vectors/identify-signature.json
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";

export function canonicalJson(value) {
  if (value === null || typeof value !== "object") return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
  const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
  return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
}

export function signIdentify(secret, payload) {
  const stringToSign = `actessia-identify-v1\n${canonicalJson(payload)}`;
  const hmac = createHmac("sha256", secret).update(stringToSign, "utf8").digest("hex");
  return { payload, signature: { version: "v1", hmac } };
}

if (process.argv[2]) {
  const v = JSON.parse(readFileSync(process.argv[2], "utf8"));
  const canonical = canonicalJson(v.payload);
  const { signature } = signIdentify(v.secret, v.payload);
  const ok = canonical === v.canonical && `actessia-identify-v1\n${canonical}` === v.string_to_sign && signature.hmac === v.signature.hmac;
  console.log(ok ? "OK identify.mjs reproduces the vector" : `MISMATCH\n${canonical}\n${signature.hmac}`);
  process.exit(ok ? 0 : 1);
}

Python

from identify import sign_identify
payload = {"user_id": user.id, "email": user.email, "plan": user.plan, "issued_at": int(time.time())}
signature = sign_identify(os.environ["ACTESSIA_IDENTIFY_SECRET"], payload)
"""Actessia identify() signing: Python 3.9+.

    hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\\n" + canonical_json(payload) ) )

canonical_json is RFC 8785 (JCS). With the `jcs` package (`pip install jcs`) it is exact for
every JSON value. The standard-library fallback below is exact for payloads made of strings,
integers, booleans, null, lists and objects, i.e. every IdentifyPayload that does not put
non-integer numbers in `traits`.

Usage from your view / handler:
    payload = {"user_id": user.id, "email": user.email, "plan": user.plan, "issued_at": int(time.time())}
    signature = sign_identify(os.environ["ACTESSIA_IDENTIFY_SECRET"], payload)
    # hand {"payload": payload, "signature": signature} to the page → Actessia.identify(payload, signature)

Run as a script to verify against the shared vector:
    python docs/snippets/identify.py packages/schemas/fixtures/vectors/identify-signature.json
"""

from __future__ import annotations

import hashlib
import hmac
import json
import os
import sys
from typing import Any

try:
    if os.environ.get("ACTESSIA_SNIPPET_NO_JCS"):
        raise ImportError
    import jcs  # type: ignore[import-not-found]

    def canonical_json(value: Any) -> str:
        return jcs.canonicalize(value).decode("utf-8")  # type: ignore[no-any-return]

except ImportError:

    def canonical_json(value: Any) -> str:
        return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def sign_identify(secret: str, payload: dict[str, Any]) -> dict[str, str]:
    string_to_sign = "actessia-identify-v1\n" + canonical_json(payload)
    digest = hmac.new(secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
    return {"version": "v1", "hmac": digest}


if __name__ == "__main__":
    with open(sys.argv[1], encoding="utf-8") as f:
        v = json.load(f)
    canonical = canonical_json(v["payload"])
    sig = sign_identify(v["secret"], v["payload"])
    ok = canonical == v["canonical"] and "actessia-identify-v1\n" + canonical == v["string_to_sign"] and sig["hmac"] == v["signature"]["hmac"]
    print("OK identify.py reproduces the vector" if ok else f"MISMATCH\n{canonical}\n{sig['hmac']}")
    sys.exit(0 if ok else 1)

PHP

<?php
// Actessia identify() signing: PHP 8.1+, no dependencies.
//
//   hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\n" . canonical_json(payload) ) )
//
// canonical_json is RFC 8785 (JCS). This implementation is exact for payloads made of strings,
// integers, booleans, null, lists and maps with ASCII keys, every normal IdentifyPayload.
// Two PHP-specific caveats: an empty PHP array encodes as `[]`, so omit an empty `traits`
// (or pass `new stdClass`); and avoid non-integer numbers in `traits` (JCS formats floats the
// way JavaScript does, which PHP does not always match).
//
// Usage from your controller:
//   $signed = actessia_sign_identify($_ENV['ACTESSIA_IDENTIFY_SECRET'], [
//     'user_id' => $user->id, 'email' => $user->email, 'plan' => $user->plan, 'issued_at' => time(),
//   ]);
//   // hand $signed (payload + signature) to the page → Actessia.identify(payload, signature)
//
// Run as a script to verify against the shared vector:
//   php docs/snippets/identify.php packages/schemas/fixtures/vectors/identify-signature.json
declare(strict_types=1);

function actessia_canonical_json(mixed $value): string
{
    if (is_object($value)) {
        $value = get_object_vars($value);
        $isMap = true;
    } else {
        $isMap = is_array($value) && !array_is_list($value);
    }
    if (is_array($value) && !$isMap) {
        return '[' . implode(',', array_map('actessia_canonical_json', $value)) . ']';
    }
    if (is_array($value)) {
        ksort($value, SORT_STRING);
        $parts = [];
        foreach ($value as $k => $v) {
            $parts[] = json_encode((string) $k, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS) . ':' . actessia_canonical_json($v);
        }
        return '{' . implode(',', $parts) . '}';
    }
    return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS);
}

/** @return array{payload: array<string, mixed>, signature: array{version: string, hmac: string}} */
function actessia_sign_identify(string $secret, array $payload): array
{
    $stringToSign = "actessia-identify-v1\n" . actessia_canonical_json($payload);
    return ['payload' => $payload, 'signature' => ['version' => 'v1', 'hmac' => hash_hmac('sha256', $stringToSign, $secret)]];
}

if (PHP_SAPI === 'cli' && isset($argv[1]) && realpath($argv[0]) === __FILE__) {
    $v = json_decode((string) file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
    $canonical = actessia_canonical_json($v['payload']);
    $signed = actessia_sign_identify($v['secret'], $v['payload']);
    $ok = $canonical === $v['canonical'] && "actessia-identify-v1\n" . $canonical === $v['string_to_sign'] && $signed['signature']['hmac'] === $v['signature']['hmac'];
    echo $ok ? "OK identify.php reproduces the vector\n" : "MISMATCH\n$canonical\n{$signed['signature']['hmac']}\n";
    exit($ok ? 0 : 1);
}

Ruby

# Actessia identify() signing: Ruby 3.0+, standard library only.
#
#   hmac = hex( HMAC-SHA256( site_identify_secret, "actessia-identify-v1\n" + canonical_json(payload) ) )
#
# canonical_json is RFC 8785 (JCS). This implementation is exact for payloads made of strings,
# integers, booleans, nil, arrays and hashes with ASCII keys, every normal IdentifyPayload.
# Avoid non-integer numbers in `traits` (JCS formats floats the way JavaScript does). The
# `json-canonicalization` gem is an exact alternative if you need arbitrary values.
#
# Usage from your controller:
#   signed = actessia_sign_identify(ENV.fetch("ACTESSIA_IDENTIFY_SECRET"),
#     { "user_id" => user.id, "email" => user.email, "plan" => user.plan, "issued_at" => Time.now.to_i })
#   # hand signed (payload + signature) to the page → Actessia.identify(payload, signature)
#
# Run as a script to verify against the shared vector:
#   ruby docs/snippets/identify.rb packages/schemas/fixtures/vectors/identify-signature.json
require "json"
require "openssl"

def actessia_canonical_json(value)
  case value
  when Hash
    h = value.to_h { |k, x| [k.to_s, x] }
    "{" + h.keys.sort.map { |k| k.to_json + ":" + actessia_canonical_json(h[k]) }.join(",") + "}"
  when Array
    "[" + value.map { |x| actessia_canonical_json(x) }.join(",") + "]"
  when Float
    value == value.floor && value.abs < 1e21 ? value.to_i.to_s : value.to_s
  else
    value.to_json # String, Integer, true, false, nil
  end
end

def actessia_sign_identify(secret, payload)
  string_to_sign = "actessia-identify-v1\n" + actessia_canonical_json(payload)
  { "payload" => payload, "signature" => { "version" => "v1", "hmac" => OpenSSL::HMAC.hexdigest("SHA256", secret, string_to_sign) } }
end

if __FILE__ == $PROGRAM_NAME && ARGV[0]
  v = JSON.parse(File.read(ARGV[0]))
  canonical = actessia_canonical_json(v["payload"])
  signed = actessia_sign_identify(v["secret"], v["payload"])
  ok = canonical == v["canonical"] && "actessia-identify-v1\n" + canonical == v["string_to_sign"] && signed["signature"]["hmac"] == v["signature"]["hmac"]
  puts(ok ? "OK identify.rb reproduces the vector" : "MISMATCH\n#{canonical}\n#{signed["signature"]["hmac"]}")
  exit(ok ? 0 : 1)
end

Then, on the page: Actessia.identify(signed.payload, signed.signature). On logout, call Actessia.reset() so the next person on that browser starts anonymous.

Rotating the secret

Rotate it from the dashboard's Install page. Signatures made with the old secret stop verifying at once, so deploy the new secret to your server first, then rotate.