Migrate from Merge to Unified.to

Merge and Unified.to both normalize third-party APIs into shared data models, and the categories overlap: HRIS, ATS, CRM, accounting, ticketing, file storage, knowledge base and chat all have Unified.to equivalents, alongside more than 20 other categories.

The biggest difference is architecture. Merge syncs data from each third-party API on a recurring schedule, and your reads return Merge's synced copy. Unified.to is real-time pass-through: every request goes to the source API and returns current data, and no end-customer data is stored on our side.

This guide maps each Merge concept to its Unified.to equivalent. For the overall plan and checklist, see Migrate to Unified.to.

Concepts

MergeUnified.to
Linked account (X-Account-Token)Connection, identified by a connection ID in the URL path
Integration (e.g. bamboohr)Integration type (e.g. bamboohr)
Category (hris, ats, crm, accounting, ticketing, filestorage, knowledgebase)Category (hris, ats, crm, accounting, ticketing, storage, kms, and more)
Common modelUnified object (API Reference)
end_user_origin_idexternal_xref
Merge LinkAuthorization component or auth URL
Sync frequencyNot applicable: reads are live
remote_data, field mappingsraw (Custom & original fields)
Passthrough (POST /api/{category}/v1/passthrough)Passthrough API (/passthrough/{connection_id}/{path})
Webhooks ({Model}.added, .changed, .removed)Native and virtual webhooks (created, updated, deleted)

What changes without a sync

Code built on Merge often assumes its sync behavior. Plan for these differences:

  • No initial sync. A new Merge linked account isn't fully readable until its first sync completes (LinkedAccount.sync_completed). A Unified.to connection can be read as soon as the user authorizes it.
  • Current data on every read. You don't need to reason about sync frequency or when Merge last synced. modified_after in Merge filters on when Merge synced a record; updated_gte in Unified.to filters on when the record changed in the source.
  • Writes are immediate. A Unified.to write goes to the source API in the same request, and the response reflects the source's result.
  • Reads call the source. If you read the same data often, keep a copy in your own database and keep it current with updated_gte or webhooks.

Authentication

Merge needs your API key and the linked account's token on each request. Unified.to needs only your API key; the connection is part of the URL.

# Merge
GET https://api.merge.dev/api/hris/v1/employees
Authorization: Bearer {MERGE_API_KEY}
X-Account-Token: {ACCOUNT_TOKEN}

# Unified.to
GET https://api.unified.to/hris/{connection_id}/employee
Authorization: Bearer {UNIFIED_API_KEY}
Merge hostUnified.to host
https://api.merge.dev (US)https://api.unified.to
https://api-eu.merge.dev (EU)https://api-eu.unified.to
https://api-ap.merge.dev (APAC)https://api-au.unified.to (Australia)

Your workspace must be created in the region you call. See API URLs.

Authorizing end users

Merge Link takes three steps: your backend creates a link token (POST /api/integrations/create-link-token), your frontend opens Merge Link with useMergeLink, and your backend exchanges the returned public token for an account token (GET /api/integrations/account-token/{public_token}).

Unified.to takes one: embed the Authorization component, or redirect the user to the auth URL. The user comes back to your success_redirect with the connection ID in the URL, and there's no token to exchange.

// Before: Merge Link
import { useMergeLink } from '@mergeapi/react-merge-link';

const { open, isReady } = useMergeLink({
    linkToken, // from POST /api/integrations/create-link-token
    onSuccess: (publicToken) => exchangeOnYourServer(publicToken), // GET /api/integrations/account-token/{public_token}
});

// After: Unified.to Authorization component
import UnifiedDirectory from '@unified-api/react-directory';

<UnifiedDirectory
    workspaceId={WORKSPACE_ID}
    categories={['hris']}
    external_xref={customerId}
    success_redirect="https://yourapp.com/integrations/callback"
/>;

Or, without a framework:

<script src="https://api.unified.to/docs/unified.js?wid=YOUR_WORKSPACE_ID&did=unified_widget&env=Production&cat=hris&uid=customer_123&surl=https%3A%2F%2Fyourapp.com%2Fintegrations%2Fcallback"></script>
<div id="unified_widget"></div>

