# How to decide between a unified API and direct integrations

> Score one real customer request against a unified common model and against direct vendor calls, then check the escape hatch and token custody before you sign.

Source: https://voxgig.com/howto/unified-api-vendor-or-direct-integrations

- Audience: api-consumer
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Take one real customer request, write down the fields and writes it needs, and map every item through a unified API's common model and through each vendor's own API. Count the fields the model drops, check that a passthrough or raw-data escape hatch covers them, and find out who holds the OAuth tokens. Pick the unified API when the model carries the request, and direct calls when it does not.

---
## You will need

Node 22 or later, and the list of vendors your customers keep asking for, with one real request
attached to each. Verified 2026-10-21 against Node 22.22.0. The
common model in the sample follows the field list
[Merge publishes for its CRM Contact](https://docs.merge.dev/merge-unified/crm/common-models/contacts/the-contact-object),
and the vendor paths follow
[HubSpot's contact properties](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide)
and [Salesforce's Contact object](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_contact.htm).
The scoring runs over files you edit, so no code on this page calls a vendor.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Direct per-vendor SDKs](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide) | One to three vendors, deep use of each, and features no common model expresses | An OAuth app, a token store, and a field map per vendor, and every vendor's API change lands on your calendar | The vendor list is long and your use of each is shallow |
| [Merge](https://docs.merge.dev/merge-unified/crm/overview) | Many vendors in one category and a request the common model already covers | The model decides which fields exist, and Remote Data and Passthrough for the rest are sold on the Professional and Enterprise plans | A customer needs vendor-specific fields on every record |
| [Nango](https://nango.dev/docs/getting-started/use-cases/unified-apis) | You want managed auth and a proxy, with a model shaped for your product rather than a vendor's | You write and maintain the per-API mapping Functions yourself, so the normalization is a codebase you own | You have no team to write integration code, or you need a model on day one |
| [Paragon](https://docs.useparagon.com/managed-sync/overview) | Normalized sync for file storage, documents, or CRM, plus workflows and actions for the rest | A Synced Object schema per category, and a hosted or on-prem platform that your workflows and credentials live inside | Your request is a handful of API calls that need no sync pipeline |

The common model is the bargain. The Merge model gives you one Contact shape across every CRM it
supports. A field it lacks reaches you only through Remote Data, a field mapping, or a passthrough
call, each on a paid plan. Direct SDKs give you every field a vendor has. The bill is one OAuth
app, one token store, and one field map per vendor, maintained for as long as the customer stays.
Nango and Paragon sit between the two, and the steps below show where.

## Write the request down as fields, not features

One real request beats a feature checklist, because a checklist asks whether a vendor supports
contacts and a request asks for the renewal date. The sample's request comes from a dental group
with two CRM products and two fields to show beside the name and email.

```json title="request.json"
{
  "customer": "Harbor Dental Group",
  "crm": "HubSpot at head office, Salesforce at two acquired practices",
  "wants": "Show each contact's lifecycle stage and renewal date beside the name and email in the app.",
  "fields": ["first_name", "last_name", "email", "lifecycle_stage", "renewal_date"]
}
```

Name the fields in your product's vocabulary rather than a vendor's. `lifecycle_stage` is
HubSpot's idea of a contact, and the scoring step finds out whether anyone else shares it.

List writes as rows too, one per operation. The passthrough call is the hatch for a write the
model lacks. It carries the vendor's own request body, so that write is a direct integration with
another company in the path.

## Score the request against the model and against each vendor

`map.mjs` gives every field a verdict from the common model and from each vendor. A field with
no path in the model is dropped: the unified response never carries it, whatever the vendor
holds.

```js title="map.mjs"
// A path the table itself holds, because paths.constructor is Object's function
// and would score a field of that name as covered.
const own = (paths, field) => (Object.hasOwn(paths, field) ? paths[field] : undefined)

// One verdict per field the customer asked for: where it lives in the common
// model, and where it lives in each vendor's own API. A field the common model
// has no path for is "dropped": the unified response never carries it, and
// only an escape hatch (remote data, a field mapping, a passthrough call)
// gets it back.
export function score(request, unified, vendors) {
  return request.fields.map((field) => {
    const path = own(unified.paths, field)
    const direct = {}
    for (const [name, vendor] of Object.entries(vendors)) {
      direct[name] = describe(own(vendor.paths, field))
    }
    return {
      field,
      unified: path ? { status: 'covered', path } : { status: 'dropped', path: null },
      direct,
    }
  })
}
```

```bash
node score.mjs
```

```text output
field           common model                      HubSpot                     Salesforce
first_name      first_name                        properties.firstname        FirstName
last_name       last_name                         properties.lastname         LastName
email           email_addresses[].email_address   properties.email            Email
lifecycle_stage dropped: not in the model         properties.lifecyclestage   missing: no standard field
renewal_date    dropped: not in the model         properties.renewal_date *   Renewal_Date__c *

* a custom field: it exists only where the customer created it

common model: 3 of 5 fields covered, 2 reachable only through an escape hatch
direct calls: 5 fields for 2 vendors = 10 paths to maintain, 100 at 20 vendors
```

Three of the five fields are in the model, and they are the three every CRM has. The two the
customer asked for by name are the two the model drops. HubSpot carries one of them as a
standard property and the other only where the customer created it. Salesforce has no standard
Contact field for a lifecycle stage at all, so direct integration meets the same gap one vendor
later. The renewal date is a custom field there too, with the suffix Salesforce gives every custom
field.[^1]

The last line prices the direct route: ten paths for two vendors, a hundred for the twenty sales
keeps mentioning, and each one edited when a vendor renames a property.

## Find out who holds the tokens and where the escape hatch is

Token custody decides who can call the vendor when the platform is down, who answers the
customer's security questionnaire, and what leaves with you if you churn.

Merge holds them. Your backend stores an `account_token` from
[Merge Link](https://docs.merge.dev/merge-unified/merge-link), and the vendor's own credentials
stay with Merge. A [passthrough request](https://docs.merge.dev/merge-unified/supplemental-data/passthrough-request/overview)
uses the stored tokens on your behalf and speaks the vendor's own request and response format.

Nango holds them and hands them over on request. A Connection stores one user's credentials and
keeps them refreshed, and
[`GET /connections/{connectionId}`](https://nango.dev/docs/reference/backend/http-api/connections/get)
returns them, with the refresh token only when you ask for it. The Nango documentation advises
fetching a token right before each use, or not holding it at all by calling the vendor through the
[proxy](https://nango.dev/docs/guides/platform/proxy-requests). The proxy is also the part of the
product its free self-hosted tier keeps.[^2]

Paragon holds them. The [Connect Portal](https://docs.useparagon.com/connect-portal/overview)
runs the OAuth flow, and the [Proxy API](https://docs.useparagon.com/apis/proxy) calls the vendor
on behalf of a Connected User with a Paragon User Token, against a per-integration list of
permitted hosts.[^3]

You hold them with direct SDKs: storage, refresh, rotation, and one OAuth app registration per
vendor.

Ask for the escape hatch while you can still negotiate: get the one your scored request needs
written into the contract, and run the `renewal_date` row through it on a trial account first.

## Check it worked

`map.test.mjs` pins the verdicts and the drop. The fifth test is the one to keep. The normalized
contact has no `renewal_date` field, and the function that dropped it returned normally, which is
the failure a customer reports a month after launch. The seventh scores a field named
`constructor` as dropped, because the scoring reads only the keys a table holds.

```bash
node --test map.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 89.30084
```

## When it goes wrong

The customer sees the renewal date in their CRM and a blank in your app. Normalization copied the
fields the model has a place for and dropped the rest without an error. The sample's normalizer
does what a platform does.

```js title="normalize.mjs"
export function toUnified(vendor, raw, { includeRemoteData = false } = {}) {
  const p = raw.properties
  const contact = {
    remote_id: raw.id,
    first_name: p.firstname ?? null,
    last_name: p.lastname ?? null,
    email_addresses: p.email ? [{ email_address: p.email, email_address_type: null }] : [],
  }
  if (includeRemoteData) {
    // The raw record rides along under the vendor's own names, unnormalized.
    contact.remote_data = [{ path: `/crm/v3/objects/${vendor.object}`, data: raw }]
  }
  return contact
}
```

```bash
node pitfall.mjs
```

```text output
vendor record carries:   firstname, lastname, email, lifecyclestage, hs_lead_status, renewal_date
unified contact carries: remote_id, first_name, last_name, email_addresses
dropped, with no error:  lifecyclestage, hs_lead_status, renewal_date
with remote_data:        remote_data[0].data.properties.lifecyclestage = "customer"
```

The last line shows the escape hatch, and it shows the cost. `lifecyclestage` comes back under
HubSpot's name, inside HubSpot's shape, so the code that reads it is a HubSpot integration again,
with the platform's invoice on top. Read raw data by the `path` it came from, because the array it
arrives in has no fixed order.[^4]

The escape hatch is on a plan you did not buy. Passthrough, Remote Data, and Field Mapping are
Professional and Enterprise features at Merge, so a plan priced on the model alone leaves the
dropped fields out of reach. Price the plan that has the hatch, not the plan that has the model.

The catalog lists the vendor, but the operation your customer needs is missing from that vendor's
entry. Coverage is per vendor and per object, so read the entry for both together and run your
request through it on a trial account.

## When not to do this

Do not buy a unified API for one vendor. The common model is a tax paid on every field for the
sake of a second vendor you do not have. The first row of the table costs less until the third
CRM arrives.

Do not route a customer's vendor-specific objects through a common model and promise fidelity. A
dental group's recall schedule or a logistics firm's load board is a custom object. A unified model
carries it only as raw data or through a passthrough, which puts you back on the vendor's API with
another company in the path.

Do not let a platform hold tokens your contract says you must hold. Data residency clauses, a
customer's security review, and what you keep when you leave the platform all turn on custody.
The answer differs between Merge, hosted Nango, and a self-hosted Nango.

Do not write your own unified model with two vendors behind it. Nango advises designing the model
around your product rather than a lowest common denominator. A model abstracted from two examples
is a model of those two vendors under a different name.

## Related how-tos

- [Evaluate a third-party SDK before you depend on it](/howto/evaluate-a-third-party-sdk)

- [Audit the scopes of the tokens your integrations use](/howto/audit-the-scopes-of-integration-tokens)

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

- [Kill-switch one vendor integration without a deploy](/howto/kill-switch-a-vendor-without-a-deploy)

- [Choose between an egress gateway and an in-process library](/howto/choose-between-an-egress-gateway-and-a-library)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed. The common model, the vendor paths, and the escape hatches in the sample are read from
the linked documentation and modeled in JSON. No request was sent to any vendor during
verification.

[^1]: Salesforce appends two underscores and a lowercase c to the API name of a custom object or
custom field. A field labeled Renewal Date in the user interface is `Renewal_Date__c` to the API.
The [Custom Objects reference](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_custom_objects.htm)
gives the example of an object labeled Issue appearing as `Issue__c`. It adds that a managed
package prefixes a namespace to the name as well, so the same field acquires a third name once it
is packaged.

[^2]: The Nango [self-hosting page](https://nango.dev/docs/guides/platform/self-hosting/self-hosting)
lists two Enterprise routes, one run by Nango inside your own cloud account and one you run from a
Helm chart. A free option is described as being for hobby projects. It keeps API auth and the
proxy and drops syncs, webhooks, and the rest, so the part of the product that holds and forwards
tokens is the part given away.

[^3]: Paragon's [Proxy API](https://docs.useparagon.com/apis/proxy) forwards any HTTP verb to a
provider on behalf of a Connected User, which sounds like a door with no frame. It has one: each
integration carries a list of permitted hosts, and a request to any other domain fails with a
message that the domain is invalid. A proxy that can call anything keeps a list of what anything
is.

[^4]: [Remote Data](https://docs.merge.dev/merge-unified/supplemental-data/remote-data) at Merge
arrives as an array of objects, each carrying the vendor endpoint it came from under `path` and the
raw response under `data`. The documentation adds that the array's order can change over time and
advises matching on `path` rather than on an index. The hatch is documented down to the order you
may not rely on.