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.
If the application exposes a SCIM 2.0 API, SlashID can pull from it on a schedule instead; see Alternative: let SlashID pull from the app's SCIM endpoint.
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": "ann@example.com" },
{ "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.
Rotating a push token
Replace a connection's push token without downtime. Rotation issues a new token; every earlier token keeps working for a grace period you choose, then stops.
POST /ip/nhi/connections/{connection_id}/push-token/rotate
SlashID-OrgID: <your organization ID>
SlashID-API-Key: <an admin API key>
Content-Type: application/json
{"grace_period_hours": 24}
{
"result": {
"push_auth_token": "<the new token>",
"previous_tokens_expire_at": "2026-10-01T09:30:00Z"
}
}
grace_period_hoursis optional: any whole number from 0 to 720, default 24.- Every token issued before the rotation keeps working until
previous_tokens_expire_atat the latest. After that, an upload that uses one of them is refused with401. - A rotation never extends a grace period an earlier rotation set, so a token already in a shorter grace period stops sooner.
0stops the earlier tokens at once. Allow up to one minute for every server to stop accepting them; the same one-minute allowance applies when a grace period ends.- A connection keeps at most 10 earlier tokens working. While it has 10, a rotation with a grace
period is refused with
409; rotate with0, or wait until one of them expires. A rotation with0is always accepted. - The request body accepts only
grace_period_hours; any other field is refused with400. GET /ip/nhi/connections/{connection_id}also returns the current token.- In the dashboard, open the data source and choose API details to reach its Push via API page, then choose Rotate token. Under Grace period for previous tokens, pick how long the previous tokens may keep working (Stop immediately, 1 hour, 24 hours by default, 7 days or 30 days) and confirm with Rotate now.
- Only custom app connections support rotation; other data sources answer
412. - Expired tokens are deleted a week after they expire.
Alternative: let SlashID pull from the app's SCIM endpoint
If the application exposes a SCIM 2.0 API, SlashID can
crawl it on a schedule instead of waiting for packages. SlashID reads users, groups, roles,
entitlements and the enterprise extension, plus any custom resource types you opt in to, and
brings them into the identity graph with the same model as a pushed package. The connection uses
ingestion_mode: "scim_pull".
Before starting
- The base URL of the app's SCIM 2.0 API, for example
https://payroll.example.com/scim/v2. It must usehttpsand must not carry a username or password, a query string or a fragment. - Credentials for one of the authentication methods below, with read access to
/Users, and to/Groupsif the app has a Groups endpoint. An app without one is fine: SlashID then reads memberships from each user'sgroups(see What SlashID reads). - Network access from SlashID to the endpoint. Its host must resolve to a public IP address: SlashID refuses private, loopback and link-local addresses.
If your endpoint or tenant restricts traffic by source IP, allow connections from SlashID's egress IPs published at https://cdn.slashid.com/egress.json. The list is global and stable; the syncToken field changes whenever the IPs change, so you can use it to detect drift.
The file follows the JAFAR draft format (A JSON-Based Format for Publishing IP Ranges of Automated HTTP Clients), the same convention major cloud providers use to publish their IP ranges.
Authentication methods
auth_method | Fields | How SlashID authenticates |
|---|---|---|
bearer | bearer_token | Authorization: Bearer <token> on every request |
oauth2 | client_id, client_secret, token_url | OAuth 2.0 client credentials; SlashID requests and refreshes the access token |
basic | username, password | Authorization: Basic <base64> on every request |
The token_url follows the same rules as the base URL, except that it may carry a query string.
Secrets (bearer_token, client_secret, password) are write-only: reading the connection never
returns them. When you change the endpoint's origin (its scheme, host or port) or the
token_url, send the current method's secret again in the same update; otherwise the update is
refused with 400 and nothing changes. Switching auth_method clears the other methods'
secrets.
Set it up in the console
- Go to Data sources → Add data source and pick SCIM. Or pick Connect any app and choose SCIM — your app has a SCIM endpoint on the second step.
- Name your app. SlashID creates the connection as a draft when you leave the second step.
- Point us at the app's SCIM endpoint: the SCIM base URL, the Authentication method and its fields, and the connection's Authoritative status (see below). Test saves the endpoint and runs the connection's health check, so you can fix a credential before you continue.
- Pick what to sync: SlashID reads the endpoint's discovery documents and a first page of
users. Choose which
userTypevalues are service accounts, and opt in to any custom resource types, each as roles or as resources. - Preview: a sample from the first page of each endpoint, with its counts and any issues. Activate starts the first full crawl and puts the connection on the schedule.
Until you activate it, the connection stays a draft: choose Resume setup on its page to pick up where you left off. A draft that is never activated is deleted after 14 days. To change the endpoint, the credentials, the authoritative status or the selection of an active connection, open it and choose SCIM settings. The endpoint and selection steps save their changes when you continue, and Save and sync now on the last step re-activates the connection and starts a new crawl.
Set it up through the API
These calls need an admin API key, like the CSV import API below.
- Create the connection with
POST /ip/nhi/connections. It starts as adraft. You can send thescimblock now, as here, or later with aPATCH:
POST /ip/nhi/connections
SlashID-OrgID: <your-org-id>
SlashID-API-Key: <your-org-api-key>
Content-Type: application/json
{
"source": "custom_app",
"name": "Payroll",
"authoritative_status": "none",
"credentials": {
"app_name": "Payroll",
"app_kind": "saas",
"ingestion_mode": "scim_pull",
"scim": {
"base_url": "https://payroll.example.com/scim/v2",
"auth_method": "bearer",
"bearer_token": "<the app's SCIM token>"
}
}
}
GET /ip/nhi/connections/{connection_id}/scim-discoveryreads/ServiceProviderConfig,/ResourceTypes,/Schemasand the first page of/Users. It reports:- what the endpoint supports;
- its resource types, with
standard: trueonUserandGroup; - the attributes and
userTypevalues seen on users; - a
discovery_warningsentry for every discovery document the endpoint does not serve or that SlashID cannot use, and for every list it cuts short.
The discovery documents are optional; only a failing
/Usersfails discovery./Schemasis read only when/ResourceTypeslists resource types.Optionally,
PATCH /ip/nhi/connections/{connection_id}thescimblock:service_account_user_types: theuserTypevalues (case-insensitive) that are service accounts rather than people.resource_types: custom resource types to crawl. Each entry names itsendpoint(for example/Roles) andmap_to(roleorresource). It can also setid_attribute,display_name_attributeandmembers_attribute(defaultsid,displayNameandmembers). A custom type is crawled only once you list it.
PATCH /ip/nhi/connections/{connection_id}
SlashID-OrgID: <your-org-id>
SlashID-API-Key: <your-org-api-key>
Content-Type: application/json
{
"source": "custom_app",
"credentials": {
"scim": {
"service_account_user_types": ["Service"],
"resource_types": [{ "endpoint": "/Roles", "map_to": "role" }]
}
}
}
POST /ip/nhi/connections/{connection_id}/scim-previewsamples the first page of each endpoint. It returns the same result as a CSV import's dry run,counts,issuesand apreviewof the graph, plus two fields:totals: the endpoint's own user and group counts, so you can tell how much the sample covers;out_of_sample_refs: references that point outside the sample, which the full crawl resolves.
POST /ip/nhi/connections/{connection_id}/scim-activatestarts the first full crawl and puts the connection on the schedule:
| Status | Meaning |
|---|---|
202 | Activated; the first crawl is queued. Activating an active connection again re-crawls it. |
409 | Another custom app in this organization already pulls from this endpoint; the message names it. Saving a changed endpoint on an active connection is refused the same way. |
412 | SCIM pull is not available, the connection is not a scim_pull connection with a scim block, or the endpoint failed the /Groups check; the message says which. |
429 | This connection was activated less than 2 minutes ago. Nothing was changed; try again in a moment. |
Every activation also checks whether the endpoint serves /Groups (see below).
What SlashID reads
| SCIM data | Becomes |
|---|---|
/Users | Identities: people, or service accounts when userType is in service_account_user_types. With active, names, emails, created and lastModified, and the enterprise extension's manager, department, employeeNumber, organization, costCenter and division |
A user's roles | Roles, granted directly to the user |
A user's entitlements | Resources of kind entitlement, granted to the user |
/Groups | Groups and their memberships, nested groups included |
| Custom resource types you opted in to | Roles or resources, with their members as grants |
- Entitlement permissions. An entitlement's grant permission is its
typewhen the type, ignoring case and surrounding spaces, is exactly one ofread,write,delete,share,adminoraccess. Any other type, or none, becomesaccess, and the original type is kept as theentitlement_typeattribute of the entitlement. - Apps without
/Groups. If the endpoint did not serve/Groupswhen the connection was activated, memberships come from each user'sgroupsattribute. If/Groupswas there at activation and later answers404,405or501, that sync fails and the previous data stays. If the app stopped serving groups on purpose, activate the connection again. - Unresolved references. A reference to something the endpoint does not return, such as a
group member or a manager nobody has, is dropped with a warning, and the sync still succeeds.
Members are matched by their
type, then by their$ref, then by ID. - Changing
service_account_user_typesafter activation. The change moves identities between people and service accounts, and each moved identity gets a new ID at the next sync. Its access-review, ownership and ticket history does not carry over. The console warns before you save such a change.
Authoritative status
authoritative_status (none, primary or secondary; default none) says whether this app's
identities are the source of truth when SlashID reconciles people across providers. Leave it
none unless the app is your directory. Set it on the endpoint step, in SCIM settings, or
with the connection's top-level authoritative_status field on POST or PATCH.
Limits for SCIM pull
- Every sync is a full crawl of
/Users,/Groups(when the app has it) and the custom resource types you opted in to, 100 items per page. - Each request times out after 60 seconds, and each page may be at most 16 MiB. Discovery, the preview and the activation check run while you wait, and each stops after 20 seconds.
- Redirects are followed only within the same scheme, host and port, at most 3 times, for the
endpoint and for the OAuth 2.0 token URL alike. Any other redirect is refused, and the message
names the field to correct,
base_urlor, for the token endpoint,token_url. For example:endpoint redirected to another scheme, host or port; set base_url to the URL it finally answers on. - Paging must honour
startIndex. If a page adds no item that was not already read whiletotalResultssays more remain, the sync fails as non-conforming instead of importing part of the directory. - Discovery shows at most 50 resource types and 200 attribute names per type, with a warning when it cuts a list short.
- A
scim_pullconnection does not accept pushed packages:POST /ip/nhi/snapshotswith its token returns409. - An active connection cannot switch between pulled and pushed data: changing
ingestion_modetocsvorapireturns412. Create a new custom app instead.
Troubleshooting SCIM pull
| Symptom | Cause | Fix |
|---|---|---|
Discovery, preview or activation fails with 412 and a message such as SCIM discovery failed: the endpoint answered HTTP 401 | The endpoint refused the request: the credentials are wrong or expired (401), or cannot list users or groups (403) | Check the token or OAuth client, and that it can read /Users, and /Groups if the app has it |
412 with the endpoint could not be reached, or its answer is not SCIM 2.0, or the endpoint did not answer within 20s | SlashID could not connect (including when the host resolves to a private, loopback or link-local address), the endpoint answered with something other than SCIM JSON (for example an HTML page from a proxy or a login screen), or it was too slow | Check the base URL and that the endpoint is reachable from SlashID's egress IPs, on a public address |
Discovery lists only User and Group, with a discovery warning | The endpoint does not serve /ResourceTypes, or lists no resource types (SCIM allows both) | Nothing to do; SlashID still crawls /Users and, if the app has it, /Groups |
endpoint redirected to another scheme, host or port; set base_url to the URL it finally answers on | The endpoint redirects to another scheme, host or port | Set base_url (or token_url, if the message names the token endpoint) to the URL it finally answers on |
An update is refused with 400 after changing the URL | The endpoint's origin or token_url changed without the secret | Send the current method's secret in the same update |
Activation returns 409 | Another custom app already pulls from this endpoint | Use the connection the message names, or delete it first |
Activation returns 429 | The connection was activated less than 2 minutes ago | Wait and try again; nothing was changed |
Syncs fail after the app stopped serving /Groups | /Groups existed at activation and now returns 404, 405 or 501 | Restore /Groups, or activate again to read memberships from users' groups |
| A sync fails as non-conforming | The endpoint ignores startIndex and repeats pages | Fix paging in the app, or contact support with the connection id |
Entitlements show the permission access | Their type is not one of the permission words above | Expected; the original type is kept as the entitlement's entitlement_type attribute |
| Nothing appears after activation | The first crawl is still running | Wait a few minutes; the connection's page shows the health check and the last sync |
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.
For a scim_pull connection, SlashID crawls the endpoint on its regular sync schedule, once a day,
instead of waiting for a push, and each successful crawl fully replaces the connection's data. The
connection's page in the console shows the Last sync time and the health check, which runs
Verify credentials, Read user accounts, Read groups and members and the optional
Discover Resource Types.
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).