In the embed script, cat limits the list to one category, uid sets external_xref (the equivalent of end_user_origin_id), and surl / furl set the success and failure redirects.

If you pass integration when creating a link token to skip Merge Link's integration picker, use the auth URL for that integration instead:

https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/bamboohr
    ?success_redirect=https://yourapp.com/integrations/callback
    &failure_redirect=https://yourapp.com/integrations/error
    &external_xref=customer_123
    &scopes=hris_employee_read,hris_group_read
    &redirect=1

On success, the callback URL receives id (the connection ID), state, nonce, type and sig. Verify the signature, then store the connection ID where you store the Merge account token today. Unlike an account token, a connection ID isn't a secret on its own: it only works with your workspace API key.

Moving existing connections

Merge holds your customers' credentials, so you have two options:

  • Re-authorize each customer through the Authorization component. Most teams roll this out gradually: new customers connect through Unified.to, and existing customers are asked to reconnect the next time they visit your integrations page.
  • Import credentials you hold, such as tokens issued to your own OAuth app or API keys your customers gave you, with Create connection, so the customer doesn't have to reconnect. See Move your connections.

If you registered your own OAuth apps with vendors for Merge, enter the same client ID and secret in Unified.to and add Unified.to's redirect URI to each app.

Making API calls

Merge paths include a category version and plural model names (/api/hris/v1/employees). Unified.to paths use singular object names scoped to a connection (/hris/{connection_id}/employee).

# Merge
curl "https://api.merge.dev/api/ats/v1/candidates?page_size=100" \
  -H "Authorization: Bearer $MERGE_API_KEY" \
  -H "X-Account-Token: $ACCOUNT_TOKEN"

# Unified.to
curl "https://api.unified.to/ats/$CONNECTION_ID/candidate?limit=100" \
  -H "Authorization: Bearer $UNIFIED_API_KEY"

With the SDKs:

// Merge
import { MergeClient } from '@mergeapi/merge-node-client';

const merge = new MergeClient({ apiKey: process.env.MERGE_API_KEY, accountToken });
const employees = await merge.hris.employees.list({ pageSize: 100 });

// Unified.to
import { UnifiedTo } from '@unified-api/typescript-sdk';

const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY } });
const employees = await unified.hris.listHrisEmployees({ connectionId, limit: 100 });

Because the connection is a call parameter rather than a client option, one Unified.to client serves all of your connections.

Response shape

Merge returns { "next": "…", "previous": "…", "results": [...] }. Unified.to returns the list itself as a JSON array, and a single object for single-record calls. Errors use standard HTTP status codes.

Common models

MergeUnified.to
hris/v1/employeeshris/{connection_id}/employee
hris/v1/employmentscompensation and employment fields on hris/{connection_id}/employee
hris/v1/groups, hris/v1/teamshris/{connection_id}/group
hris/v1/locationshris/{connection_id}/location
hris/v1/companieshris/{connection_id}/company
hris/v1/time-offhris/{connection_id}/timeoff
hris/v1/employee-payroll-runshris/{connection_id}/payslip
hris/v1/benefitshris/{connection_id}/benefit
ats/v1/candidatesats/{connection_id}/candidate
ats/v1/applicationsats/{connection_id}/application
ats/v1/jobsats/{connection_id}/job
ats/v1/interviewsats/{connection_id}/interview
ats/v1/scorecardsats/{connection_id}/scorecard
crm/v1/contactscrm/{connection_id}/contact
crm/v1/accountscrm/{connection_id}/company
crm/v1/opportunitiescrm/{connection_id}/deal
crm/v1/leadscrm/{connection_id}/lead
crm/v1/engagements, crm/v1/notes, crm/v1/taskscrm/{connection_id}/event
accounting/v1/invoicesaccounting/{connection_id}/invoice
accounting/v1/contactsaccounting/{connection_id}/contact
accounting/v1/accountsaccounting/{connection_id}/account
accounting/v1/journal-entriesaccounting/{connection_id}/journal
ticketing/v1/ticketsticketing/{connection_id}/ticket
filestorage/v1/files, filestorage/v1/foldersstorage/{connection_id}/file
knowledgebase/v1/articleskms/{connection_id}/page

