How-to › Operate integrations in production

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.

Audience
API consumer
Level
beginner
Topic
Control outbound integrations from one place
Verified

A customer signs on the condition that your product syncs with their CRM, and the next customer names a different one. Your only integration is written against one vendor’s field names, and sales has a list of six more. Each one costs an OAuth app, a token store, and a field map before the first record syncs.

What you get

You will end up with one customer request scored field by field against a unified common model and two vendors’ APIs, plus a test showing what the model drops. This is for you if customers keep asking for a CRM you have not integrated.

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, and the vendor paths follow HubSpot’s contact properties and Salesforce’s Contact object. The scoring runs over files you edit, so no code on this page calls a vendor.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Direct per-vendor SDKsOne to three vendors, deep use of each, and features no common model expressesAn OAuth app, a token store, and a field map per vendor, and every vendor’s API change lands on your calendarThe vendor list is long and your use of each is shallow
MergeMany vendors in one category and a request the common model already coversThe model decides which fields exist, and Remote Data and Passthrough for the rest are sold on the Professional and Enterprise plansA customer needs vendor-specific fields on every record
NangoYou want managed auth and a proxy, with a model shaped for your product rather than a vendor’sYou write and maintain the per-API mapping Functions yourself, so the normalization is a codebase you ownYou have no team to write integration code, or you need a model on day one
ParagonNormalized sync for file storage, documents, or CRM, plus workflows and actions for the restA Synced Object schema per category, and a hosted or on-prem platform that your workflows and credentials live insideYour 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.

{
  "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.

// 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,
    }
  })
}
node score.mjs
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, and the vendor’s own credentials stay with Merge. A passthrough request 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} 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. The proxy is also the part of the product its free self-hosted tier keeps.2

Paragon holds them. The Connect Portal runs the OAuth flow, and the Proxy API 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.

node --test map.test.mjs
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.

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
}
node pitfall.mjs
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.

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.

Footnotes

  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 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. ↩︎ Back to text

  2. The Nango self-hosting page 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. ↩︎ Back to text

  3. Paragon’s Proxy API 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. ↩︎ Back to text

  4. 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.