IdenIden Docs
Bridge Developer Guide

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)
  1. Start -- POST .../sync/ creates a new sync session and returns a sync_id. Iden snapshots the current state of all resources so it can detect what changed.
  2. 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.
  3. 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.

FieldTypeRequiredDescription
idstringyesUnique identifier in the external system
emailstringconditionalEmail address. Required if no username is provided
usernamestringconditionalUsername. Required if no email is provided
first_namestringnoFirst name
last_namestringnoLast name
display_namestringnoDisplay name shown in Iden
statusstringnoOne of active (default), inactive, or suspended
membershipsobjectnoMap of group resource type slug to list of ref objects [{"id": "...", "name": "..."}]. name is optional if the group was already pushed
assignmentsobjectnoMap 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.

FieldTypeRequiredDescription
idstringyesUnique identifier
namestringyesGroup name
descriptionstringnoHuman-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.

FieldTypeRequiredDescription
idstringyesUnique identifier
namestringyesLicense name
descriptionstringnoHuman-readable description
max_countintegernoMaximum available seats. 0 means unlimited
used_countintegernoNumber of seats currently in use
is_paidbooleannoWhether this is a paid license
is_unlimitedbooleannoWhether 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:

  1. All group resource types
  2. All license resource types
  3. 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}"
done

Full 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 status

Completion 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

ScenarioUse
Full snapshot sync (push all resources)Complete
Partial sync failed midwayAbandon
Incremental sync (only changed records)Abandon
Testing your connector during developmentAbandon (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

StatusDescription
in_progressPages are being pushed. The session is open
completingYou called complete and the cleanup is running asynchronously
completedCleanup finished. Stale records have been marked inactive
errorThe session encountered an unrecoverable error during cleanup
abandonedThe 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, name for groups/licenses, email/username for accounts) -- returns 400
  • 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"
fi

Starting 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

  1. Always push groups and licenses before accounts. Account memberships and assignments reference group and license IDs, so those records must exist first.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

On this page