Field names differ between the two models, so update your mappers object by object using the API Reference. Merge's remote_id is the vendor's ID; in Unified.to, the object id is usually the vendor's ID, and the original is always available in raw.__id when it isn't.

Pagination and filtering

MergeUnified.to
page_size (max 100)limit (default 100)
cursor, with nextoffset; you're done when a page returns fewer than limit
modified_afterupdated_gte
expandRelated IDs are returned on the object; get related objects by ID
include_remote_data=truefields=raw
include_deleted_data, remote_was_deleteddeleted webhooks where supported
let offset = 0;
const limit = 100;
while (true) {
    const page = await unified.hris.listHrisEmployees({
        connectionId,
        limit,
        offset,
        updatedGte: lastRun,
    });
    await save(page);
    if (page.length < limit) break;
    offset += limit;
}

See Pagination, filtering & sorting.

Remote data and field mappings

Replace include_remote_data=true with raw in the fields parameter. The vendor's original record comes back in each object's raw property:

GET /hris/{connection_id}/employee?fields=id,name,emails,raw

Where you use Merge field mappings to pull custom fields into a common model, request them with a raw. prefix, such as fields=raw,raw.cost_center, and map them in your code. To write a vendor-specific field, include it in raw on the create or update request. See Custom & original fields.

Passthrough

Merge's passthrough endpoint is per category and takes the method and path in the body. Unified.to's takes the vendor path in the URL and uses the HTTP method you call it with, and is available for every connection:

# Merge
curl -X POST https://api.merge.dev/api/hris/v1/passthrough \
  -H "Authorization: Bearer $MERGE_API_KEY" \
  -H "X-Account-Token: $ACCOUNT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "method": "GET", "path": "/v1/meta/fields" }'

# Unified.to
curl "https://api.unified.to/passthrough/$CONNECTION_ID/v1/meta/fields" \
  -H "Authorization: Bearer $UNIFIED_API_KEY"

Unified.to adds the vendor's base URL and authentication, and returns the vendor's response unchanged rather than wrapped. See Passthrough.

Webhooks

Merge webhooks are configured for your organization and fire after syncs. Unified.to webhooks are created per connection and object, and each delivery carries the changed records.

Merge eventUnified.to
Employee.addedobject_type: hris_employee, event: created
Employee.changedobject_type: hris_employee, event: updated
Employee.removedobject_type: hris_employee, event: deleted, where supported
Candidate.added / .changedobject_type: ats_candidate, event: created / updated
LinkedAccount.linkedYour success_redirect receives the new connection ID
LinkedAccount.sync_completedNot needed; the first delivery is marked INITIAL-PARTIAL / INITIAL-COMPLETE

Create one per connection, typically right after the connection is authorized:

curl -X POST https://api.unified.to/unified/webhook \
  -H "Authorization: Bearer $UNIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "connection_id": "'$CONNECTION_ID'",
    "object_type": "hris_employee",
    "event": "updated",
    "hook_url": "https://yourapp.com/webhooks/unified",
    "interval": 60
  }'

Integrations with native webhooks deliver changes as they happen. For the rest, virtual webhooks check for changes every interval minutes, independent of any sync schedule. A webhook can also backfill existing records, which replaces waiting for Merge's initial sync.

Signature verification. Replace your X-Merge-Webhook-Signature check with a check of the sig256 field in the payload body: HMAC-SHA256(workspace secret, JSON of data + nonce), base64-encoded. See Webhooks.

Deleting connections

Replace POST /api/{category}/v1/delete-account with DELETE /unified/connection/{connection_id}. After you cut over, delete each customer's Merge linked account so Merge no longer holds a synced copy of their data.

Next steps

Are we missing anything? Let us know
Was this page helpful?