Skip to content

Read the identity map

The identity map says which records are the same person. It lists every record Samesake holds, with its personId, and the person IDs that have been retired by a merge. You read it through an OData feed, which Power BI, Excel and Azure Data Factory can use directly, or a script can read with any HTTP client.

The feed is read-only and holds IDs only: no names, email addresses or other personal details. It covers all of your sources.

Feed URL https://app.samesake.co.uk/odata
Standard OData version 4, JSON
Authentication Basic, with a feed key
Entity sets IdentityMap, PersonRedirects
Page size 5,000 rows, then a next link
Rate limit 120 requests a minute for each feed key

The metadata document at https://app.samesake.co.uk/odata/$metadata describes both entity sets and their fields. Tools such as Power BI read it for you.

A feed key is a user name and a password, issued by an admin. It can read the feed and nothing else.

  1. Sign in at app.samesake.co.uk as an admin, then open Admin and Sources.
  2. In the Feed keys card, give the key a name that says what uses it, such as Power BI finance, and choose Issue feed key.
  3. Copy the feed URL, the user name and the password. The password is shown once: Samesake keeps only a scrambled copy.

The user name is fk_ followed by eight letters and digits, and the password is 32 letters and digits. Use one key for each tool or report, so you can revoke one without stopping the rest. To revoke a key, choose Revoke beside it on the same page. The feed refuses it from the next request. The page shows when each key was last used, and marks one that is over a year old.

Feed keys and API keys are separate. A feed key can’t send records, and an API key can’t read the feed. A request with a missing, wrong or revoked feed key, or with an API key, gets 401 Unauthorized with a WWW-Authenticate: Basic header, which is what makes Power BI and Excel ask for credentials.

Send the user name and password as Basic authentication. With curl, that is -u:

Terminal window
curl -u fk_abcd1234:0123456789abcdefghjkmnpqrstvwxyz \
'https://app.samesake.co.uk/odata/IdentityMap?$top=2'

The key in that example is a placeholder. Use the one Samesake gave you. The single quotes stop the shell treating $top as a variable.

Response: 200 OK
{
"@odata.context": "https://app.samesake.co.uk/odata/$metadata#IdentityMap",
"value": [
{
"RecordId": "rec_3n8p2w6c9r4d",
"PersonId": "per_5d9h2k7m3x8q",
"SourceSystemCode": "SHOP",
"SourceRecordId": "C-58210",
"Status": "Active",
"ModifiedAt": "2026-10-14T09:30:12.4172938+00:00"
},
{
"RecordId": "rec_7k2m9xq4ht3b",
"PersonId": "per_5d9h2k7m3x8q",
"SourceSystemCode": "CRM",
"SourceRecordId": "CRM-10042",
"Status": "Active",
"ModifiedAt": "2026-10-14T09:12:40.0815226+00:00"
}
]
}

One row for each record in each of your sources. The key is RecordId.

Field Type What it holds
RecordId Text Samesake’s ID for the record, such as rec_7k2m9xq4ht3b. It never changes.
PersonId Text The person the record belongs to, such as per_5d9h2k7m3x8q. Empty until matching has looked at the record. It can change when people are merged or split: see Concepts.
SourceSystemCode Text The code of the source, such as CRM.
SourceRecordId Text Your own ID for the record in that source.
Status Text in the JSON Active, or Deleted if the source deleted the record. (OData defines it as an enumeration. This page doesn’t cover filtering on it in the address: load the rows and filter in your tool.) A deleted record stays in the feed, so a report can see that it has gone.
ModifiedAt Date and time with offset When the row last changed: the later of the source’s last change to the record (a delete counts) and the last time matching changed its PersonId.

The date and time are ISO 8601 with an offset, such as 2026-10-14T09:30:12.4172938+00:00. The exact digits can differ: there can be fractional seconds, or none, and the offset can be written another way, such as Z. Read it with a date and time parser rather than by position.

Because ModifiedAt moves when matching changes a PersonId, as well as when your system sends a change, it is the field to use for incremental refresh.

One row for each person ID that a merge has retired. The key is RetiredPersonId.

Field Type What it holds
RetiredPersonId Text A person ID that no longer identifies anyone.
CurrentPersonId Text The person ID that replaced it. Always the current one, in a single step, never along a chain.
RetiredAt Date and time with offset When the ID was retired, or when the redirect was last re-pointed at a newer ID.
Terminal window
curl -u fk_abcd1234:0123456789abcdefghjkmnpqrstvwxyz \
'https://app.samesake.co.uk/odata/PersonRedirects'
Response: 200 OK
{
"@odata.context": "https://app.samesake.co.uk/odata/$metadata#PersonRedirects",
"value": [
{
"RetiredPersonId": "per_8c4t6w2n5y7f",
"CurrentPersonId": "per_5d9h2k7m3x8q",
"RetiredAt": "2026-10-14T09:31:05.6630114+00:00"
}
]
}

