> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbitforms.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Two-way Sync

> Keep contacts, companies and deals in step between Orbit and your app: a field mapping, change events, your own writes told apart, and a catch-up feed.

A two-way sync lets a workspace map fields between Orbit and your app ("Orbit field" on one side, "your field" on the other), choose which way each one flows, and then forget about it: a change on either side shows up on the other within about a minute. The mapping screen and the sync logic live in your app. Orbit gives you the pieces: the workspace's fields, the records, change events, a way to tell your own writes apart, and a feed to catch up on anything you missed.

## Scopes

| Scope | For |
| - | - |
| `crm:read` | The workspace's fields and pipelines, for the mapping screen |
| `contacts:read`, `contacts:write` | Contacts (add `companies:*` and `deals:*` for those objects) |
| `webhooks:write` | Subscribing to change events |
| `team:read` | `GET /api/v1/me`: the workspace's id and your app's id |

Adding scopes to an approved app is a [change request](https://docs.orbitforms.ai/apps/app-review.md#changing-an-approved-app); workspaces that installed the app before grant the new scopes when they reconnect.

## 1. Show the workspace's fields

`GET /api/v1/crm/schema` returns every standard and custom field per object with its type and options (a select's choices, a teammate field's members). That is the Orbit column of your mapping table. Read it when the mapping screen opens, and again before a sync run, rather than caching it for good: workspaces rename, add and delete fields. On companies and deals a write that names a field that no longer exists answers `400`; on contacts the key is kept as a plain value with no field behind it, so check the schema rather than relying on an error. Flag that mapping row instead of retrying.

Your app can also create the fields it needs (`POST /api/v1/crm/fields`, `crm:write`), say a "Last ticket status" field filled from your side.

## 2. Link records

Match records once, then keep both ids:

* Contacts by email: `GET /api/v1/contacts?email=ada@example.com` (exact, any case).
* Companies by domain: `GET /api/v1/companies?domain=acme.com`.
* Then store your record's id on the Orbit record: `PATCH /api/v1/contacts/:id` with `{ "external_source": "your_app", "external_id": "u_8812" }` (the same on companies and deals). It lands in `external_ids` next to any other system's id, comes back on every read and every webhook, and finds the record later: `GET /api/v1/contacts?external_source=your_app&external_id=u_8812`.

Keep your own map from your ids to Orbit ids as well; `external_ids` is the fallback when your map and Orbit disagree.

## 3. Hear about changes

Subscribe the workspace once, after it connects:

```bash theme={null}
curl -X POST https://orbitforms.ai/api/v1/webhooks \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example/orbit/webhooks",
    "events": ["contact.created", "contact.updated", "contact.deleted", "contact.restored",
               "company.created", "company.updated", "company.deleted", "company.restored"]
  }'
```

`contact.updated` and the other `*.updated` events list what changed in `data.changes` as `{ field: { from, to } }`. Custom fields arrive as one entry, `custom_fields`, with the whole object before and after: compare the keys your mapping uses. Deliveries are signed (check `X-Orbit-Signature`), at least once (de-duplicate on the envelope's `id`) and not ordered (use `occurred_at`). See [Webhooks](https://docs.orbitforms.ai/apps/webhooks.md) and [Events](https://docs.orbitforms.ai/developers/webhooks/events.md).

## 4. Skip your own writes

Every change your app makes through the API raises the same events as anyone else's, and you receive them. They carry your app's id in `actor.app_id`:

```js theme={null}
const { data: me } = await orbit.get('/api/v1/me') // once per connection
const MY_APP_ID = me.credential.app.id

function onOrbitEvent(event) {
  if (event.actor.app_id === MY_APP_ID) return // our own write coming back
  applyToOurSide(event)
}
```

Without this check, two systems that store a value slightly differently (`"5"` and `5`, a phone number with and without formatting) overwrite each other for ever. Orbit also raises no event for a write that changes nothing, so an echo that slips through stops after one round.

## 5. Write to Orbit

* Send only the mapped fields that changed. `custom_fields` in a `PATCH` merges into what is stored; `null` removes a key.
* Orbit normalizes some values: emails are lowercased, phones are stored in E.164 for the workspace's country when they parse, values are converted to their field's type (a number field stores `5` for `"5"`), and a select value must be one of the field's options. Keep what the response returns as your copy of the Orbit side, so the next comparison is against what Orbit actually holds.
* A `404` means the record was deleted in Orbit (or is in Recently deleted): unlink it, or wait for `contact.restored`.

## 6. Catch up

Webhook deliveries give up after their retries, and your endpoint will be down sometimes. Read the [changes feed](https://docs.orbitforms.ai/developers/api/crm.md#changes-feed) every so often, say every 15 minutes, and apply whatever you missed:

```bash theme={null}
curl "https://orbitforms.ai/api/v1/contacts?updated_since=2026-10-30T11:45:00Z&per_page=100" \
  -H "Authorization: Bearer ACCESS_TOKEN"
# then, while meta.has_more:
curl "https://orbitforms.ai/api/v1/contacts?updated_since=2026-10-30T11:45:00Z&per_page=100&cursor=NEXT_CURSOR" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

Start each run from when the previous run started, minus a few minutes, and treat records you already applied as no-ops. The feed lists records changed by anyone, your app included: compare against your copy rather than writing back what you already have.

## 7. The first sync

Moving a workspace's existing records in one go is an import: `POST /api/v1/contacts/import` (and `/companies/import`, `/deals/import`) takes 1,000 rows a call, with `external_source` and an `external_id` per row so a re-run updates instead of duplicating. Imported records are not sent to webhooks unless the workspace turned that on, so your first sync does not come back to you as thousands of events. Use the imports rather than single creates for this: single creates are capped per hour (below).

## Direction and conflicts

These are your app's settings, per mapping row; Orbit applies whatever you write.

| Setting | Meaning |
| - | - |
| Orbit → your app | Orbit is the source; changes in your app are overwritten on the next Orbit change |
| Your app → Orbit | The reverse |
| Two-way | Both directions, with a rule for when both sides changed since the last sync: **Orbit wins**, **your app wins**, or **latest change wins** |

For the first sync, when both sides already hold a value, ask the workspace which side to keep. "Latest change wins" needs a time per field: Orbit keeps `updated_at` per record, not per field, so record the `occurred_at` of each change event per mapped field and compare those.

## Limits

| What | Limit |
| - | - |
| Writes (`POST`, `PATCH`, `DELETE`) | 300 per minute per workspace for your app |
| Contact creates (`POST /api/v1/contacts`) | 100 per hour per workspace, 1,000 once your app is approved. Your budget is separate from the person's own |
| Imports | 100,000 rows per workspace per hour |

A `429` carries `Retry-After`. Read limits and the rest are on [Rate limits](https://docs.orbitforms.ai/developers/api/rate-limits.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.