Most multi-accounting questions boil down to one sentence: “this device has already created three accounts.” Prynt answers it directly. Every event you fetch server side carries an accountsOnDevice object listing the accounts (your own user ids) that have ever been attached to the device behind that request.
This post covers what is in the field, where the data comes from, the failure mode that leaves it empty, and how to turn it into a one-trial-per-device rule that doesn’t punish families sharing a laptop.
What the field looks like
Fetch an event with your secret key, GET /v1/events/{requestId}, and you get:
{
"requestId": "req_…",
"visitorId": "vst_…",
"decision": "allow",
"riskScore": 12,
"linkedId": null,
"accountsOnDevice": {
"count": 2,
"truncated": false,
"accounts": [
{ "linkedId": "user_8812", "events": 14,
"firstSeenAt": "2026-09-30T18:02:11Z", "lastSeenAt": "2026-10-06T09:40:57Z" },
{ "linkedId": "user_7310", "events": 3,
"firstSeenAt": "2026-09-12T21:15:03Z", "lastSeenAt": "2026-09-13T08:01:44Z" }
]
}
}
Field by field:
countis the number of accounts listed.truncatedistruewhen the device holds more accounts than the list shows. The list is capped at 20, so a truncated device has at least 20 accounts. Treatcountas a floor in that case. In practice, any device that reaches the cap is almost never a legitimate user.accountsis sorted by most recent activity. For each account:linkedIdis your id, exactly as you sent it.eventsis how many identifications carried that account on this device.firstSeenAt/lastSeenAtbracket the period that account was active there.
The list is scoped to the environment of the key you use. A test key never sees live accounts, and the reverse is also true.
Where the accounts come from
Prynt does not know your users. An account appears on a device only when you attach your id to an event from that device, in one of two ways.
After signup, from your server. The browser identifies before the account exists, so there is no id yet. Once you create the user, attach it:
import { PryntServer } from '@prynt/node';
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY });
const event = await prynt.getEvent(req.body.pryntRequestId);
// ...policy check, then create the user...
await prynt.updateEvent(req.body.pryntRequestId, { linkedId: String(user.id) });
The PUT /v1/events/{requestId} response includes the updated accountsOnDevice, so you can log the device’s new total right away.
After login, from the browser. Pass the signed-in user’s id to identify():
await prynt.identify({ linkedId: currentUser.id, tag: { action: 'login' } });
The first path tells you which device created the account. The second tells you which devices an account has used since. Use both. An account created on a phone and later used on the laptop that already holds four others should appear in that laptop’s list too.
The empty-list failure mode
The most common integration bug is skipping the link step. Everything looks fine. Events come back, visitorId is stable, and count is always 0, because nothing was ever attached. Three things to check:
- Is the
PUTrunning? It often lives in a code path that only some signups reach, such as email-password but not OAuth. Log its response. - Is the id stable? Linking with an email one day and a database id the next creates two “accounts” for one person. Pick an id that never changes.
- Is it the same environment? Linking with a staging key and checking with a live key returns nothing.
There is one more subtle case. The device’s own linkedId on the visitor record is set once and never replaced, but accountsOnDevice is built from every linked event. Don’t use visitor.linkedId as your multi-account signal. It only tells you who was first.
From count to policy
The simplest rule is one trial per device: if any other account exists, don’t grant a new trial.
function otherAccounts(aod, selfId) {
const others = aod.accounts.filter((a) => a.linkedId !== selfId).length;
return aod.truncated ? Math.max(others, aod.accounts.length) : others;
}
const others = otherAccounts(event.accountsOnDevice, null); // user not created yet
if (event.decision === 'block' || others >= 2) return refuse();
const trialEligible = others === 0;
A few refinements work better in practice than the bare count.
Separate “account” from “trial.” Letting a second account exist with no trial credits is much safer than refusing it. Real people do create a work account and a personal one. Degraded free tiers covers that pattern.
Use recency. lastSeenAt lets you ignore accounts nobody has touched in a year. A shared family computer collects accounts over time, while a farmer collects them in an afternoon. Ten accounts with firstSeenAt values a few minutes apart is a very different story from three accounts spread over two years.
Weight by activity. An account with events: 1 was created and abandoned. An account with dozens of events is someone’s real workspace. Several one-event accounts in a row is the classic shape of trial farming.
Account for the self-check. When you evaluate after the account exists, for example in a webhook or a post-login hook, filter out the account itself, or every user will count as their own duplicate.
The ready-made recipes for Express, Clerk, Supabase and Auth0 share one policy module that handles the self-exclusion and the truncated case for you; recency and activity weighting are refinements you add on top. It blocks by default when the device already has 2 other accounts. The device-based signup limits guide explains how to pick your own number.
accountsOnDevice vs the risk signals
Prynt also computes behavior signals from the same history, and they answer different questions:
| What it is | Use it for | |
|---|---|---|
accountsOnDevice | Raw list of every linked account, all time | Your own signup policy |
multiAccount / distinctAccounts30d | 2+ distinct accounts on the device within 30 days | Risk score, rules, the MULTI_ACCOUNT reason code |
accountSharing | More than 3 accounts on the device within 24 hours | Bursts, credential stuffing, ACCOUNT_SHARING |
Rules in the console can use multiAccount and distinctAccounts30d directly, for example distinctAccounts30d gte 3 → challenge, without any code. accountsOnDevice is for when you want the exact list in your own code: to show it to a reviewer, or to make an exception for a known household.
Beyond one device
A count per device catches one person with one laptop. Organized farms spread accounts across many devices, and that takes a different view. Accounts that share devices with each other form clusters, which is what an identity graph maps. The multi-accounting detection guide covers both layers, and you can see a device’s visitorId hold steady across incognito windows in the playground.
Start by logging accountsOnDevice.count on every signup for a week without acting on it. The distribution will show you where your limit should sit, and it will also confirm that your link step is actually running.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.