Skip to main content

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"
}
}
FieldDescription
credentials.app_nameRequired. Display name of the application
credentials.app_kindRequired. One of saas, internal, database, legacy, other
credentials.app_urlOptional URL of the application
credentials.ingestion_modeRequired. 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.

FileRequiredContents
_SID_META.jsonYesThe package envelope (below)
app.jsonYesThe application itself: name, kind, URL, owners
identities.jsonNoUsers and service accounts
groups.jsonNoGroups
roles.jsonNoRoles
resources.jsonNoThe things your app protects — databases, cost centers, modules, whatever your app calls them
memberships.jsonNoWhich identities and groups belong to which groups
grants.jsonNoWho 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​

FieldRequiredDescription
nameYesDisplay name of the application
kindNoFree-text kind, e.g. saas, internal
urlNoURL of the application
ownersNoList of owner references
{
"name": "Legacy Payroll",
"kind": "legacy"
}

identities.json​

FieldRequiredDescription
idYesUnique within this file
typeYesuser or service_account
display_nameYes—
emailNo—
usernameNo—
statusNoactive (default) or disabled
managerNoA 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
attributesNoFlat 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​

FieldRequiredDescription
idYesUnique within this file
display_nameYes—
descriptionNo—
parent_idsNoIds of groups this group is nested under (no cycles)
ownersNo—
attributesNo—
[{ "id": "fin", "display_name": "Finance" }]

roles.json​

FieldRequiredDescription
idYesUnique within this file
display_nameYes—
descriptionNo—
privilegedNoMarks the role as privileged
ownersNo—
attributesNo—
[{ "id": "admin", "display_name": "Admin", "privileged": true }]

resources.json​

FieldRequiredDescription
idYesUnique within this file
kindYesYour own vocabulary — Database, Cost center, anything you use
display_nameYes—
parent_idNoId of a parent resource (no cycles). Omitted → nested directly under the application
ownersNo—
attributesNo—
[
{
"id": "payroll-db",
"kind": "Database",
"display_name": "Payroll DB",
"owners": [{ "id": "ann", "type": "business" }]
}
]

memberships.json​

FieldRequiredDescription
member.typeYesidentity or group
member.idYes—
group_idYes—
[{ "member": { "type": "identity", "id": "ann" }, "group_id": "fin" }]

grants.json​

FieldRequiredDescription
principal.typeYesidentity, group, or role
principal.idYes—
role_idNo—
resource_idNo—
permissionNoFree 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 packageWhat shows up in SlashID
A grant naming only a role_idThe 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_idThe 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_idDefines 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_idsThe 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 idThat identity owns the app, group, role, or resource, tagged business, technical, or left unspecified
An owner listed only by emailResolved 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:

