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
| Merge | Unified.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 model | Unified object (API Reference) |
end_user_origin_id | external_xref |
| Merge Link | Authorization component or auth URL |
| Sync frequency | Not applicable: reads are live |
remote_data, field mappings | raw (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_afterin Merge filters on when Merge synced a record;updated_gtein 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_gteor 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 host | Unified.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
| Merge | Unified.to |
|---|---|
hris/v1/employees | hris/{connection_id}/employee |
hris/v1/employments | compensation and employment fields on hris/{connection_id}/employee |
hris/v1/groups, hris/v1/teams | hris/{connection_id}/group |
hris/v1/locations | hris/{connection_id}/location |
hris/v1/companies | hris/{connection_id}/company |
hris/v1/time-off | hris/{connection_id}/timeoff |
hris/v1/employee-payroll-runs | hris/{connection_id}/payslip |
hris/v1/benefits | hris/{connection_id}/benefit |
ats/v1/candidates | ats/{connection_id}/candidate |
ats/v1/applications | ats/{connection_id}/application |
ats/v1/jobs | ats/{connection_id}/job |
ats/v1/interviews | ats/{connection_id}/interview |
ats/v1/scorecards | ats/{connection_id}/scorecard |
crm/v1/contacts | crm/{connection_id}/contact |
crm/v1/accounts | crm/{connection_id}/company |
crm/v1/opportunities | crm/{connection_id}/deal |
crm/v1/leads | crm/{connection_id}/lead |
crm/v1/engagements, crm/v1/notes, crm/v1/tasks | crm/{connection_id}/event |
accounting/v1/invoices | accounting/{connection_id}/invoice |
accounting/v1/contacts | accounting/{connection_id}/contact |
accounting/v1/accounts | accounting/{connection_id}/account |
accounting/v1/journal-entries | accounting/{connection_id}/journal |
ticketing/v1/tickets | ticketing/{connection_id}/ticket |
filestorage/v1/files, filestorage/v1/folders | storage/{connection_id}/file |
knowledgebase/v1/articles | kms/{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
| Merge | Unified.to |
|---|---|
page_size (max 100) | limit (default 100) |
cursor, with next | offset; you're done when a page returns fewer than limit |
modified_after | updated_gte |
expand | Related IDs are returned on the object; get related objects by ID |
include_remote_data=true | fields=raw |
include_deleted_data, remote_was_deleted | deleted 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 event | Unified.to |
|---|---|
Employee.added | object_type: hris_employee, event: created |
Employee.changed | object_type: hris_employee, event: updated |
Employee.removed | object_type: hris_employee, event: deleted, where supported |
Candidate.added / .changed | object_type: ats_candidate, event: created / updated |
LinkedAccount.linked | Your success_redirect receives the new connection ID |
LinkedAccount.sync_completed | Not 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
- Follow the migration plan and checklist
- Browse Supported integrations to confirm coverage
- Questions? Email hello@unified.to or join our Discord