Integrate a custom app (API push)
A custom app connection is SlashID's source-agnostic connector for an application SlashID has no dedicated adapter for — an in-house tool, a legacy system, or anything else that isn't one of the providers listed elsewhere in this section. You describe the application's identities, groups, roles, resources, and who has access to what in one documented JSON package format, and push it to SlashID. SlashID materializes it into the identity graph exactly like any other connected source: identities show up for review, access shows up as relationships, and access reviews can target it.
This page covers the API push path: you (or a script, or a pipeline) build the package and send it to SlashID over HTTP whenever you have new data. If you have CSV exports rather than a script, the dashboard has a guided wizard for the same package format: Data sources → Add data source → Connect any app → Upload CSV exports. It profiles each file, proposes a column mapping, shows a preview of what will be imported and saves the mapping for the next upload.
Before starting
Before starting, ensure you have:
- An org API key with permission to manage data source connections
- A way to produce the package described in Step 2 — a script,
an export job, or anything that can write JSON and shell out to
tar/gzip - Network access from wherever you run the push to
https://api.slashid.com
Step 1: Create the connection
Create the connection with POST /ip/nhi/connections, setting source to custom_app and
credentials.ingestion_mode to api:
POST /ip/nhi/connections
SlashID-OrgID: <your-org-id>
SlashID-API-Key: <your-org-api-key>
Content-Type: application/json
{
"source": "custom_app",
"name": "Legacy Payroll",
"credentials": {
"app_name": "Legacy Payroll",
"app_kind": "legacy",
"ingestion_mode": "api"
}
}
| Field | Description |
|---|---|
credentials.app_name | Required. Display name of the application |
credentials.app_kind | Required. One of saas, internal, database, legacy, other |
credentials.app_url | Optional URL of the application |
credentials.ingestion_mode | Required. Set to api for the push flow on this page |
The response echoes back a credentials.app_key — a stable, immutable identifier SlashID mints
for the application. It ignores any app_key you send; you never choose it yourself. Keep it
around for reference, but nothing in the package format below needs to know it. The response
also carries push_auth_token, the bearer token Step 4 pushes packages with:
{
"result": {
"id": "0f6a...",
"name": "Legacy Payroll",
"push_auth_token": "sidnhi_...",
"credentials": {
"app_key": "legacy-payroll-4f8a2c",
"app_name": "Legacy Payroll",
"app_kind": "legacy",
"ingestion_mode": "api",
"setup_state": "active"
}
}
}
For the api ingestion mode, setup_state is active immediately — there is no draft/resume
step to complete. A csv connection stays draft (shown as "Setup incomplete · Resume" in the
Data sources list) until the wizard's first import commits.
Step 2: Build the package
A package is a gzip-compressed tar archive with flat entry names — no directories — where
every entry is one complete JSON document (arrays and objects, not one-object-per-line). Every
file except _SID_META.json and app.json is optional; omit any you have nothing to send for.
| File | Required | Contents |
|---|---|---|
_SID_META.json | Yes | The package envelope (below) |
app.json | Yes | The application itself: name, kind, URL, owners |
identities.json | No | Users and service accounts |
groups.json | No | Groups |
roles.json | No | Roles |
resources.json | No | The things your app protects — databases, cost centers, modules, whatever your app calls them |
memberships.json | No | Which identities and groups belong to which groups |
grants.json | No | Who has which role, and/or access to which resource |
Throughout the package, a reference to an identity is either {"id": "..."} (an id used
elsewhere in the package) or {"email": "..."}. An owner reference is the same, plus an
optional "type": "business" | "technical".
Every entity may carry attributes: a flat map of string keys to string values, surfaced as
metadata in SlashID. Keys must match [a-z0-9_]{1,64}, and each entity may carry at most 50.
_SID_META.json
{
"source": "custom_app",
"format": "snapshot_dump",
"version": 1
}
Fixed for this package version — copy it as-is.
app.json
| Field | Required | Description |
|---|---|---|
name | Yes | Display name of the application |
kind | No | Free-text kind, e.g. saas, internal |
url | No | URL of the application |
owners | No | List of owner references |
{
"name": "Legacy Payroll",
"kind": "legacy"
}
identities.json
| Field | Required | Description |
|---|---|---|
id | Yes | Unique within this file |
type | Yes | user or service_account |
display_name | Yes | — |
email | No | — |
username | No | — |
status | No | active (default) or disabled |
manager | No | A reference to another identity, by id (must exist in this file) or email. Recorded as metadata for context only — it does not create a relationship in the access graph |
attributes | No | Flat string map |
[
{ "id": "ann", "type": "user", "display_name": "Ann Lee", "email": "[email protected]" },
{ "id": "svc-etl", "type": "service_account", "display_name": "ETL bot" }
]
groups.json
| Field | Required | Description |
|---|---|---|
id | Yes | Unique within this file |
display_name | Yes | — |
description | No | — |
parent_ids | No | Ids of groups this group is nested under (no cycles) |
owners | No | — |
attributes | No | — |
[{ "id": "fin", "display_name": "Finance" }]
roles.json
| Field | Required | Description |
|---|---|---|
id | Yes | Unique within this file |
display_name | Yes | — |
description | No | — |
privileged | No | Marks the role as privileged |
owners | No | — |
attributes | No | — |
[{ "id": "admin", "display_name": "Admin", "privileged": true }]
resources.json
| Field | Required | Description |
|---|---|---|
id | Yes | Unique within this file |
kind | Yes | Your own vocabulary — Database, Cost center, anything you use |
display_name | Yes | — |
parent_id | No | Id of a parent resource (no cycles). Omitted → nested directly under the application |
owners | No | — |
attributes | No | — |
[
{
"id": "payroll-db",
"kind": "Database",
"display_name": "Payroll DB",
"owners": [{ "id": "ann", "type": "business" }]
}
]
memberships.json
| Field | Required | Description |
|---|---|---|
member.type | Yes | identity or group |
member.id | Yes | — |
group_id | Yes | — |
[{ "member": { "type": "identity", "id": "ann" }, "group_id": "fin" }]
grants.json
| Field | Required | Description |
|---|---|---|
principal.type | Yes | identity, group, or role |
principal.id | Yes | — |
role_id | No | — |
resource_id | No | — |
permission | No | Free text — your app's own permission name (see below) |
At least one of role_id / resource_id is required. A role-typed principal may only target
a resource_id — a role cannot be granted another role.
[
{
"principal": { "type": "identity", "id": "ann" },
"role_id": "admin",
"resource_id": "payroll-db",
"permission": "write"
}
]
Step 3: How grants become access
| In the package | What shows up in SlashID |
|---|---|
A grant naming only a role_id | The principal (identity, group, or role) has that role |
A grant naming only a resource_id (with an optional permission) | The principal can access that resource, with the given privilege |
A grant naming both role_id and resource_id | The principal has the role, and can directly access that resource — see below for why this is a direct relationship, not routed through the role |
A grant whose principal is a role, targeting a resource_id | Defines what the role itself grants: everyone who holds it, unscoped, can reach the resource through it |
A memberships.json row, or a group's parent_ids | The member (identity or group) is a member of the group; nested groups work the same way |
A resource's parent_id (or none) | The resource is nested under its parent resource — or, when there's no parent, directly under the application |
An owner listed by id | That identity owns the app, group, role, or resource, tagged business, technical, or left unspecified |
An owner listed only by email | Resolved after each sync against the identities your other connections carry: a match becomes an owner link to that identity, an email nobody carries is recorded as an owner by display name (so you can see it and fix the package), and owners dropped from your next package are removed. Owners you add by hand are never touched. |
Why a scoped grant (both role_id and resource_id) becomes a direct relationship, not a
role-to-resource one: say Ann is Admin on Database 1 and Bob is Admin on Database 2. If
"Admin" were one role pointing at both databases, Ann would appear to have access to Database 2
as well — she doesn't. So each scoped grant instead becomes a direct access relationship from
the principal straight to that one resource, tagged with which role it came through. A review of
Ann's access, or of who can reach Database 2, is always exact.
Permission text
The permission value you send in grants.json (or, when a grant names a role but no
permission, the role's own display name) is normalized to one of six buckets SlashID uses
consistently across every connected source:
| Bucket | Matches words like |
|---|---|
admin | admin, administrator, owner, full, manage, management |
delete | delete, remove, purge |
write | write, edit, update, create, modify, contribute |
share | share, grant, invite |
read | read, view, list, get, audit |
access | anything else — an opaque code like RW is never guessed |
Matching is case-insensitive, on whole words, checked in the order above — so "read/write"
resolves to write, never read. Send your application's own permission strings as-is; SlashID
stores them verbatim as well as using them to pick the bucket.
Relationship properties
Several grants (or owners) can land on the same relationship — e.g. the same role held with two
scopes, or two grants that both normalize to read. SlashID merges them into one relationship
carrying:
| Appears on | Property | Meaning |
|---|---|---|
| Access relationships | privileges | Sorted, de-duplicated list of the privilege names granted. Omitted entirely when there are none |
| Access relationships | via_roles | Sorted, de-duplicated list of role ids that contributed this access. Omitted when none did |
| Access relationships | direct | Always present. true when at least one grant gave this access without going through a role |
| "Has role" relationships | permission_scopes | Sorted, de-duplicated list of resource ids the role is held on. Omitted when the role isn't scoped to any |
| "Has role" relationships | unscoped | Always present. true when the role is also held without being scoped to any specific resource |
| Ownership relationships | ownership_type | business, technical, or unspecified |
direct: true alongside via_roles: ["admin"] is what keeps a review honest: revoking the
admin role from that principal does not, by itself, remove this access.
Step 4: Push the package
Pack the files — order doesn't matter to the reader, but _SID_META.json first matches how
SlashID's own tooling writes a package and is the easiest entry to find when inspecting a
rejected upload — and push it with the push_auth_token from Step 1:
tar -czf package.tar.gz _SID_META.json app.json identities.json groups.json roles.json resources.json memberships.json grants.json
curl -X POST "https://api.slashid.com/ip/nhi/snapshots" \
-H "Authorization: Bearer $PUSH_AUTH_TOKEN" -H "Content-Type: application/gzip" \
--data-binary @package.tar.gz
A successful push returns 202 Accepted immediately. Parsing and validating the package happens
afterwards, during the sync — a validation error rejects that sync and leaves previously synced
data untouched; it does not fail the HTTP request itself. The one thing checked synchronously,
before the upload is even acknowledged, is the size limit below.
Limits
- One upload per connection every 60 minutes. A second push attempted sooner gets
429 Too Many Requests. - 1.5 GiB compressed. A package over that size fails the upload itself with
400 Bad Request. - 12 GiB decompressed. Enforced later, while the sync reads the package — a package that expands past this even though it fit under the compressed limit gets cut off mid-archive and fails that sync instead.
Verification
After a successful push, SlashID:
- Parses and validates the package (see Troubleshooting for what can reject it)
- Materializes the application, its identities, groups, roles, resources, and the relationships in Step 3 into the identity graph
This happens asynchronously — it can take a few minutes after the 202 before everything is
visible in the Identity Protection Dashboard.
Re-uploading (any time after the 60-minute throttle window) fully replaces this connection's
data with what the new package describes.
Re-uploading through the CSV import API
A connection created with credentials.ingestion_mode: "csv" takes raw CSV exports instead of a
canonical package. The dashboard wizard drives the same API below, so a scheduled export can
re-upload headlessly once a mapping has been confirmed once. Every call needs an admin API key
or user token and the SlashID-OrgID header; non-admin callers are refused.
- Open a session —
POST /ip/nhi/connections/{connection_id}/imports(201 Created). A connection has one open session at a time: while one is open, the same call returns200 OKwith that existing session instead of opening a second. A session expires 24 hours after creation. - Upload each file —
POST …/imports/{import_id}/files?filename=<name>with atext/csvbody. SlashID sniffs the delimiter and encoding, finds the header row, profiles every column and detects what the file describes (identities, groups, roles, resources, memberships, grants, or a flat access report). Re-posting the samefilenamereplaces the file; any upload clears the confirmed mapping and the dry-run. - Confirm the mapping —
PUT …/imports/{import_id}/mapping. The session returned by step 2 carries aproposed_mapping; when the connection has a saved mapping whose file patterns and headers match, it is applied automatically (source: "saved"on every binding) and you can PUT it unchanged.GET /ip/nhi/connections/{connection_id}/import-mappingreturns the saved mapping;PUTthere replaces it. - Dry-run —
POST …/imports/{import_id}/dry-run(202 Accepted), then pollGET …/imports/{import_id}untilstatusisdry_run_ready(ordry_run_failed). The session'sdry_run_resultholds entity counts, up to 500 issues (issues.errorsmust be 0 to commit), a diff against the connection's current data, and a 20-row preview.requires_confirmation: truemeans the import removes more than 30% of an entity or relationship type: users, service accounts, groups, roles, resources, grants, role assignments or memberships. - Commit —
POST …/imports/{import_id}/commitwith{"confirm_large_removal": true}when step 4 asked for it (without it, the commit is refused with412and a message startinglarge_removal:). The package is published and a sync starts, exactly as for a pushed package; then the mapping is saved for the next session, the connection is activated, and the staged files are deleted. Commits are limited to one per connection every 2 minutes (429 Too Many Requests), and a second commit while one is in flight gets409 Conflict. If the connection's data changed since the dry-run (a package was pushed or committed since), the commit is refused with412and a message startingbaseline_changed:: run the dry-run again, then commit.
To remove a file from a session, DELETE …/imports/{import_id}/files/{file_id} (200 OK with the
updated session; like an upload, it clears the confirmed mapping and the dry-run). To abandon a
session, DELETE …/imports/{import_id} (204 No Content) cancels it and deletes its staged files
so a new one can be opened; a session that is committing cannot be cancelled (412). A commit
interrupted mid-flight leaves its session committing until a background sweep returns it to
dry_run_ready, typically within the hour; commit it again then (or, if the interrupted commit
did publish and the commit answers baseline_changed:, run the dry-run first). Re-committing is
safe even if the interrupted commit did publish: every import is a full snapshot. Should the session's staged
package be gone by then, the sweep returns it to mapping instead (run the dry-run again), or
expires it if its uploaded files are gone too.
Limits
- 500 MB per file, 20 files and 5,000,000 rows per session, 32 KB per cell — an upload that
breaches one returns
413 Payload Too Largeand leaves the session unchanged. The dry-run checks the row and cell limits again, so files uploaded before a limit was lowered report a blocking issue (session_rows_exceed_limit,cell_too_long) instead of importing. - 1,000 values per multi-value cell, 10,000 grants per flat-access row (its roles × resources)
and 10,000,000 grants per import. A row over one of these is a blocking dry-run issue
(
too_many_values,row_grants_exceed_limit,package_grants_exceed_limit) with its line. - A csv connection stays a draft until its first commit. A draft refuses pushes to
/ip/nhi/snapshotsand manual syncs with412 Precondition Failed, and is excluded from review scopes; only an import commit activates it. A draft that never commits is deleted after 14 days.
Troubleshooting
A rejected push doesn't fail the HTTP request (see Step 4) — it fails
that sync instead, and leaves the connection with last_attempted_sync_status: "failure". Today
that status is the only thing the API surfaces on the connection itself; there's no message or
per-issue detail there yet. The complete list of issues — file, position, code, message — is
recorded in SlashID's sync logs; contact support with your connection id to have it pulled. The
tables below let you pre-check your own export against the same rules SlashID applies, so most
pushes never need that. The CSV wizard shows the same list on its Preview step, with a
"Download issues" CSV, before anything is imported.
Before validation even starts
Two rejections happen before SlashID validates anything — it can't recognize the upload as a custom_app package at all:
| What's wrong | Message | How to fix |
|---|---|---|
_SID_META.json is missing, or names a source other than custom_app | not a custom_app package | Make sure _SID_META.json is present and its source is exactly custom_app |
_SID_META.json's version isn't one this adapter supports | unsupported custom_app package version <n> (supported: <n>) | Set version to the value the message says is supported |
Validation issues
Once SlashID recognizes the package, it validates the whole thing before writing anything — an error rejects the entire push, and nothing already synced is touched; a warning doesn't block it but is worth reviewing. SlashID retains at most 500 issues per push, but always counts every one (errors and warnings alike) even past that cap — if you're pushing something large enough to hit it, fix what you can see and re-push.
| Code | Severity | What it means | How to fix |
|---|---|---|---|
app_name_required | Error | app.json has no (or a blank) name | Set name in app.json |
id_required | Error | A row in identities.json / groups.json / roles.json / resources.json has no id | Give every row a non-empty id |
id_duplicate | Error | The same id appears twice within one file | Make ids unique within each file |
display_name_required | Error | A row has an id but a blank display_name | Set display_name for that row |
identity_type_invalid | Error | identities.json's type isn't user or service_account | Fix the type value |
status_invalid | Error | identities.json's status isn't active, disabled, or omitted | Fix or remove status |
group_parent_unknown | Error | A group's parent_ids names a group id that isn't in groups.json | Fix the id, or add the missing group |
group_cycle | Error | A group's parent_ids chain loops back on itself | Break the cycle |
resource_kind_required | Error | A resource has no kind | Set kind |
resource_parent_unknown | Error | A resource's parent_id names a resource id that isn't in resources.json | Fix the id, or add the missing resource |
resource_cycle | Error | A resource's parent_id chain loops back on itself | Break the cycle |
owner_unknown | Error | An owner reference by id doesn't match any row in identities.json | Use a known identity id, or switch to email |
owner_ref_empty | Error | An owner reference has neither id nor email | Set one of the two |
owner_type_invalid | Error | An owner's type isn't business or technical | Fix or remove type |
owner_email_invalid | Warning | An owner's email doesn't look like an email address (no @, or a space or control character) | Check the value — the package is still accepted |
owner_type_conflict | Warning | The same owner id or email is listed more than once on the same target with different types | SlashID resolves it automatically (business beats technical beats unspecified); dedupe the entries if this wasn't intentional |
manager_unknown | Error | An identity's manager refers by id to a row that isn't in identities.json | Use a known identity id, or switch to email |
manager_ref_empty | Error | An identity's manager has neither id nor email | Set one of the two, or remove manager |
manager_email_invalid | Warning | An identity's manager.email doesn't look like an email address | Check the value — the package is still accepted |
attributes_too_many | Error | An entity has more than 50 attributes | Trim to 50 or fewer |
attribute_key_invalid | Error | An attribute key doesn't match [a-z0-9_]{1,64} | Rename the key |
membership_group_unknown | Error | A membership's group_id isn't in groups.json | Fix group_id |
membership_member_unknown | Error | A membership's member.id isn't a known identity or group | Fix member.id |
membership_member_type_invalid | Error | A membership's member.type isn't identity or group | Fix member.type |
grant_target_required | Error | A grant has neither role_id nor resource_id | Set at least one |
grant_principal_type_invalid | Error | A grant's principal.type isn't identity, group, or role | Fix principal.type |
grant_principal_unknown | Error | A grant's principal.id doesn't match a known identity, group, or role | Fix principal.id |
grant_role_unknown | Error | A grant's role_id isn't in roles.json | Fix role_id, or add the missing role |
grant_resource_unknown | Error | A grant's resource_id isn't in resources.json | Fix resource_id, or add the missing resource |
grant_role_to_role | Error | A grant whose principal is a role also sets role_id | A role principal may only target a resource_id, never another role |
role_unused | Warning | A role in roles.json is never referenced by any grant | Grant it somewhere, or remove it — informational only |
value_too_long | Error | A string value (an id, name, description, email, kind, permission, or attribute value) is over 32 KiB | Shorten the value |
value_contains_nul | Error | A string value contains a NUL character (\u0000), which can't be stored | Remove the character from the named field |
If a push fails validation, that sync fails and leaves your previously synced data as-is; fix the package and push again (subject to the 60-minute throttle above).