Identity Verification

Identity verification proves that SDK identify calls came from your application. Your server signs identity data with a shared secret, and Aimdoc verifies the signature before trusting it.

How it works

  1. Your backend computes an HMAC-SHA256 signature using your Aimdoc identity verification secret.
  2. Your frontend passes that signature as user_hash to aimdoc.identify.
  3. Aimdoc normalizes the submitted identity fields, recomputes the signature, and rejects the request if any canonical signed value was changed.

User-only identifies use the legacy signature over the user's external ID and normalized email. Identifies that write shared account or journey state use a versioned canonical JSON payload:

  • v2account without stage or objectives
  • v3account includes a lifecycle stage
  • v4 — person-level and/or account-level objective claims are present (with or without account)

Configure a signing secret

In the Aimdoc dashboard:

  1. Open Integrations.
  2. Select Identity Verification.
  3. Choose Generate signing secret.
  4. Store the secret in your backend's secret manager or server environment.

Treat this value like an API credential. Anyone with the secret can generate valid identity assertions for your organization.

Sign identify calls

For a user-only identify, sign "{external_id}:{normalized_email}":

// Server (Node.js)
import { createHmac } from 'crypto'

const userHash = createHmac(
  'sha256',
  process.env.AIMDOC_IDENTITY_SECRET,
)
  .update(`${user.id}:${user.email.trim().toLowerCase()}`)
  .digest('hex')

Pass the server-generated value to the browser:

aimdoc.identify({
  external_id: user.id,
  email: user.email,
  user_hash: userHashFromServer,
})

Sign an account identify

Account identity affects shared account records, so every identify containing account requires a signed canonical payload — even before organization-wide enforcement is enabled.

// Server (Node.js)
import { createHmac } from 'crypto'

const canonicalizeDomain = (value = '') => {
  const rawValue = value.trim()
  if (!rawValue) return ''
  const url = new URL(
    rawValue.includes('://') ? rawValue : `https://${rawValue}`,
  )
  return url.hostname
    .toLowerCase()
    .replace(/\.+$/, '')
    .replace(/^www\./, '')
}

const signedPayload = JSON.stringify({
  account: {
    domain: canonicalizeDomain(account.domain),
    external_id: account.external_id.trim(),
    name: (account.name || '').trim(),
  },
  email: user.email.trim().toLowerCase(),
  external_id: user.id.trim(),
  version: 2,
})

const userHash = createHmac(
  'sha256',
  process.env.AIMDOC_IDENTITY_SECRET,
)
  .update(signedPayload)
  .digest('hex')

Canonical JSON uses compact separators and alphabetically sorted keys at every level. Send the same values and the server-generated hash to the browser:

aimdoc.identify({
  external_id: user.id,
  email: user.email,
  account: {
    external_id: account.external_id,
    name: account.name,
    domain: account.domain,
  },
  user_hash: userHashFromServer,
})

See SDKs for the complete account identity behavior and SDK method usage.

Sign a lifecycle stage claim

When account.stage is present, include stage on the signed account object and set version to 3. Without stage, keep signing the v2 payload above.

const signedPayload = JSON.stringify({
  account: {
    domain: canonicalizeDomain(account.domain),
    external_id: account.external_id.trim(),
    name: (account.name || '').trim(),
    stage: account.stage.trim(),
  },
  email: user.email.trim().toLowerCase(),
  external_id: user.id.trim(),
  version: 3,
})

Use a stage key configured for your organization. Omit stage entirely when you are not claiming one — do not send an empty string.

Sign objective claims

Objectives let your app sync journey progress when a user completes a milestone in your product. Claims are a map of { key: 'in_progress' | 'done' } and can appear in two places:

  • account.objectives — account-level objectives
  • top-level objectives — person-level objectives for the identified user

Any identify that includes objective claims requires a signature, even without an account object. When claims are present, they join the canonical payload where they appear and version becomes 4. Sort keys alphabetically inside each objectives map (JSON.stringify does not sort object keys for you).

const signedPayload = JSON.stringify({
  account: {
    domain: canonicalizeDomain(account.domain),
    external_id: account.external_id.trim(),
    name: (account.name || '').trim(),
    objectives: { trial_started: 'done' },
    stage: account.stage.trim(), // include only when claiming a stage
  },
  email: user.email.trim().toLowerCase(),
  external_id: user.id.trim(),
  objectives: { completed_onboarding: 'done' },
  version: 4,
})

Omit whichever objectives map has no claims. For a person-only claim, omit account entirely:

const signedPayload = JSON.stringify({
  email: user.email.trim().toLowerCase(),
  external_id: user.id.trim(),
  objectives: { completed_onboarding: 'done' },
  version: 4,
})

Without objectives, keep signing the v2 or v3 payload — existing integrations are unaffected. Keys must match active objectives on the agent this identify targets, at the correct level, with a valid status.

Enable enforcement

After all of your applications send signed user-only identifies, enable Enforce identity verification in the dashboard. Aimdoc will then reject every unsigned identify call.

Roll this out in order:

  1. Generate and securely store the secret.
  2. Deploy server-side signature generation.
  3. Verify your application sends valid hashes.
  4. Enable enforcement.

Invalid hashes are always rejected, regardless of the enforcement setting.

Rotate the secret

Choose Rotate in the Identity Verification settings when you need to replace a secret. Rotation immediately invalidates signatures generated with the previous secret.

Deploy the new secret to your backend promptly after rotation. Identify calls using the old secret will fail until your server generates hashes with the new one.

Was this page helpful?