Account automation clients
Self-provision a confidential OAuth client that manages your own company account over the API — create and configure services, edit your company profile and fields, upload photos — instead of clicking through the portal. This is the management counterpart to the per-service registered data client, which only reads connected-person data.
Account client vs. per-service data client
allus has two kinds of company OAuth client. They are parallel and never overlap:
| Account automation client (this page) | Per-service data client | |
|---|---|---|
| Purpose | Manage your own account: services, company profile, fields, photos | Read auto-updating connected-person data |
| Client id prefix | acct_… | svc_… |
| Bound to | Your company user (acts AS the company) | Exactly one service |
| How many | Multiple named per company (least-privilege per integration) | One per service |
| Scope | Configurable capability groups (management endpoints) | A fixed, non-configurable service-data whitelist |
| Reads person data? | No — never gets /api/company-data/* | Yes (that's its whole job) |
| Auth | Confidential; client_credentials (default) + auth-code/PKCE | Confidential; same two grants |
Owner-scoping does the confinement: an account client acts as your company user, so every endpoint it touches returns 404 for anything you don't own — exactly as the portal does when you're logged in.
Provisioning a client
Create the client in the portal (Settings → API clients): give it a name, tick the capability groups to grant, and optionally set a redirect URI (enables the auth-code flow). The portal returns a client_id (an acct_… id) and a client_secret shown exactly once.
The same provisioning is available over the portal API (owner-scoped to your company; these routes are reachable with your portal token):
| Method & path | Purpose |
|---|---|
POST /api/account/clients | Create. Body { name, capabilities:[service_management|company_profile_fields|customer_connections|oauth_apps|delegate_management], redirect_uri?, purpose? }. Returns { client_id, client_secret (once), name, purpose, capabilities, redirect_uri }. |
GET /api/account/clients | List your account clients (client_id, name, purpose, capabilities, redirect_uri, created_at) — never the secret. |
PUT /api/account/clients/{id}/capabilities | Re-grant: { capabilities:[…], redirect_uri? }. The always-on baseline is always kept (the whitelist is never empty). |
POST /api/account/clients/{id}/regenerate-secret | New secret (shown once); revokes existing tokens. |
DELETE /api/account/clients/{id} | Hard-delete the client, its tokens, and its whitelist. |
purpose is an optional identity string (1–64 chars, [a-z0-9_.:-]) unique per company: set it once at create time to let your own integration find this exact client again by filtering the list, rather than by its display name — a client created without one always carries purpose: null, and it can never be set or changed afterwards. Omit the field (or send null) for no purpose; a purpose key present with a blank or whitespace-only string is rejected as 400 account_clients.purpose_invalid rather than silently treated as omitted. A second create with a purpose already in use on your account is refused 409 account_clients.purpose_taken. A handful of prefixes (currently crm: and shopify:) are reserved for allme's own platform features and are refused here with 400 account_clients.purpose_reserved — the portal's Integrations section provisions its own clients under those through their own dedicated routes, never through this one. Those routes are not an integrator surface and are deliberately not listed above: they exist for the portal's own hand-off, mint a client whose grant and secret that feature then owns, and are documented in the portal rather than here. PUT .../capabilities and POST .../regenerate-secret both refuse a client carrying a reserved purpose (409 account_clients.purpose_managed): its grant and secret are changed only by its owning feature's own route.
Every endpoint is owner-scoped to your company, so a client you don't own returns 404. Validation surfaces these error keys: account_clients.name_invalid (missing/blank name), account_clients.redirect_uri_invalid (a malformed redirect URI), account_clients.purpose_invalid (a purpose outside the allowed shape), account_clients.purpose_reserved (a purpose prefix reserved for a platform feature), account_clients.purpose_taken (a purpose already used by another of your clients), account_clients.purpose_managed (a capability/secret change refused because the client belongs to a platform feature), and account_clients.not_found (an unknown or non-owned client id).
client_secret is shown once on create + regenerate and stored only as a keyed hash — keep it in a secret manager, never in source control. Regenerating revokes all existing tokens for that client, so rotate during a maintenance window. Multiple named clients let you rotate one integration without touching the others.Authenticating
An account client uses the standard client-credentials grant. POST your client_id/client_secret form-encoded to /oauth2/token:
curl -s -X POST https://api-eu.allme.fyi/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id=acct_1a2b3c... \
-d client_secret=...
# → { "token_type": "Bearer", "expires_in": 3600, "access_token": "ey..." }
The same exchange from code:
import requests
resp = requests.post("https://api-eu.allme.fyi/oauth2/token", data={
"grant_type": "client_credentials",
"client_id": "acct_1a2b3c...",
"client_secret": "...",
})
token = resp.json()["access_token"]const resp = await fetch("https://api-eu.allme.fyi/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: "acct_1a2b3c...",
client_secret: "...",
}),
});
const { access_token } = await resp.json();$resp = file_get_contents('https://api-eu.allme.fyi/oauth2/token', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => 'acct_1a2b3c...',
'client_secret' => '...',
]),
],
]));
$token = json_decode($resp, true)['access_token'];import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
)
resp, err := http.PostForm("https://api-eu.allme.fyi/oauth2/token", url.Values{
"grant_type": {"client_credentials"},
"client_id": {"acct_1a2b3c..."},
"client_secret": {"..."},
})
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var body struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
log.Fatal(err)
}
token := body.AccessToken
fmt.Println("bearer token:", token)using var http = new HttpClient();
var resp = await http.PostAsync("https://api-eu.allme.fyi/oauth2/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "acct_1a2b3c...",
["client_secret"] = "...",
}));
var token = JsonDocument.Parse(await resp.Content.ReadAsStringAsync())
.RootElement.GetProperty("access_token").GetString();HttpClient http = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder(URI.create("https://api-eu.allme.fyi/oauth2/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(
"grant_type=client_credentials"
+ "&client_id=acct_1a2b3c..."
+ "&client_secret=..."))
.build();
String json = http.send(req, HttpResponse.BodyHandlers.ofString()).body();
// pull access_token out of the JSON with your JSON library of choiceSend the token as a bearer header on every management call, addressed to the api_url the token response returned rather than to the global front door you authenticated at (Regions):
curl -s https://api-eu.allme.fyi/api/services \
-H "Authorization: Bearer ey..."
Authorization code + PKCE (optional)
If you set a redirect_uri on the client, it also supports the authorization-code grant with PKCE — for letting a company user authorize the integration interactively. The allowed_user_type='company' gate applies (only a company account can complete it), and the resulting token carries the real company user, so ownership is already correct.
curl -s -X POST https://api-eu.allme.fyi/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d client_id=acct_1a2b3c... \
-d client_secret=... \
-d code=<AUTH_CODE> \
-d redirect_uri=https://your-app.example/callback
- Access tokens last one hour (
expires_in: 3600). - client_credentials tokens carry no refresh token — request a fresh one when needed.
- auth-code / refresh-token grants return a rotating
refresh_token.
The token mechanics are identical to the service client — see that page for the full grant details.
Capability groups
You grant an account client a subset of named capability groups. Each maps to a set of additive endpoint patterns. A group is all-or-nothing; the always-on read baseline is granted to every account client even if you tick no groups.
| Group | Endpoints granted | What it lets you do |
|---|---|---|
| Always-on baseline every client |
/api/account (exact)/api/profile (exact)/oauth2/token/api/contact-field-types* |
Read your account info; read + update the benign profile (display name); read the field-type registry. The exact patterns match only those routes — the sensitive sub-paths below are excluded by construction. |
Service managementservice_management |
/api/services*/api/account/recovery-* |
The full service family: create/get/delete services, share-code, admin-email, request & share config, webhooks, register per-service data clients, share photos, the service private-pem, and the runs of your own services' contract flows (/api/services/{id}/flows/{flowId}/runs*: start, read, answer the company's turn, cancel, and …/runs/{runId}/generate — the party that answers a document-mode leaf generates the contract, so an automation that answers the leaf here generates here too, from your service-key copy of the answers; body and response as the company-data generate). Your own services only. The recovery routes let it store a recovery copy of a secret it mints. |
Company profile & fieldscompany_profile_fields |
/api/contact-data*/api/contact-fields*/api/photos* |
Your company's own plaintext business-card data, custom fields, and profile photos. |
Customer connectionscustomer_connections |
/api/company-connections*/api/customer/*/api/keys* |
Act as a customer of another company's service: your company↔company connections, answering their requests, the documents and contract-flow runs they send you, and your account change feed (GET /api/customer/changes). The SDKs' customer client runs on a client with this group. |
Sign-in apps (OAuth)oauth_apps |
/api/account/oauth-apps*/api/account/recovery-* |
Create and manage “Sign in with allme” (OAuth/OIDC) apps on this account. POST /api/account/oauth-apps accepts claims_delivery (encrypted default, or plaintext for relying parties that read raw id_token claims and cannot run the SDK, e.g. Shopify); PUT /api/account/oauth-apps/{id} replaces the redirect-URI set (exact-match, audit-logged). A bigger grant than the others — a leaked client with it can register sign-in apps; grant it only to a trusted self-provisioning integration. |
Delegate managementdelegate_management |
/api/delegates* |
Manage the people who act for your company in the portal, and hand a granted service its key over the API. Granting someone access to a service's connected people is a separate decision from configuring the service, so it is its own group. |
/api/account and /api/profile (not the wildcards the portal login holds), so the credential/identity/destructive sub-paths are never reachable. A client with no whitelist entry at all can call nothing.Never offered
These account-sensitive operations are never part of any capability group and can never be granted to an account client — they stay portal-login-only:
/api/account/password · /api/account/email · /api/account/2fa* · /api/account/delete-request · /api/account/confirm-delete · /api/account/trusted-devices* · /api/profile/migrate-region · /api/profile/keys · /api/account-key
A leaked account-client secret cannot change your login, change your email, disable 2FA, delete the account, migrate region, rotate your encryption keys, or download your account key — those paths are distinct routes that no group's pattern matches.
Creating services: the key recipe
Creating a service from a browser generates the service keypair for you and shows the passphrase once. An automation client has no browser, so when it calls POST /api/services it must generate the RSA-2048 keypair itself and send the same payload the portal sends:
{
"name": "CRM",
"admin_email": "ops@yourco.example",
"public_key": "<base64 SPKI of the RSA-2048 public key>",
"encrypted_private_key": { "iter": 100000, "salt": "<b64>", "iv": "<b64>", "data": "<b64>" }
}
The contract is zero-knowledge: the server stores only the public key and the encrypted private key — it never sees the passphrase or the plaintext private key. Reproduce the exact recipe the portal uses:
| Step | Algorithm |
|---|---|
| Keypair | RSA-OAEP, 2048-bit, public exponent 65537, hash SHA-256. |
public_key | Export the public key as SPKI, base64-encode the DER bytes. |
| Passphrase | A random 32-byte secret — this is the service private-key passphrase. Store it (and optionally a recovery copy). |
| Wrap key | PBKDF2-SHA256, 100 000 iterations, random 16-byte salt → an AES-256 key. |
encrypted_private_key | Export the private key as PKCS#8, encrypt with AES-256-GCM (random 12-byte IV). Emit { iter, salt (b64), iv (b64), data (b64) }. |
A worked example per language — the TypeScript one (Node 18+ / browser Web Crypto) is identical to the portal's crypto.js; every variant emits the exact same { iter, salt, iv, data } shape, with the GCM tag appended to data:
const b64 = (buf) => Buffer.from(buf).toString("base64"); // browser: btoa(String.fromCharCode(...new Uint8Array(buf)))
// 1. RSA-2048 keypair
const kp = await crypto.subtle.generateKey(
{ name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1,0,1]), hash: "SHA-256" },
true, ["encrypt","decrypt"]);
// 2. public_key = base64(SPKI)
const publicKey = b64(await crypto.subtle.exportKey("spki", kp.publicKey));
// 3. a random 32-byte passphrase — KEEP THIS; it is never recoverable
const passphrase = b64(crypto.getRandomValues(new Uint8Array(32)));
// 4. PBKDF2-SHA256 100k over the passphrase -> AES-256-GCM wrap key
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const km = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]);
const aesKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
km, { name: "AES-GCM", length: 256 }, false, ["encrypt"]);
// 5. encrypt the PKCS#8 private key
const pkcs8 = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, pkcs8);
const encrypted_private_key = { iter: 100000, salt: b64(salt), iv: b64(iv), data: b64(new Uint8Array(data)) };
// 6. create the service
await fetch("https://api-eu.allme.fyi/api/services", {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "CRM", admin_email: "ops@yourco.example", public_key: publicKey, encrypted_private_key }),
});import base64, os, requests
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
b64 = lambda b: base64.b64encode(b).decode()
# 1. RSA-2048 keypair
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# 2. public_key = base64(SPKI)
public_key = b64(key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
# 3. a random 32-byte passphrase — KEEP THIS; it is never recoverable
passphrase = b64(os.urandom(32))
# 4. PBKDF2-SHA256 100k over the passphrase -> AES-256-GCM wrap key
salt, iv = os.urandom(16), os.urandom(12)
aes_key = PBKDF2HMAC(hashes.SHA256(), 32, salt, 100_000).derive(passphrase.encode())
# 5. encrypt the PKCS#8 private key (AESGCM appends the tag to the ciphertext)
pkcs8 = key.private_bytes(serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
data = AESGCM(aes_key).encrypt(iv, pkcs8, None)
encrypted_private_key = {"iter": 100_000, "salt": b64(salt), "iv": b64(iv), "data": b64(data)}
# 6. create the service
requests.post("https://api-eu.allme.fyi/api/services",
headers={"Authorization": f"Bearer {token}"},
json={"name": "CRM", "admin_email": "ops@yourco.example",
"public_key": public_key, "encrypted_private_key": encrypted_private_key})import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"golang.org/x/crypto/pbkdf2"
)
b64 := base64.StdEncoding.EncodeToString
// 1. RSA-2048 keypair
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatal(err)
}
// 2. public_key = base64(SPKI)
spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
log.Fatal(err)
}
publicKey := b64(spki)
// 3. a random 32-byte passphrase — KEEP THIS; it is never recoverable
pp := make([]byte, 32)
rand.Read(pp)
passphrase := b64(pp)
fmt.Println("service key passphrase (store it now):", passphrase)
// 4. PBKDF2-SHA256 100k over the passphrase -> AES-256-GCM wrap key
salt := make([]byte, 16)
iv := make([]byte, 12)
rand.Read(salt)
rand.Read(iv)
aesKey := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
// 5. encrypt the PKCS#8 private key (Seal appends the GCM tag)
pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
log.Fatal(err)
}
block, err := aes.NewCipher(aesKey)
if err != nil {
log.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
log.Fatal(err)
}
data := gcm.Seal(nil, iv, pkcs8, nil)
// 6. create the service
payload, err := json.Marshal(map[string]any{
"name": "CRM", "admin_email": "ops@yourco.example",
"public_key": publicKey,
"encrypted_private_key": map[string]any{
"iter": 100000, "salt": b64(salt), "iv": b64(iv), "data": b64(data),
},
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest("POST", "https://api-eu.allme.fyi/api/services", bytes.NewReader(payload))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()static string B64(byte[] b) => Convert.ToBase64String(b);
// 1. RSA-2048 keypair
using var rsa = RSA.Create(2048);
// 2. public_key = base64(SPKI)
var publicKey = B64(rsa.ExportSubjectPublicKeyInfo());
// 3. a random 32-byte passphrase — KEEP THIS; it is never recoverable
var passphrase = B64(RandomNumberGenerator.GetBytes(32));
// 4. PBKDF2-SHA256 100k over the passphrase -> AES-256-GCM wrap key
var salt = RandomNumberGenerator.GetBytes(16);
var iv = RandomNumberGenerator.GetBytes(12);
var aesKey = Rfc2898DeriveBytes.Pbkdf2(passphrase, salt, 100_000, HashAlgorithmName.SHA256, 32);
// 5. encrypt the PKCS#8 private key — data = ciphertext || tag (AesGcm keeps them separate)
var pkcs8 = rsa.ExportPkcs8PrivateKey();
var cipher = new byte[pkcs8.Length];
var tag = new byte[16];
using var gcm = new AesGcm(aesKey, 16);
gcm.Encrypt(iv, pkcs8, cipher, tag);
var data = cipher.Concat(tag).ToArray();
// 6. POST /api/services with name, admin_email, public_key and
// new { iter = 100_000, salt = B64(salt), iv = B64(iv), data = B64(data) }Base64.Encoder b64 = Base64.getEncoder();
SecureRandom rnd = new SecureRandom();
// 1. RSA-2048 keypair
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
// 2. public_key = base64(SPKI) — an RSA public key's getEncoded() IS the SPKI DER
String publicKey = b64.encodeToString(kp.getPublic().getEncoded());
// 3. a random 32-byte passphrase — KEEP THIS; it is never recoverable
byte[] pp = new byte[32]; rnd.nextBytes(pp);
String passphrase = b64.encodeToString(pp);
// 4. PBKDF2-SHA256 100k over the passphrase -> AES-256 wrap key
byte[] salt = new byte[16]; rnd.nextBytes(salt);
byte[] iv = new byte[12]; rnd.nextBytes(iv);
SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] aesKey = f.generateSecret(
new PBEKeySpec(passphrase.toCharArray(), salt, 100000, 256)).getEncoded();
// 5. encrypt the PKCS#8 private key (doFinal appends the GCM tag; getEncoded() = PKCS#8 DER)
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(128, iv));
byte[] data = c.doFinal(kp.getPrivate().getEncoded());
// 6. POST /api/services with name, admin_email, public_key and
// { "iter": 100000, "salt": b64(salt), "iv": b64(iv), "data": b64(data) }// 1. RSA-2048 keypair
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
// 2. public_key = base64(SPKI) — strip the PEM armor to get the base64 DER
$publicKey = preg_replace('/-----[^-]+-----|\s/', '', openssl_pkey_get_details($key)['key']);
// 3. a random 32-byte passphrase — KEEP THIS; it is never recoverable
$passphrase = base64_encode(random_bytes(32));
// 4. PBKDF2-SHA256 100k over the passphrase -> AES-256 wrap key
$salt = random_bytes(16);
$iv = random_bytes(12);
$aesKey = hash_pbkdf2('sha256', $passphrase, $salt, 100000, 32, true);
// 5. PKCS#8 export + AES-256-GCM — data = ciphertext . tag (openssl keeps them separate)
openssl_pkey_export($key, $pkcs8Pem); // "-----BEGIN PRIVATE KEY-----" = PKCS#8
$pkcs8 = base64_decode(preg_replace('/-----[^-]+-----|\s/', '', $pkcs8Pem));
$cipher = openssl_encrypt($pkcs8, 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA, $iv, $tag);
$encryptedPrivateKey = ['iter' => 100000, 'salt' => base64_encode($salt),
'iv' => base64_encode($iv), 'data' => base64_encode($cipher . $tag)];
// 6. POST /api/services with name, admin_email, public_key, encrypted_private_keyA recovery copy. Your company has a recovery key whose private half only your account password opens (the portal provisions it). GET /api/account/recovery-key returns its public_key and fingerprint. Seal the passphrase to that public key with the standard {"_enc":1,…} wrapper and send it on the same POST /api/services as recovery_secret + recovery_key_fingerprint (both or neither; a stale fingerprint is refused). The company can then recover the passphrase in the portal under Settings → Recovery — see Recovery.
Rate limits
Account-client requests are rate-limited per token (a dedicated tier, 600 requests a minute by default). If you exceed it you get 429 with Retry-After; back off and retry. The limit is generous for normal configuration automation — it exists to stop runaway loops, not to throttle real use.
A 503 with error_key: "db.writes_paused" means saving is paused: the platform cannot complete a save in every region right now. Any call that is not a GET can answer it, and so can the account change feed GET /api/customer/changes (which then drains nothing). Nothing was written, so wait Retry-After (30 seconds) and repeat the same call. Reads and token requests keep working.
A 503 with error_key: "platform.out_of_order" means allme is temporarily out of order: the region serving your call is being rebuilt. Every call can answer it, reads included; the account change feed GET /api/customer/changes then drains nothing. The request was not processed, so wait Retry-After (300 seconds) and repeat the same call. The token request with grant_type=client_credentials keeps working; every other grant at POST /oauth2/token, the authorization-code exchange and a refresh_token grant included, answers it.
Next steps
- Advanced: Portal API — every management endpoint your capability groups unlock (services, request/share fields, webhooks).
- Authentication — the token + whitelist model in full, and the per-service data client.
- Encryption & data handling — the
{"_enc":1,…}wrapper and the PEM format behind the key recipe. - Open the portal API spec →