A report that stored a PersonId can look it up in RetiredPersonId and use CurrentPersonId instead. A redirect isn’t always permanent: if an unmerge brings a retired ID back into use, its redirect is removed. So load this list in full each time, instead of only adding new rows. It’s small beside the identity map.

The feed accepts these OData options, and no others:

Option Use
$select Return only the fields you list, comma-separated: $select=RecordId,PersonId.
$filter Return only rows that match: $filter=SourceSystemCode eq 'CRM'. Any field can be filtered, using OData’s usual operators such as eq, ne, gt, ge, lt, le, and and or.
$orderby Sort by a field: $orderby=ModifiedAt desc.
$top At most this many rows, up to 5,000. A larger number is refused.
$skip Skip this many rows. To read everything, follow the next links instead.
$count $count=true adds @odata.count, the number of rows that match the filter.
$skiptoken Used by the next links. You don’t write it yourself.

Anything else, such as $expand, $apply or $search, and a filter or sort on a field that doesn’t exist, is refused with 400 Bad Request and a short message:

Response: 400 Bad Request
{ "error": { "code": "BadRequest", "message": "The query isn't valid." } }

Field names are case-sensitive: RecordId, not recordId. Put a date and time in a filter without quotes, as 2026-10-14T00:00:00Z. Write the time in UTC with a Z: a + offset has to be URL-encoded. Quote text with single quotes. If you build the URL by hand, write spaces as %20:

Terminal window
curl -u fk_abcd1234:0123456789abcdefghjkmnpqrstvwxyz \
'https://app.samesake.co.uk/odata/IdentityMap?$filter=ModifiedAt%20ge%202026-10-14T00:00:00Z&$select=RecordId,PersonId,Status'

A response holds at most 5,000 rows. If there are more, it ends with an @odata.nextLink: the full address of the next page, which carries a $skiptoken and the options you asked for.

Response: 200 OK
{
"@odata.context": "https://app.samesake.co.uk/odata/$metadata#IdentityMap(RecordId)",
"value": [
{ "RecordId": "rec_000a2b3c4d5e" },
{ "RecordId": "rec_000f6g7h8j9k" }
],
"@odata.nextLink": "https://app.samesake.co.uk/odata/IdentityMap?$select=RecordId&$skiptoken=..."
}

That example is cut short: a real page that has a next link holds 5,000 rows. Request the next link exactly as it is, with the same credentials, and keep going until a response has no @odata.nextLink. Don’t build the token yourself, and don’t change it.

Rows come back in RecordId order unless you ask for another with $orderby. The next links page by the last row seen, not by a row count, so a record that arrives while you’re reading doesn’t push the rows you haven’t read off the end of a page or repeat one you have. Power BI, Excel and Data Factory follow next links without being asked. If you read the feed yourself, follow them in a loop:

import time
import requests
session = requests.Session()
session.auth = ("fk_abcd1234", "0123456789abcdefghjkmnpqrstvwxyz")
url = "https://app.samesake.co.uk/odata/IdentityMap?$filter=ModifiedAt ge 2026-10-14T00:00:00Z"
rows = []
while url:
response = session.get(url, timeout=90)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", "5")))
continue
response.raise_for_status()
page = response.json()
rows.extend(page["value"])
url = page.get("@odata.nextLink")

Reading all of a large identity map every time is slow. After the first full read, read only what has changed: remember when you last read, and ask for the rows whose ModifiedAt is at or after that time.

IdentityMap?$filter=ModifiedAt ge 2026-10-14T00:00:00Z

ModifiedAt covers a change to the record, a delete, and a change to its PersonId, so the rows you get cover everything that could change a report. Use ge and start a little before the time you last read, so a row that changed in the same instant isn’t missed. Reading a row twice does no harm.

A row you read again replaces the row you held: key your copy on RecordId. If you only ever add rows, a record that changed twice is in your copy twice, with its old PersonId and its new one. Read the identity map in Power BI explains what that means for Power BI’s incremental refresh.

For PersonRedirects, read the whole list each time, as above.

Limit Value
Rows in a response 5,000 at most, with a next link for the rest
$top 5,000 at most
Requests 120 a minute for each feed key, counted over a fixed minute

Every page you fetch is a request, so a full read of 600,000 rows is 120 requests. Beyond the limit, the feed answers 429 Too Many Requests with a Retry-After header: the number of seconds to wait, up to 60. Other feed keys aren’t affected, and the limit is separate from the API’s 600 requests a minute.

What the feed shows follows matching. A record you’ve just sent has no PersonId until matching has run, and a merge or split appears when it has been applied. It isn’t a live view of your sources, so give it a little while after a change.

  • Personal details. There are no names, email addresses, dates of birth, addresses or phone numbers. To read those, use the API with a source’s key, one record at a time.
  • Writing. The feed can’t change anything.
  • Other accounts. A feed key reads only its own account’s records.