Skip to main content

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 integrationInstanceId on 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:

  1. Start a jobPOST /persister/synchronization/jobs with source: "integration-external" and integrationInstanceId: "<your instance id>". JupiterOne returns a jobId.
  2. Upload entities and relationshipsPOST .../upload, .../entities, or .../relationships against that jobId. Repeat as needed for large datasets.
  3. FinalizePOST .../finalize to commit. JupiterOne reconciles the dataset against the previous run according to the chosen syncMode.

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.

FieldTypeDescription
_keystringUnique 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.
_typestringThe integration-specific type. Combines a resource name with the integration type from your definition: <resource>_<integration-type> (for example invoice_my-billing-acme).
_classstring | string[]One or more standard JupiterOne entity classes (User, Device, Application, Account, Finding, Vulnerability, etc.). Pick the closest match from the data model.
namestringRequired. Even when redundant with class defaults.
displayNamestringRequired. 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 has device.os.name, push osName: "...", not os: { 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 use null instead.
  • Strings ≤ 4096 characters. Truncate descriptions, notes, error messages, file paths.
  • Boolean fields use is{CamelCase}isEnabled, isAdmin, isMfaEnabled. Never enabled or mfa.
  • Date fields use {action}On as epoch millisecondscreatedOn, 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 has hostname, osType, ipAddresses, macAddresses. A User entity already has email, 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 "" — use null or 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:

  • _key uses the source system's stable UUID (no prefixes other than the integration namespace).
  • _type follows the <resource>_<integration-type> pattern.
  • _class: "Application" is a standard JupiterOne class — properties like category and vendor are inherited from the class.
  • Booleans use is{CamelCase} (isSso, isMfaEnforced).
  • Dates are epoch ms (createdOn, lastSeenOn).
  • userCount and monthlyCostUsd are 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

FieldTypeDescription
_keystringUnique identifier for this relationship. A common pattern: <fromKey>|<class>|<toKey>.
_typestringNaming pattern: <from-type>_<verb>_<to-type> (for example team_my-billing-acme_owns_application_my-billing-acme).
_classstringOne of the standard JupiterOne relationship classes (see below). Uppercase.
_fromEntityKeystringThe _key of the source entity. Must already exist or be uploaded in the same job.
_toEntityKeystringThe _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:

ClassUse for
HASContainment / membership (Team HAS User, Account HAS Application).
OWNSOwnership (Team OWNS Application).
ASSIGNEDAssignment (User ASSIGNED Role).
USESUse / consumption (Application USES DataStore).
MANAGESManagement (User MANAGES Team).
ISIdentity / mapping (User IS Person — typically mapped relationship).
ALLOWS / DENIESPolicy (Role ALLOWS Action).
PROTECTSSecurity control (Service PROTECTS Host).
EXPLOITSFindings (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, Link header. Stop when the cursor or hasMore flag indicates the end.
  • Typed responses — define interfaces matching what the API returns. Don't carry any types 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:

  1. Open your custom instance — the Jobs tab should show the run with status: FINISHED and entity / relationship counts.

  2. Run a J1QL query to confirm the data is present:

    FIND * with _integrationInstanceId = "<your-instance-id>"
  3. 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 DIFF finalize 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 _key strings. The literal text "undefined" in a key is always a bug.