BucketMatches words like
adminadmin, administrator, owner, full, manage, management
deletedelete, remove, purge
writewrite, edit, update, create, modify, contribute
shareshare, grant, invite
readread, view, list, get, audit
accessanything 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 onPropertyMeaning
Access relationshipsprivilegesSorted, de-duplicated list of the privilege names granted. Omitted entirely when there are none
Access relationshipsvia_rolesSorted, de-duplicated list of role ids that contributed this access. Omitted when none did
Access relationshipsdirectAlways present. true when at least one grant gave this access without going through a role
"Has role" relationshipspermission_scopesSorted, de-duplicated list of resource ids the role is held on. Omitted when the role isn't scoped to any
"Has role" relationshipsunscopedAlways present. true when the role is also held without being scoped to any specific resource
Ownership relationshipsownership_typebusiness, 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.

  1. 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 returns 200 OK with that existing session instead of opening a second. A session expires 24 hours after creation.
  2. Upload each file — POST …/imports/{import_id}/files?filename=<name> with a text/csv body. 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 same filename replaces the file; any upload clears the confirmed mapping and the dry-run.
  3. Confirm the mapping — PUT …/imports/{import_id}/mapping. The session returned by step 2 carries a proposed_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-mapping returns the saved mapping; PUT there replaces it.
  4. Dry-run — POST …/imports/{import_id}/dry-run (202 Accepted), then poll GET …/imports/{import_id} until status is dry_run_ready (or dry_run_failed). The session's dry_run_result holds entity counts, up to 500 issues (issues.errors must be 0 to commit), a diff against the connection's current data, and a 20-row preview. requires_confirmation: true means the import removes more than 30% of an entity or relationship type: users, service accounts, groups, roles, resources, grants, role assignments or memberships.
  5. Commit — POST …/imports/{import_id}/commit with {"confirm_large_removal": true} when step 4 asked for it (without it, the commit is refused with 412 and a message starting large_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 gets 409 Conflict. If the connection's data changed since the dry-run (a package was pushed or committed since), the commit is refused with 412 and a message starting baseline_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 Large and 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/snapshots and manual syncs with 412 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 wrongMessageHow to fix
_SID_META.json is missing, or names a source other than custom_appnot a custom_app packageMake sure _SID_META.json is present and its source is exactly custom_app
_SID_META.json's version isn't one this adapter supportsunsupported 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.

CodeSeverityWhat it meansHow to fix
app_name_requiredErrorapp.json has no (or a blank) nameSet name in app.json
id_requiredErrorA row in identities.json / groups.json / roles.json / resources.json has no idGive every row a non-empty id
id_duplicateErrorThe same id appears twice within one fileMake ids unique within each file
display_name_requiredErrorA row has an id but a blank display_nameSet display_name for that row
identity_type_invalidErroridentities.json's type isn't user or service_accountFix the type value
status_invalidErroridentities.json's status isn't active, disabled, or omittedFix or remove status
group_parent_unknownErrorA group's parent_ids names a group id that isn't in groups.jsonFix the id, or add the missing group
group_cycleErrorA group's parent_ids chain loops back on itselfBreak the cycle
resource_kind_requiredErrorA resource has no kindSet kind
resource_parent_unknownErrorA resource's parent_id names a resource id that isn't in resources.jsonFix the id, or add the missing resource
resource_cycleErrorA resource's parent_id chain loops back on itselfBreak the cycle
owner_unknownErrorAn owner reference by id doesn't match any row in identities.jsonUse a known identity id, or switch to email
owner_ref_emptyErrorAn owner reference has neither id nor emailSet one of the two
owner_type_invalidErrorAn owner's type isn't business or technicalFix or remove type
owner_email_invalidWarningAn 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_conflictWarningThe same owner id or email is listed more than once on the same target with different typesSlashID resolves it automatically (business beats technical beats unspecified); dedupe the entries if this wasn't intentional
manager_unknownErrorAn identity's manager refers by id to a row that isn't in identities.jsonUse a known identity id, or switch to email
manager_ref_emptyErrorAn identity's manager has neither id nor emailSet one of the two, or remove manager
manager_email_invalidWarningAn identity's manager.email doesn't look like an email addressCheck the value — the package is still accepted
attributes_too_manyErrorAn entity has more than 50 attributesTrim to 50 or fewer
attribute_key_invalidErrorAn attribute key doesn't match [a-z0-9_]{1,64}Rename the key
membership_group_unknownErrorA membership's group_id isn't in groups.jsonFix group_id
membership_member_unknownErrorA membership's member.id isn't a known identity or groupFix member.id
membership_member_type_invalidErrorA membership's member.type isn't identity or groupFix member.type
grant_target_requiredErrorA grant has neither role_id nor resource_idSet at least one
grant_principal_type_invalidErrorA grant's principal.type isn't identity, group, or roleFix principal.type
grant_principal_unknownErrorA grant's principal.id doesn't match a known identity, group, or roleFix principal.id
grant_role_unknownErrorA grant's role_id isn't in roles.jsonFix role_id, or add the missing role
grant_resource_unknownErrorA grant's resource_id isn't in resources.jsonFix resource_id, or add the missing resource
grant_role_to_roleErrorA grant whose principal is a role also sets role_idA role principal may only target a resource_id, never another role
role_unusedWarningA role in roles.json is never referenced by any grantGrant it somewhere, or remove it — informational only
value_too_longErrorA string value (an id, name, description, email, kind, permission, or attribute value) is over 32 KiBShorten the value
value_contains_nulErrorA string value contains a NUL character (\u0000), which can't be storedRemove 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).