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.
Identity verification applies to App Mode on the Activate plan — the
in-app identify path for signed-in users in your product. It does not
apply to anonymous website visitors or website-agent enrichment. For website
enrichment, see Visitor Identification.
How it works
- Your backend computes an HMAC-SHA256 signature using your Aimdoc identity verification secret.
- Your frontend passes that signature as
user_hashtoaimdoc.identify. - Aimdoc normalizes the submitted identity fields, recomputes the signature, and rejects the request if any canonical signed value was changed.
Compute signatures on your server. Never include the identity-verification secret in browser code, environment variables exposed to the browser, or SDK payloads.
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:
- v2 —
accountwithoutstageor objectives - v3 —
accountincludes a lifecyclestage - v4 — person-level and/or account-level objective claims
are present (with or without
account)
Configure a signing secret
In the Aimdoc dashboard:
- Open Integrations.
- Select Identity Verification.
- Choose Generate signing secret.
- 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:
- Generate and securely store the secret.
- Deploy server-side signature generation.
- Verify your application sends valid hashes.
- 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.