Custom integration: Code + Sync API
Pick Custom instance when you create a Custom Definition and want to feed data into JupiterOne with code you own. JupiterOne creates the instance and a fresh API key — your code pushes entities and relationships against that instance via the Sync API. The runner can live anywhere with outbound HTTPS to JupiterOne: a CI job, a cron'd container, an event handler, an on-prem host.
What you get from JupiterOne
After you save the definition with Custom instance selected, JupiterOne issues:
- Instance ID — a UUID identifying this instance. Pass as
integrationInstanceIdon every Sync API call. - API key — used to authenticate Sync API calls. Treat as a secret; rotate from the instance page if it leaks.
The ingestion contract
Every ingestion run follows the same three-step flow against the Sync API:
- Start a job —
POST /persister/synchronization/jobswithsource: "integration-external"andintegrationInstanceId: "<your instance id>". JupiterOne returns ajobId. - Upload entities and relationships —
POST .../upload,.../entities, or.../relationshipsagainst thatjobId. Repeat as needed for large datasets. - Finalize —
POST .../finalizeto commit. JupiterOne reconciles the dataset against the previous run according to the chosensyncMode.
A job left un-finalized stays in AWAITING_UPLOADS and never reaches your graph. Always finalize.
Authentication
Send the API key as a bearer token on every Sync API request:
Authorization: Bearer <YOUR_API_KEY>
The instance ID is not secret. Rotate the API key from the instance page if it leaks.
Sync modes
Pick a syncMode per job based on what the data represents:
DIFF(default) — replaces the full dataset in scope. Anything you don't re-upload in this job is deleted on finalize. Use for full polls (snapshot of all current resources).PATCH— adds or updates entities only; never deletes. Use for event-driven pushes where you only know about deltas.CROSS_SCOPE— for relationships whose endpoints live in different scopes/instances.OVERRIDE— pin properties on entities created by another integration.
See the Sync API reference for the full mode reference.
Entity construction
An entity is a node in the JupiterOne graph. Every resource you ingest — a user, a host, an application, a finding — becomes one entity. Get the shape right and your data is queryable next to managed integrations; get it wrong and queries miss it.
Required fields
Every entity must include these five fields. JupiterOne rejects uploads missing any of them.
| Field | Type | Description |
|---|---|---|
_key | string | Unique identifier within your custom integration. Use the source resource's stable unique ID (UUID, ARN, numeric ID). Keep it minimal — no extra prefixes. Max 7000 characters. |
_type | string | The integration-specific type. Combines a resource name with the integration type from your definition: <resource>_<integration-type> (for example invoice_my-billing-acme). |
_class | string | string[] | One or more standard JupiterOne entity classes (User, Device, Application, Account, Finding, Vulnerability, etc.). Pick the closest match from the data model. |
name | string | Required. Even when redundant with class defaults. |
displayName | string | Required. Human-readable label shown in the UI. |
Property rules
The platform enforces a small set of rules on the rest of the properties.
- Primitives only. Property values must be
boolean,string,number, or arrays of those. No nested objects (except_rawData). Flatten nested API objects: if your source hasdevice.os.name, pushosName: "...", notos: { name: "..." }. - No mixed-type arrays.
["a", 1, true]is rejected. - No properties starting with
_other than the platform-reserved ones (_key,_type,_class,_rawData,_fromEntityKey,_toEntityKey,_mapping). - No
undefined. Omit the property or usenullinstead. - Strings ≤ 4096 characters. Truncate descriptions, notes, error messages, file paths.
- Boolean fields use
is{CamelCase}—isEnabled,isAdmin,isMfaEnabled. Neverenabledormfa. - Date fields use
{action}Onas epoch milliseconds —createdOn,updatedOn,lastSeenOn,lastLoginOn. Convert ISO strings to epoch ms before push. Never push ISO strings. - Don't redefine class-inherited properties. A
Device-class entity already hashostname,osType,ipAddresses,macAddresses. AUserentity already hasemail,firstName,lastName,username. Don't add custom-named duplicates. The full list per class is in the data model. - Defaults. Never default missing strings to
""— usenullor omit. Empty strings break filters that test for presence.
What to include
Pick the minimum set of properties that offer query value for security, compliance, or asset inventory. Be selective.
Always include when available:
- Identifiers — unique ID, name, displayName.
- Ownership — owner, createdBy, assignedTo.
- Status — active / enabled / lifecycle state.
- Timestamps —
createdOn,updatedOn,lastSeenOn,lastLoginOn. - Security-relevant — permissions, roles, MFA status, encryption status, severity, compliance status.
- Classification — category, type, tags / labels.
- Network identity (devices/hosts) — hostname, IP addresses, MAC addresses, OS type.
What to exclude
Always exclude:
- Secrets — passwords, API keys, tokens, certificates, private keys. Never push as properties or in raw data.
- Sensitive PII beyond what is needed for identity (SSN, home addresses, phone numbers).
- Request/response bodies, full stack traces, raw logs.
- Internal platform IDs with no security meaning (sequence numbers, render hints, template IDs).
- UI/display-only metadata (locale, timezone, theme).
When in doubt, leave it out. Adding a property later is cheap; removing one is a breaking change for queries, alerts, and compliance mappings.
JSON example
This is a literal upload payload — exactly what POST /upload accepts. The Application entity describes a third-party SaaS app discovered in your billing system.
{
"entities": [
{
"_key": "billing-acme:app:00f4a812-1c4f-4d79-9c3c-aaa2efad8e1f",
"_type": "application_my-billing-acme",
"_class": "Application",
"name": "slack",
"displayName": "Slack",
"category": "communication",
"vendor": "Slack Technologies",
"isSso": true,
"isMfaEnforced": true,
"userCount": 142,
"monthlyCostUsd": 1420.50,
"createdOn": 1730419200000,
"lastSeenOn": 1736294400000,
"owner": "platform-team@example.com",
"tags": ["sanctioned", "tier-1"]
}
]
}
Notes on this payload:
_keyuses the source system's stable UUID (no prefixes other than the integration namespace)._typefollows the<resource>_<integration-type>pattern._class: "Application"is a standard JupiterOne class — properties likecategoryandvendorare inherited from the class.- Booleans use
is{CamelCase}(isSso,isMfaEnforced). - Dates are epoch ms (
createdOn,lastSeenOn). userCountandmonthlyCostUsdare flat primitives; no nested billing object.
Relationship construction
A relationship is an edge between two entities — "team A owns application B," "user X has assigned role Y," "finding F exploits vulnerability V." Relationships are how JupiterOne answers "what does X depend on / connect to" without joins. Without them, your entities are isolated nodes.
When to use a relationship vs a property
If two entities are linked, push a relationship, not a flat foreign-key property. A child entity should not carry a parentId field when the relationship already encodes the link.
BAD: Application { _key: "app1", teamId: "team-42" }
GOOD: Team --OWNS--> Application
The exception: cveId on a Finding/Vulnerability. JupiterOne correlates CVEs automatically via that property — do not create a mapped relationship to a cve entity.
Required fields
| Field | Type | Description |
|---|---|---|
_key | string | Unique identifier for this relationship. A common pattern: <fromKey>|<class>|<toKey>. |
_type | string | Naming pattern: <from-type>_<verb>_<to-type> (for example team_my-billing-acme_owns_application_my-billing-acme). |
_class | string | One of the standard JupiterOne relationship classes (see below). Uppercase. |
_fromEntityKey | string | The _key of the source entity. Must already exist or be uploaded in the same job. |
_toEntityKey | string | The _key of the target entity. Must already exist or be uploaded in the same job. |
Standard relationship classes
Pick the verb that best matches the real-world relationship. Common options:
| Class | Use for |
|---|---|
HAS | Containment / membership (Team HAS User, Account HAS Application). |
OWNS | Ownership (Team OWNS Application). |
ASSIGNED | Assignment (User ASSIGNED Role). |
USES | Use / consumption (Application USES DataStore). |
MANAGES | Management (User MANAGES Team). |
IS | Identity / mapping (User IS Person — typically mapped relationship). |
ALLOWS / DENIES | Policy (Role ALLOWS Action). |
PROTECTS | Security control (Service PROTECTS Host). |
EXPLOITS | Findings (Finding EXPLOITS Vulnerability). |
The full list lives in the data model.
JSON example
This payload connects the Application entity from the entity example to a Team entity owning it. Both endpoints exist in this same custom integration — a simple direct relationship.
{
"entities": [
{
"_key": "billing-acme:team:engineering",
"_type": "team_my-billing-acme",
"_class": "Team",
"name": "engineering",
"displayName": "Engineering",
"memberCount": 42
},
{
"_key": "billing-acme:app:00f4a812-1c4f-4d79-9c3c-aaa2efad8e1f",
"_type": "application_my-billing-acme",
"_class": "Application",
"name": "slack",
"displayName": "Slack"
}
],
"relationships": [
{
"_key": "billing-acme:team:engineering|owns|billing-acme:app:00f4a812-1c4f-4d79-9c3c-aaa2efad8e1f",
"_type": "team_my-billing-acme_owns_application_my-billing-acme",
"_class": "OWNS",
"_fromEntityKey": "billing-acme:team:engineering",
"_toEntityKey": "billing-acme:app:00f4a812-1c4f-4d79-9c3c-aaa2efad8e1f"
}
]
}
Mapped relationships (cross-integration)
Use a mapped relationship when one endpoint is owned by a different integration — for example, your custom integration creates an Application and you want to link it to an aws_iam_role already ingested by the AWS integration. You don't know the target's _key; you describe how to find it.
{
"relationships": [
{
"_key": "billing-acme:app:slack|uses|aws-iam-role-1",
"_type": "application_my-billing-acme_uses_aws_iam_role",
"_class": "USES",
"_mapping": {
"sourceEntityKey": "billing-acme:app:00f4a812-1c4f-4d79-9c3c-aaa2efad8e1f",
"relationshipDirection": "FORWARD",
"targetFilterKeys": [["_type", "_key"]],
"targetEntity": {
"_type": "aws_iam_role",
"_key": "arn:aws:iam::123456789012:role/SlackProvisioner"
},
"skipTargetCreation": true
}
}
]
}
Set skipTargetCreation: true when you expect the target to already exist (most common). Set it to false only for shared entities like cve where JupiterOne should create them on demand.
See Creating relationships between entities for the full mapped-relationship reference.
Step-by-step build guide
The end-to-end shape of a custom integration is the same regardless of language: pull data from your vendor, transform it into JupiterOne's entity/relationship JSON, push it through the Sync API in batches, finalize. This guide walks the pieces. Code samples are TypeScript for illustration; any HTTP-capable runtime works.
1. Project setup
Make the JupiterOne credentials available as environment variables on whatever runs your code:
export JUPITERONE_API_KEY=<your-api-key>
export JUPITERONE_INTEGRATION_INSTANCE_ID=<your-instance-id>
export JUPITERONE_API_BASE=https://api.us.jupiterone.io
Add an HTTP client and your vendor API's SDK (or just a fetch library). Keep credentials out of source control.
2. Build a vendor API client
Encapsulate vendor calls behind a small client class. Three things matter:
- Authentication — read the vendor docs. Don't default to
Bearer— vendors use many patterns:X-Api-Key,Authorization: Basic, custom headers, OAuth. - Pagination — handle every page. Common patterns: cursor /
next_page_token, offset+limit,Linkheader. Stop when the cursor orhasMoreflag indicates the end. - Typed responses — define interfaces matching what the API returns. Don't carry
anytypes into your converters.
Sketch:
class VendorClient {
constructor(private apiKey: string, private baseUrl: string) {}
async *iterateApplications(): AsyncGenerator<VendorApp> {
let cursor: string | undefined;
do {
const url = new URL(`${this.baseUrl}/v1/apps`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { "X-Api-Key": this.apiKey },
});
if (!res.ok) throw new Error(`vendor api ${res.status}`);
const body: { apps: VendorApp[]; next_cursor?: string } = await res.json();
for (const app of body.apps ?? []) yield app;
cursor = body.next_cursor;
} while (cursor);
}
}
Note the ?? [] guard before iterating — APIs lie about whether arrays are present.
3. Map vendor responses to entities and relationships
Write a small pure function per resource that turns one API object into one JupiterOne entity. Keep these functions free of HTTP, logging, or upload concerns — just data shape transformation.
const INTEGRATION_TYPE = "my-billing-acme"; // matches your custom definition's Integration Type
function toApplicationEntity(app: VendorApp): JupiterOneEntity {
return {
_key: `${INTEGRATION_TYPE}:app:${app.id}`,
_type: `application_${INTEGRATION_TYPE}`,
_class: "Application",
name: app.slug,
displayName: app.display_name ?? app.slug,
category: app.category ?? null,
vendor: app.publisher ?? null,
isSso: app.sso_enabled === true,
isMfaEnforced: app.mfa_required === true,
userCount: app.user_count,
monthlyCostUsd: app.cost_monthly_usd,
createdOn: app.created_at ? Date.parse(app.created_at) : undefined,
lastSeenOn: app.last_active_at ? Date.parse(app.last_active_at) : undefined,
owner: app.owner_email ?? null,
tags: app.tags ?? null,
};
}
function toTeamOwnsAppRelationship(
team: VendorTeam,
app: VendorApp,
): JupiterOneRelationship {
const fromKey = `${INTEGRATION_TYPE}:team:${team.slug}`;
const toKey = `${INTEGRATION_TYPE}:app:${app.id}`;
return {
_key: `${fromKey}|owns|${toKey}`,
_type: `team_${INTEGRATION_TYPE}_owns_application_${INTEGRATION_TYPE}`,
_class: "OWNS",
_fromEntityKey: fromKey,
_toEntityKey: toKey,
};
}
Validate before transforming. If a record is missing id or slug, log and skip — don't push partial entities.
4. Push to JupiterOne via the Sync API
Three endpoints, one job lifecycle. Wrap them in a small client.
class JupiterOneSync {
constructor(
private apiBase: string,
private apiKey: string,
private instanceId: string,
) {}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.apiBase}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`j1 sync ${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
async startJob(syncMode = "DIFF"): Promise<string> {
const { job } = await this.post<{ job: { id: string } }>(
"/persister/synchronization/jobs",
{
source: "integration-external",
integrationInstanceId: this.instanceId,
syncMode,
},
);
return job.id;
}
async upload(
jobId: string,
payload: { entities?: object[]; relationships?: object[] },
): Promise<void> {
await this.post(`/persister/synchronization/jobs/${jobId}/upload`, payload);
}
async finalize(jobId: string): Promise<void> {
await this.post(`/persister/synchronization/jobs/${jobId}/finalize`, {});
}
}
5. Wire it together
Pull, transform, batch, upload, finalize:
const vendor = new VendorClient(
process.env.VENDOR_API_KEY!,
"https://api.vendor.com",
);
const j1 = new JupiterOneSync(
process.env.JUPITERONE_API_BASE!,
process.env.JUPITERONE_API_KEY!,
process.env.JUPITERONE_INTEGRATION_INSTANCE_ID!,
);
const jobId = await j1.startJob("DIFF");
const BATCH = 250;
let entities: object[] = [];
let relationships: object[] = [];
async function flush() {
if (!entities.length && !relationships.length) return;
await j1.upload(jobId, { entities, relationships });
entities = [];
relationships = [];
}
for await (const app of vendor.iterateApplications()) {
if (!app.id || !app.slug) continue; // skip records missing required fields
entities.push(toApplicationEntity(app));
if (app.owner_team) {
relationships.push(toTeamOwnsAppRelationship(app.owner_team, app));
}
if (entities.length >= BATCH || relationships.length >= BATCH) {
await flush();
}
}
await flush();
await j1.finalize(jobId);
Batch sizes around 250 keep request bodies modest while minimizing round-trips. Compression (Content-Encoding: gzip) is supported if you push much larger batches.
6. Run and verify
Run the script. Then in the JupiterOne app:
-
Open your custom instance — the Jobs tab should show the run with
status: FINISHEDand entity / relationship counts. -
Run a J1QL query to confirm the data is present:
FIND * with _integrationInstanceId = "<your-instance-id>" -
Open one of the entities and verify the property names match what you intended (boolean fields prefixed
is, dates as numbers, no nested objects).
If numEntitiesUploaded is non-zero but the query returns nothing, the job didn't finalize — check that your finalize call returned 200.
Operational tips
- Idempotency — a
DIFFfinalize is the source of truth. If a job fails partway, start a fresh job; don't try to resume. - Validate small, then scale up — push a handful of entities first, query for them, verify shape. Then loop the full dataset.
- Schema drift — if your source adds new fields, decide deliberately whether to ingest them. Don't blindly forward every API field.
- Never delete properties that you previously pushed without confirming downstream queries are updated. Removed properties are a breaking change.
- Concurrency — when fetching detail-per-entity from a list endpoint, parallelize but respect the vendor's rate limits. Read response headers (
x-ratelimit-*,retry-after) before raising concurrency. - Defensive guards on API arrays — always
?? []before iterating arrays from API responses, and?? ''(or?? null) before interpolating into_keystrings. The literal text"undefined"in a key is always a bug.
Related references
- Sync API reference — full endpoint reference, payload shapes, validation rules.
- JupiterOne data model — entity classes, relationship classes, base properties.
- Creating relationships between entities — mapped-relationship deep dive.
- Open-source JupiterOne SDK — TypeScript helpers used by JupiterOne's own integrations. Optional; the Sync API is the contract.