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.
Get a feed key
Section titled “Get a feed key”A feed key is a user name and a password, issued by an admin. It can read the feed and nothing else.
- Sign in at app.samesake.co.uk as an admin, then open Admin and Sources.
- 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. - 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.
Read it
Section titled “Read it”Send the user name and password as Basic authentication. With curl, that is -u:
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.
{ "@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" } ]}The two entity sets
Section titled “The two entity sets”IdentityMap
Section titled “IdentityMap”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.
PersonRedirects
Section titled “PersonRedirects”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. |
curl -u fk_abcd1234:0123456789abcdefghjkmnpqrstvwxyz \ 'https://app.samesake.co.uk/odata/PersonRedirects'{ "@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.
Query options
Section titled “Query options”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:
{ "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:
curl -u fk_abcd1234:0123456789abcdefghjkmnpqrstvwxyz \ 'https://app.samesake.co.uk/odata/IdentityMap?$filter=ModifiedAt%20ge%202026-10-14T00:00:00Z&$select=RecordId,PersonId,Status'Paging
Section titled “Paging”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.
{ "@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")Incremental refresh
Section titled “Incremental refresh”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:00ZModifiedAt 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.
Limits
Section titled “Limits”| 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.
What the feed doesn’t have
Section titled “What the feed doesn’t have”- 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.