Sync Guide
Deep dive into the paginated sync protocol, record schemas, and cleanup behavior.
This guide covers the full mechanics of the Bridge sync protocol -- how sessions work, how to structure records, how to handle large datasets, and what happens when a sync completes or is abandoned.
Session Lifecycle
Every sync follows a three-phase lifecycle:
start_sync --> push page(s) --> complete_sync (or abandon_sync)- Start --
POST .../sync/creates a new sync session and returns async_id. Iden snapshots the current state of all resources so it can detect what changed. - Push pages --
PUT .../sync/{sync_id}/{resource_type_slug}/sends batches of records. You can push multiple pages per resource type. Records accumulate across pages within the session. - Complete --
POST .../sync/{sync_id}/complete/triggers an asynchronous cleanup pass. Or abandon --POST .../sync/{sync_id}/abandon/skips cleanup and keeps everything as-is.
The sync uses PUT semantics: when you complete a session, the set of records you pushed becomes the full truth. Any record that existed before but was not included in the session is marked inactive. This is how Iden detects deprovisioned accounts, deleted groups, and expired licenses.
Record Schemas
Each resource type has its own record schema. All records require an id field -- this is the unique identifier for the resource in your external system.
Account Records
Account records represent user accounts. Each record must include id and at least one of email or username.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique identifier in the external system |
email | string | conditional | Email address. Required if no username is provided |
username | string | conditional | Username. Required if no email is provided |
first_name | string | no | First name |
last_name | string | no | Last name |
display_name | string | no | Display name shown in Iden |
status | string | no | One of active (default), inactive, or suspended |
memberships | object | no | Map of group resource type slug to list of ref objects [{"id": "...", "name": "..."}]. name is optional if the group was already pushed |
assignments | object | no | Map of license resource type slug to list of ref objects [{"id": "...", "name": "..."}]. name is optional if the license was already pushed |
Example:
{
"id": "u1",
"email": "alice@acme.com",
"username": "alice",
"first_name": "Alice",
"last_name": "Smith",
"display_name": "Alice Smith",
"status": "active",
"memberships": {
"team": [{"id": "g1"}, {"id": "g2"}],
"department": [{"id": "d1"}]
},
"assignments": {
"license": [{"id": "lic-pro"}]
}
}Group Records
Group records represent teams, roles, departments, or any organizational unit. Each record must include id and name.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique identifier |
name | string | yes | Group name |
description | string | no | Human-readable description |
Example:
{
"id": "g1",
"name": "Engineering",
"description": "Core engineering team"
}License Records
License records represent software licenses or entitlements. Each record must include id and name.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique identifier |
name | string | yes | License name |
description | string | no | Human-readable description |
max_count | integer | no | Maximum available seats. 0 means unlimited |
used_count | integer | no | Number of seats currently in use |
is_paid | boolean | no | Whether this is a paid license |
is_unlimited | boolean | no | Whether the license has unlimited seats |
Example:
{
"id": "lic-pro",
"name": "Pro Plan",
"description": "Full-featured professional license",
"max_count": 50,
"used_count": 23,
"is_paid": true,
"is_unlimited": false
}Ordering Recommendations
You can push resource types in any order. If you push accounts with memberships that reference groups not yet synced, those groups will be created with minimal info (just the ID). The full group details are filled in when the group resource type is synced later.
That said, pushing groups and licenses before accounts is recommended so that group and license records have full details (name, description, etc.) by the time account memberships reference them.
Suggested push order:
- All group resource types
- All license resource types
- All account resource types
Memberships and Assignments
Memberships and assignments let you map accounts to groups and licenses. Both fields use the same structure: an object where each key is a resource type slug, and the value is a list of ref objects with id (and optionally name).
Memberships (accounts to groups)
The memberships field on an account record maps the account to groups. The key must match the slug of a registered group resource type, and each ref object's id must match the id of a group record pushed in the same session. The name field is optional if the group was already pushed.
{
"id": "u1",
"email": "alice@acme.com",
"memberships": {
"team": [{"id": "g1"}, {"id": "g2"}],
"department": [{"id": "dept-eng"}]
}
}In this example, teams and departments are slugs of registered group resource types. Alice is a member of groups g1, g2, and dept-eng.
Assignments (accounts to licenses)
The assignments field works the same way, but for license resource types.
{
"id": "u1",
"email": "alice@acme.com",
"assignments": {
"license": [{"id": "lic-pro"}],
"addon": [{"id": "addon-analytics"}, {"id": "addon-export"}]
}
}Here, licenses and add-ons are slugs of registered license resource types. Alice is assigned to the lic-pro license and two add-ons.
Combining memberships and assignments
You can include both fields on a single account record:
{
"id": "u1",
"email": "alice@acme.com",
"first_name": "Alice",
"last_name": "Smith",
"memberships": {"team": [{"id": "g1"}]},
"assignments": {"license": [{"id": "lic-pro"}]}
}If an account has no group or license relationships, omit the fields entirely or pass empty objects.
Page Size Limits
Each PUT request can contain at most 100 records. If you push more than 100, the API returns a 422 validation error.
Within each record, the memberships and assignments fields are also limited: each slug key can contain at most 100 ref objects. For example, a single account can be a member of up to 100 groups per group resource type.
Pagination for Large Datasets
If you have more than 100 records for a resource type, push them across multiple pages. Each PUT call appends to the session -- records accumulate across all pages for a given resource type. There is no limit to the number of pages you can push.
Python helper
import requests
BASE_URL = "https://developer.idenhq.com/org/acme"
HEADERS = {
"Authorization": "Api-Key <your-api-key>",
"Content-Type": "application/json",
}
def push_records(app_id, sync_id, slug, all_records, page_size=100):
"""Push records in pages of up to 100."""
total_created = 0
total_updated = 0
for i in range(0, len(all_records), page_size):
page = all_records[i : i + page_size]
response = requests.put(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/{sync_id}/{slug}/",
headers=HEADERS,
json={"records": page},
)
response.raise_for_status()
result = response.json()
total_created += result["created"]
total_updated += result["updated"]
print(f" Page {i // page_size + 1}: {result}")
print(f" Total: {total_created} created, {total_updated} updated")
return {"created": total_created, "updated": total_updated}curl with pagination
For large datasets in shell scripts, split your JSON file and loop:
# Assuming records are stored in groups_page1.json, groups_page2.json, etc.
for PAGE_FILE in groups_page*.json; do
curl -X PUT "https://developer.idenhq.com/org/acme/api/v1/bridge/apps/${APP_ID}/sync/${SYNC_ID}/team/" \
-H "Authorization: Api-Key <your-api-key>" \
-H "Content-Type: application/json" \
-d @"${PAGE_FILE}"
doneFull paginated sync example
Here is a complete Python example that syncs a large dataset with groups, licenses, and accounts:
import time
import requests
BASE_URL = "https://developer.idenhq.com/org/acme"
HEADERS = {
"Authorization": "Api-Key <your-api-key>",
"Content-Type": "application/json",
}
def push_records(app_id, sync_id, slug, all_records, page_size=100):
"""Push records in pages of up to 100."""
for i in range(0, len(all_records), page_size):
page = all_records[i : i + page_size]
response = requests.put(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/{sync_id}/{slug}/",
headers=HEADERS,
json={"records": page},
)
response.raise_for_status()
print(f" {slug} page {i // page_size + 1}: {response.json()}")
def sync_app(app_id, groups, licenses, accounts):
"""Run a complete paginated sync."""
# Start session
sync_id = requests.post(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/",
headers=HEADERS,
).json()["sync_id"]
print(f"Started sync: {sync_id}")
# Push groups and licenses first, then accounts
push_records(app_id, sync_id, "team", groups)
push_records(app_id, sync_id, "license", licenses)
push_records(app_id, sync_id, "account", accounts)
# Complete the sync
requests.post(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/{sync_id}/complete/",
headers=HEADERS,
).raise_for_status()
print("Sync completing...")
# Poll until done
while True:
status = requests.get(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/{sync_id}/",
headers=HEADERS,
).json()
print(f" Status: {status['status']}")
if status["status"] in ("completed", "error", "abandoned"):
break
time.sleep(5)
return statusCompletion vs Abandonment
You have two options for ending a sync session, and the choice determines whether Iden removes stale records.
Complete (POST .../sync/{sync_id}/complete/)
Completing a sync tells Iden: "the records I pushed are the complete set." Iden then runs an asynchronous cleanup pass that compares what you pushed against what existed before. Any record not included in the session is marked inactive (removed).
Use completion for regular syncs where you are pushing a full snapshot of all resources from the external system.
Response: 202 Accepted -- cleanup runs asynchronously. Poll GET .../sync/{sync_id}/ to check progress.
Abandon (POST .../sync/{sync_id}/abandon/)
Abandoning a sync tells Iden: "keep everything I pushed, but do not remove anything." Records that were pushed are upserted normally, but no removal pass runs. Existing records that were not included in the session remain in their current state.
Use abandonment when:
- Your connector encountered an error partway through and you do not want a partial push to trigger removals.
- You are doing an incremental or partial sync where you only push changed records, not the full set.
- You want to bail out of a sync without side effects.
Response: 204 No Content
Choosing between complete and abandon
| Scenario | Use |
|---|---|
| Full snapshot sync (push all resources) | Complete |
| Partial sync failed midway | Abandon |
| Incremental sync (only changed records) | Abandon |
| Testing your connector during development | Abandon (safer while iterating) |
Polling for Completion
After calling complete, the cleanup runs asynchronously. Poll the sync status endpoint to check progress.
curl:
curl "https://developer.idenhq.com/org/acme/api/v1/bridge/apps/${APP_ID}/sync/${SYNC_ID}/" \
-H "Authorization: Api-Key <your-api-key>"Python:
import time
import requests
BASE_URL = "https://developer.idenhq.com/org/acme"
HEADERS = {
"Authorization": "Api-Key <your-api-key>",
"Content-Type": "application/json",
}
def wait_for_sync(app_id, sync_id, poll_interval=5, timeout=300):
"""Poll until the sync session reaches a terminal state."""
elapsed = 0
while elapsed < timeout:
response = requests.get(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/{sync_id}/",
headers=HEADERS,
)
response.raise_for_status()
data = response.json()
status = data["status"]
print(f"Sync status: {status}")
if status == "completed":
print("Sync completed successfully.")
for rt in data.get("progress", []):
print(f" {rt['name']}: {rt['synced_count']} records")
return data
if status == "error":
error = data.get("error", {})
raise RuntimeError(
f"Sync failed: {error.get('error_code')} - {error.get('message')}"
)
if status == "abandoned":
print("Sync was abandoned.")
return data
time.sleep(poll_interval)
elapsed += poll_interval
raise TimeoutError(f"Sync did not complete within {timeout} seconds")Status values
| Status | Description |
|---|---|
in_progress | Pages are being pushed. The session is open |
completing | You called complete and the cleanup is running asynchronously |
completed | Cleanup finished. Stale records have been marked inactive |
error | The session encountered an unrecoverable error during cleanup |
abandoned | The session was abandoned. No removal pass was performed |
When the status is error, the response includes an error object:
{
"sync_id": "d4e5f6a7-...",
"status": "error",
"progress": [...],
"error": {
"error_code": "CLEANUP_FAILED",
"message": "Cleanup task failed after maximum retries."
}
}Error Handling
Validation errors (400 and 422)
If a page of records fails Pydantic schema validation (missing required fields, wrong types), the API returns 400 Bad Request with a detail field:
{"detail": "Field required"}If records pass schema validation but fail business-rule validation (unknown slugs, integrity errors), the API returns 422 Unprocessable Entity with a detail field describing the specific record and problem:
{"detail": "Record 'u1': unknown membership slug 'nonexistent'"}Common validation failures:
- Missing required field (
id,namefor groups/licenses,email/usernamefor accounts) -- returns400 - Unknown membership or assignment slug (the slug does not match any registered resource type) -- returns
422 - Too many records in a single page (over 100) -- returns
400 - Too many membership or assignment ref objects per slug per record (over 100) -- returns
400
Handling validation errors in code
Python:
response = requests.put(
f"{BASE_URL}/api/v1/bridge/apps/{app_id}/sync/{sync_id}/account/",
headers=HEADERS,
json={"records": accounts},
)
if response.status_code in (400, 422):
error_data = response.json()
print(f"Validation error: {error_data.get('detail')}")
# Decide whether to fix and retry, or abandon the session
else:
response.raise_for_status()
print(f"Pushed successfully: {response.json()}")curl:
# The response body contains the error details on 422
RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
"https://developer.idenhq.com/org/acme/api/v1/bridge/apps/${APP_ID}/sync/${SYNC_ID}/account/" \
-H "Authorization: Api-Key <your-api-key>" \
-H "Content-Type: application/json" \
-d '{"records": [{"id": "u1"}]}')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
if [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "422" ]; then
echo "Validation failed: $BODY"
fiStarting a New Sync Cancels Previous Sessions
Starting a new sync session automatically cancels any previous incomplete session for the same app. This means:
- If a previous session is
in_progress, it is cancelled. Records pushed to that session are discarded and no cleanup runs. - You do not need to explicitly abandon a stale session before starting a new one.
- Only one sync session can be active per app at any time.
This protects against stuck sessions and simplifies retry logic -- if your connector crashes mid-sync, simply start a new session on the next run.
Best Practices
- Always push groups and licenses before accounts. Account memberships and assignments reference group and license IDs, so those records must exist first.
- Use complete for full syncs, abandon for partial syncs. If you push a complete snapshot every time, use complete so that removed resources are detected. If you only push deltas, use abandon to avoid removing records you did not include.
- Handle 400/422 errors per page. If one page fails validation, fix the invalid records and retry that page. You do not need to restart the entire session.
- Poll with a reasonable interval. A 5-second polling interval works well for most datasets. For very large syncs (thousands of records), the cleanup phase may take longer.
- Set a timeout on polling. Do not poll indefinitely. If the sync does not complete within a reasonable window, log the error and alert your team.
- Use pagination for datasets over 100 records. Split large datasets into pages of up to 100 records each. There is no limit on the number of pages per resource type.