Block repeat respondents inside a time window with days_since_last_match
Last updated: September 9, 2026
The multiple_accounts score tells you a person has other accounts on your platform, while the linked_accounts array tells you when those other accounts were last active. Combining the two lets you block someone who came back under a new account inside the current window while still letting through a legitimate panelist who last participated months ago. This doc defines a days_since_last_match field, walks through one example, and shows two ways to implement it: directly in code, and inside Qualtrics using embedded data, one math operation, and branch logic, with nothing outside Qualtrics required.
The rule: block a multi-accounter only when a strong linked account was seen inside the window
Block when the account looks like a multi-accounter AND one of its strong linked accounts was seen recently. In pseudocode:
strong_links = entries in linked_accounts with score > S,
excluding the current account's own id
days_since_last_match = days between now and the MOST RECENT last_seen among strong_links
(blank if there are zero strong links)
block = multiple_accounts > M AND days_since_last_match < t
Three values that you set thresholds for:
Placeholder | Meaning | Example value used below (recommend) |
|---|---|---|
| Minimum linked-account score to count as the same person | 0.4 |
| Minimum | 0.4 |
| Window in days inside which a returning person counts as a duplicate | 30 |
Four details of the rule matter in practice:
Use the most recent
last_seenacross all strong links, because a person with two strong links is active as recently as the newer of the two, and the highest-scored link is often the older one.Exclude the current account's own id from the scan, which protects against a response that lists the account as linked to itself.
Comparisons are strict, so a score exactly equal to
SorMis below the threshold and exactlytdays is allowed through, and days are kept fractional so a respondent at 29.6 days is inside a 30-day window.Finally,
last_seenis the last time Verisoul saw that linked account in your project, which is account activity of any kind (a survey completion is one example), and it moves forward each time the account shows up.
Rolling window, or fixed wave. This rule checks the past t days from the moment of the check, which is a rolling window. If the requirement is one response per fixed wave (one per calendar month, say), set t to the wave length as an approximation, and accept that a person active on the 28th of one wave is blocked on the 2nd of the next. Enforcing fixed waves exactly means checking participation in that specific wave, which needs your own completion records alongside this rule.
An example: a link seen 7 days ago gets blocked, the same link seen 183 days ago passes
Take a session for account john-doe where the API returns multiple_accounts of 0.82 and this linked_accounts array (trimmed to the relevant fields), evaluated on 2025-12-10:
"multiple_accounts": 0.82,
"linked_accounts": [
{
"account_id": "john-doe-2",
"score": 1,
"last_seen": "2025-12-03T08:21:14.902Z",
"match_type": ["browser", "network", "email", "phone", "username"]
},
{
"account_id": "alice-2",
"score": 0.17,
"last_seen": "2025-12-15T19:45:52.330Z",
"match_type": ["network"]
}
]
Applying the rule with S = 0.4, M = 0.5, t = 30:
Strong links:
john-doe-2(score 1.0) qualifies, whilealice-2(0.17) is a weak network-only match and is ignored, even though itslast_seenis more recent.days_since_last_match= days between 2025-12-10 and 2025-12-03 = 7.multiple_accounts0.82 > 0.5 and 7 < 30, so this respondent is blocked as a within-window duplicate.
If john-doe-2 had been last seen on 2025-06-10, days_since_last_match would be 183, which is above the 30-day window, and the respondent would pass even with a multiple_accounts score of 0.82. This is what the field adds: the multi-accounting score alone would block both cases, while the window separates a repeat inside this window from a panelist returning from an earlier one.
Method 1: compute the field in code from the API response
Call the Verisoul API as you already do, then compute the field from the response. The snippets assume a successful response; handle request failures with your application's existing error policy (see the fail-open discussion under Method 2, which applies equally here).
Python
from datetime import datetime, timezone
LINK_SCORE_MIN = 0.4 # S
MULTI_ACCOUNT_MIN = 0.5 # M
WINDOW_DAYS = 30 # t
def days_since_last_match(response, current_account_id, link_score_min=LINK_SCORE_MIN):
strong = [
a for a in response.get("linked_accounts", [])
if a["account_id"] != current_account_id and a["score"] > link_score_min
]
if not strong:
return None
latest = max(datetime.fromisoformat(a["last_seen"].replace("Z", "+00:00")) for a in strong)
return (datetime.now(timezone.utc) - latest).total_seconds() / 86400
def should_block(response, current_account_id):
days = days_since_last_match(response, current_account_id)
return (
response["multiple_accounts"] > MULTI_ACCOUNT_MIN
and days is not None
and days < WINDOW_DAYS
)
JavaScript / Node
const LINK_SCORE_MIN = 0.4; // S
const MULTI_ACCOUNT_MIN = 0.5; // M
const WINDOW_DAYS = 30; // t
function daysSinceLastMatch(response, currentAccountId, linkScoreMin = LINK_SCORE_MIN) {
const strong = (response.linked_accounts || []).filter(
a => a.account_id !== currentAccountId && a.score > linkScoreMin
);
if (strong.length === 0) return null;
const latest = Math.max(...strong.map(a => Date.parse(a.last_seen)));
return (Date.now() - latest) / 86400000;
}
function shouldBlock(response, currentAccountId) {
const days = daysSinceLastMatch(response, currentAccountId);
return response.multiple_accounts > MULTI_ACCOUNT_MIN && days !== null && days < WINDOW_DAYS;
}
Store days_since_last_match, the thresholds, and the decision alongside the session, so t and M can be re-evaluated later on historical data. Changing S retroactively needs the original linked_accounts array, so store that too if you expect to tune it.
Method 2: build it in Qualtrics with embedded data, one math operation, and two branches
Qualtrics can do everything above with Survey Flow elements plus one short question script, which is required because Qualtrics math operations only work on numbers and last_seen arrives as an ISO date string. The script picks the most recent strong link and converts its timestamp; Qualtrics computes the days and applies the rule. The flow ends up looking like this:
Embedded Data: thresholds and defaults (lookup_status = error)
Web Service: existing Verisoul call, now also mapping linked-account fields
Block 1: first page, question script selects the most recent strong link
Embedded Data: now_epoch from the Qualtrics server clock
Branch: lookup_status = ok AND has_qualifying_match = 1
Embedded Data: math operation sets days_since_last_match
Branch: lookup_status = ok AND multiple_accounts > M AND days_since_last_match < t
End of Survey (screen-out)
Remaining blocks
Step 1: thresholds and defaults live in one Embedded Data element at the top of the flow
Create these fields at the start of Survey Flow, ahead of the Web Service element, so that a survey admin can change a threshold in one place and so that fields written by the script below are saved with the response. Set the types as shown, since branch comparisons on text fields can misbehave.
Field | Type | Initial value |
|---|---|---|
| Number | Your |
| Number | Your |
| Number | Your |
| Text | The account id you send to Verisoul (usually already an embedded data field) |
| Text |
|
| Number |
|
| Number | blank |
| Number | blank |
| Number | blank |
Starting lookup_status at error means a failed or skipped Verisoul call is recorded as such, since only the script sets it to ok.
Step 2: the Web Service element maps the first five linked accounts into embedded data
In the existing Web Service element that calls Verisoul, add these response mappings. Qualtrics uses dot notation with a numeric index for arrays, and linked_accounts comes back highest score first, so five positions cover essentially every real case (a respondent with more than five strong links is a different kind of problem and the top five still decide correctly whenever the most recent strong link is among them).
Response path | Embedded data field | Type |
|---|---|---|
|
| Number |
|
| Text |
|
| Number |
|
| Text |
|
| same |
Use Test URL → Add Embedded Data to confirm the exact path syntax Qualtrics generates for your response, then type the remaining positions by hand, because Test URL only lists positions present in the test response. Positions absent from a live response map to blank, which the script treats as "no more links." Leave Fire and Forget unchecked so the response is saved before the survey continues.
Step 3: a short question script selects the most recent strong link and converts its timestamp
Put an always-shown introductory question in its own block immediately after the Web Service element, and add this to the question's JavaScript editor. The fields it writes become available to Survey Flow elements once the respondent leaves the block.
Qualtrics.SurveyEngine.addOnload(function () {
var Q = Qualtrics.SurveyEngine;
var blank = function (v) { return v == null || String(v).trim() === ""; };
var S = Number(Q.getEmbeddedData("link_score_threshold"));
var currentId = String(Q.getEmbeddedData("current_account_id") || "");
var latest = null;
// A blank overall score means the Verisoul call failed; leave lookup_status = error.
if (blank(Q.getEmbeddedData("multiple_accounts"))) return;
for (var i = 1; i <= 5; i++) {
var id = Q.getEmbeddedData("linked_" + i + "_id");
var score = Number(Q.getEmbeddedData("linked_" + i + "_score"));
var seen = Date.parse(Q.getEmbeddedData("linked_" + i + "_last_seen"));
if (blank(id)) continue; // empty position
if (id === currentId || !(score > S)) continue; // self-match or weak link
if (isFinite(seen)) latest = latest === null ? seen : Math.max(latest, seen);
}
Q.setEmbeddedData("has_qualifying_match", latest === null ? 0 : 1);
Q.setEmbeddedData("latest_match_epoch", latest === null ? "" : latest / 1000);
Q.setEmbeddedData("lookup_status", "ok");
});
The script reads S from the embedded data field, so changing the threshold in Step 1 changes the scan too. It writes latest_match_epoch in seconds since 1970, which is the form the math operation can subtract from. The snippet uses the classic getEmbeddedData / setEmbeddedData interface; if the survey uses the Simple Layout experience, confirm those calls still read and write in Preview before relying on it.
Step 4: the current time comes from the Qualtrics server, and one math operation produces the days
The current time should come from the Qualtrics server, because a respondent's device clock set forward would turn a block into an allow. After the introductory block, add an Embedded Data element that sets:
now_epoch = ${date://CurrentDate/c?format=U}
c?format=U gives the current time as a Unix timestamp in seconds. Then add a Branch with the conditions lookup_status Is Equal To ok and has_qualifying_match Is Equal To 1, and inside it an Embedded Data element that sets:
days_since_last_match = $e{ ( e://Field/now_epoch - e://Field/latest_match_epoch ) / 86400 }
Field references inside $e{ ... } are written unwrapped, as in the Qualtrics math operations documentation, while ordinary piped text elsewhere in the flow keeps its ${...} wrapper. Keep the result fractional, since rounding can flip a decision at the boundary. Respondents outside the branch keep days_since_last_match blank, which makes the block condition in the next step false.
Step 5: a single branch screens out anyone who is both a multi-accounter and inside the window
Below the calculation, add a Branch requiring all four conditions:
Field | Comparison | Value |
|---|---|---|
| Is Equal To |
|
| Is Equal To |
|
| Is Greater Than |
|
| Is Less Than |
|
Inside the branch, place an End of Survey element with the screen-out message (a custom screen-out question can sit inside the branch ahead of it). The main survey blocks follow the branch, so everyone else continues. Because M and t are piped from the Step 1 fields, tuning them later touches one element.
Step 6: decide what happens when the lookup fails, and default to fail-open
As built, a respondent whose Verisoul call failed keeps lookup_status = error, skips both branches, and continues into the survey with the status saved on the response for later filtering. That is a fail-open policy, and it is the right default for most surveys, where a missed duplicate costs less than a lost legitimate response. If a completed check is required, add a Branch ahead of the main blocks with lookup_status Is Not Equal To ok that ends the survey or routes to a retry message. Choose the policy before launch and record it in the survey notes.
Testing: run these cases in Preview before fielding
Add a temporary question after the math that displays ${e://Field/lookup_status}, ${e://Field/has_qualifying_match}, ${e://Field/days_since_last_match}, and ${e://Field/multiple_accounts}, run each case through Preview and once through the published link, and remove the question before fielding.
Scenario | Expected result |
|---|---|
| Block |
| Allow |
| Allow |
Link score exactly S | Link ignored |
Successful lookup, zero strong links | Allow, |
Older high-score link plus newer lower-score link above S | Newer link decides |
Recent link below S | Link ignored |
Current account's own id appears in the list | Excluded |
Web Service call fails | Status |
Start with t equal to the wave length, then tighten S if shared devices cause false positives
Set t to the wave length for the first wave and review the screened-out respondents before trusting the rule, because the false-positive risk sits in the pairing of a moderate multiple_accounts score with a strong link that belongs to a household member or a shared device. If that shows up, raising S to 0.6 or counting only links whose match_type includes email or phone tightens the rule. The rule screens incoming responses only, so previously recorded duplicates stay in the data until you filter them using the saved days_since_last_match field.