Skip to content

How to Migrate from Merge.dev Without Re-Authenticating Customers

Learn the complete technical strategy to securely export OAuth tokens from Merge.dev and migrate to a pass-through unified API without forcing users to reconnect.

Yuvraj Muley Yuvraj Muley · · 15 min read
How to Migrate from Merge.dev Without Re-Authenticating Customers

If you are evaluating a migration away from Merge.dev to another unified API, the single biggest fear is obvious: the migration cliff. Forcing hundreds of enterprise users to click "Reconnect" on their Salesforce, Workday, or HubSpot integrations is a massive friction point. It means generating support tickets, increasing churn risk, and spending weeks coordinating with enterprise clients who will not appreciate re-linking their core systems of record simply because you decided to switch infrastructure vendors.

The math on your current integration vendor might no longer make sense. The per-linked-account pricing might be dragging down your unit economics, or your enterprise customers are demanding custom field support that the rigid unified schema simply cannot handle. But you are paralyzed by the thought of asking every customer to re-authenticate.

The good news is that if you architected your initial implementation correctly—or even if you didn't, but are willing to put in a little operational work—there are concrete technical strategies to migrate OAuth tokens to a new unified API platform without touching your end users. You do not have to start over from scratch, and you do not have to email your customers.

This guide breaks down the exact technical strategy to export OAuth tokens, import them into Truto's generic credential context, handle rate limits post-migration, and use declarative mappings to mimic your old API responses so your frontend code does not have to change.

The Vendor Lock-In Trap of Unified APIs

Unified API platforms solve a real problem: they abstract away the pain of dealing with terrible vendor API docs, inconsistent pagination, and undocumented edge cases. The initial pitch is compelling: write to one schema, connect to hundreds of platforms. But the architecture of most platforms creates a dependency that is easy to miss during evaluation and incredibly painful to discover later.

When customers authenticate through Merge, Merge holds the OAuth tokens. Switching vendors typically means asking every customer to re-authenticate—a migration tax that creates real, structural lock-in. This isn't a bug in Merge's design. It is a natural consequence of how most unified APIs work: they operate the OAuth flow, they store the tokens, and they refresh them on your behalf.

The economic and technical pressure to migrate tends to build over time for three specific reasons:

First, the pricing models punish growth. According to SaaStr's 2025 SaaS Management Report, the average company now spends $49M annually on SaaS, with portfolio growth increasing to an average of 275 applications across all buyers (with enterprises averaging 660). When your customers expect you to integrate with a dozen of their internal tools, paying a flat monthly fee per linked account quickly destroys your profit margins. We covered this economic reality in detail in our direct comparison of Truto and Merge.dev.

Second, schema rigidity becomes a deal blocker. Enterprise customers heavily customize their systems of record. When a Fortune 500 prospect demands that you read and write to a custom object in their Salesforce instance, a rigid unified schema will stall the deal. You are forced to either upgrade to an expensive enterprise tier to unlock custom field mapping or bypass the unified model entirely to make raw passthrough requests.

Third, data privacy liabilities. Store-and-sync architectures copy your customers' third-party data onto the unified API vendor's servers. SaaS security is now a high priority for 86% of organizations, with 76% actively increasing their security budgets. Storing a replica of a customer's HRIS or accounting data on a third-party server creates significant compliance friction during enterprise InfoSec reviews. A pass-through architecture creates far less friction, which you can read more about in our guide on what zero data retention means for SaaS integrations.

So the business case for migration is clear. The technical barrier is the re-authentication cliff. Let's remove it.

The Prerequisite: Do You Own Your OAuth Apps?

Before we look at the extraction code, we have to address a hard technical truth. This is the single most important question in the entire migration: If you do not own the OAuth Client ID and Client Secret for each integration, you cannot migrate tokens. Full stop.

Here is why: An OAuth refresh token is cryptographically bound to the OAuth application that issued it. When you call a provider's /token endpoint with a refresh token, the provider checks that the client_id and client_secret in the request match the application that originally granted the token. If Merge's default OAuth application issued the token—using Merge's Client ID—then only Merge's credentials can refresh it.

Modern security standards make this ownership even more critical. OAuth 2.1 eliminates implicit flows and mandates Proof Key for Code Exchange (PKCE) for all clients. It also requires strict, exact-match redirect URIs. This tightening of the spec means there is zero room for creative workarounds. You cannot swap redirect URIs after the fact or use partial string matches.

When you set up an integration through a unified API platform, you typically have two choices for handling the OAuth flow:

  1. Use the platform's default OAuth apps: The provider uses their own Client ID and Client Secret to authenticate your users.
  2. Bring Your Own (BYO) OAuth app: You register your own application in the third-party developer portal, and you provide your own credentials to the unified API platform.

Merge.dev supports this second option, which they call "partner credentials." Their documentation for providers like Salesforce, HubSpot, and Dropbox walks you through creating your own OAuth application and entering the credentials into Merge's dashboard. For example, for Salesforce, you set the Callback URL to https://app.merge.dev/oauth/callback. For HubSpot, you add the same redirect URL.

If you set up your own OAuth apps through this process, you hold the keys to your own infrastructure. The tokens were issued under your application. You can extract the access_token and refresh_token pairs and move them to any infrastructure you want.

If you used Merge's default OAuth apps—the ones that work out of the box with zero configuration—those tokens belong to Merge's OAuth application. You will need to re-authenticate those customers. There is no technical path around this.

Warning

Audit your integrations now. Log into your Merge dashboard, go to Integrations, and check each provider. If it says "Merge credentials" or you never configured partner credentials, those linked accounts cannot be token-migrated. If it shows your own OAuth app credentials, you are ready to migrate.

How to Extract Your OAuth Tokens from Merge.dev

The first technical step is extracting your active connection data. You need to pull the raw credentials for every linked account in your system.

Merge.dev does not natively expose raw OAuth tokens through their standard unified API endpoints. Their authentication model uses an account_token per linked account—a Merge-specific identifier that authenticates your requests to their API, not the underlying provider's token.

However, you have three viable options for extracting your connection data:

Option 1: Contact Merge Support Directly

This is the most straightforward path. Request a complete credential export for your linked accounts. You need the access_token, refresh_token, expires_at timestamp, and any provider-specific context (like Salesforce's instance_url or a provider's subdomain). Depending on your plan and contract terms, Merge may accommodate this request, providing a clean export file.

Option 2: Intercept During Active Sessions

If you own the OAuth app, you can set up a parallel token exchange. Because you control the Client ID and Client Secret, you can call the provider's token endpoint directly with your credentials.

Most unified API platforms provide some form of endpoint to retrieve raw integration credentials or metadata. You will need to write a script that iterates through your active accounts. Here is a conceptual Node.js script demonstrating how you would batch extract this data if the platform exposes it. You will need to handle pagination and rate limits on the extraction API to ensure you do not drop any records.

async function extractTokens(linkedAccountIds) {
  const extractedData = [];
  
  for (const accountId of linkedAccountIds) {
    try {
      // Conceptual endpoint - check current provider capabilities
      const response = await fetch(`https://api.current-provider.com/api/v1/account-tokens/${accountId}`, {
        headers: {
          'Authorization': `Bearer ${process.env.PROVIDER_API_KEY}`
        }
      });
      
      if (!response.ok) {
        console.error(`Failed to fetch tokens for ${accountId}`);
        continue;
      }
      
      const data = await response.json();
      
      extractedData.push({
        originalAccountId: accountId,
        integrationName: data.integration_name,
        accessToken: data.credentials.access_token,
        refreshToken: data.credentials.refresh_token,
        expiresAt: data.credentials.expires_at,
        tenantId: data.tenant_id // Your internal user/org ID
      });
      
    } catch (error) {
      console.error(`Error extracting ${accountId}:`, error);
    }
  }
  
  return extractedData;
}

Option 3: Gradual Migration with Dual-Write via Passthrough

For non-OAuth integrations (API key-based connections like BambooHR, 15Five, or legacy systems), the credentials are static. You can extract the API key through Merge's passthrough variable system. You can securely access a linked account's stored credentials programmatically by including variables in double brackets (e.g., {{API_KEY}}).

You can build a script that retrieves these credentials for each linked account and stores them for import into your new platform.

# Pseudocode: Extract API key integrations via Merge passthrough
import requests
 
def extract_api_key_credential(merge_api_key, account_token, provider_path):
    response = requests.post(
        "https://api.merge.dev/api/hris/v1/passthrough",
        headers={
            "Authorization": f"Bearer {merge_api_key}",
            "X-Account-Token": account_token,
            "Content-Type": "application/json"
        },
        json={
            "method": "GET",
            "path": provider_path,
            "request_format": "JSON"
        }
    )
    return response.json()

For each extraction method, build a mapping file that captures the provider name, the end-user's unique identifier, the credential type (OAuth or API key), and every field needed to reconstruct the connection. Treat this export file like production database credentials, because that is exactly what it is.

Importing Tokens into Truto's Integrated Account Context

Once you have your tokens, you need to import them into your new infrastructure.

Truto's credential architecture is built around a single concept: the integrated account. Instead of relying on hardcoded, provider-specific database columns (like a salesforce_refresh_token column), Truto stores all connection data in a highly flexible, generic JSON context object.

This means you can programmatically create an Integrated Account and inject your exported OAuth tokens directly into the context payload. An OAuth token for Salesforce and an API key for BambooHR live in the exact same structure, differentiated only by the JSON contents. You do not need to match a rigid internal schema; you simply write the token data into the context field, and the platform's generic engine takes over.

Here is how you structure the import payload for Truto using Node.js:

async function importToTruto(extractedAccount) {
  const payload = {
    environment_id: process.env.TRUTO_ENVIRONMENT_ID,
    integration_name: extractedAccount.integrationName,
    tenant_id: extractedAccount.tenantId,
    authentication_method: "oauth2",
    context: {
      oauth: {
        token: {
          access_token: extractedAccount.accessToken,
          refresh_token: extractedAccount.refreshToken,
          expires_at: extractedAccount.expiresAt,
          token_type: "Bearer"
        }
      }
    }
  };
 
  const response = await fetch('https://api.truto.one/integrated-accounts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.TRUTO_API_KEY}`
    },
    body: JSON.stringify(payload)
  });
 
  return response.json();
}

If you prefer to run this as a batch script via CLI, here is what the import looks like for an OAuth2 integration using curl:

curl -X POST https://api.truto.one/integrated-account \
  -H "Authorization: Bearer YOUR_TRUTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "integration_name": "salesforce",
    "environment_id": "env_abc123",
    "tenant_id": "customer_xyz",
    "context": {
      "oauth": {
        "token": {
          "access_token": "IMPORTED_ACCESS_TOKEN",
          "refresh_token": "IMPORTED_REFRESH_TOKEN",
          "expires_at": "2026-04-07T12:00:00Z",
          "token_type": "Bearer",
          "scope": "full refresh_token"
        }
      },
      "subdomain": "customer-org",
      "instance_url": "https://customer-org.my.salesforce.com"
    }
  }'

For API key integrations, the structure is even simpler:

curl -X POST https://api.truto.one/integrated-account \
  -H "Authorization: Bearer YOUR_TRUTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "integration_name": "bamboohr",
    "environment_id": "env_abc123",
    "tenant_id": "customer_xyz",
    "context": {
      "api_key": "IMPORTED_API_KEY",
      "subdomain": "customer-company"
    }
  }'

The Token Refresh Lifecycle

When you import these tokens, you are likely importing access_token values that are already expired or will expire within the hour. This is where the underlying architecture of your new platform matters immensely. We have all stared at a dashboard of 401 Unauthorized errors because a refresh token silently expired or a race condition caused two concurrent requests to attempt a refresh simultaneously, invalidating the token chain.

Once the integrated account is created with valid credentials in Truto, three things happen automatically:

  1. The token refresh lifecycle activates: Truto immediately evaluates the expires_at timestamp. Truto automatically schedules a background task to proactively refresh the token 60 to 180 seconds before it expires. If an API request comes in and the token is within 30 seconds of expiration, Truto executes an on-demand refresh before proxying the request to the upstream provider. To prevent race conditions, Truto uses distributed locks to ensure that only one refresh operation runs per integrated account at a time. Concurrent API calls simply await the in-progress refresh promise, ensuring your newly imported tokens are never accidentally revoked.
  2. Credential encryption kicks in: Sensitive fields like access_token, refresh_token, and api_key are encrypted at rest automatically. No extra configuration is needed.
  3. The redirect URI needs updating: Since you own the OAuth app, you must update the redirect URI in the provider's developer portal from https://app.merge.dev/oauth/callback to Truto's callback URL. Existing tokens don't need the redirect URI for refresh—it is only used during the initial authorization code exchange. But any new connections going forward will need the updated URI.

You can dive deeper into this specific mechanism in our guide on architecting reliable token refreshes.

sequenceDiagram
    participant Client as Your App
    participant Truto as Truto API
    participant Lock as Distributed Lock
    participant Upstream as Third-Party API

    Client->>Truto: GET /unified/crm/contacts
    Truto->>Truto: Check token expiry
    alt Token expired or expiring in < 30s
        Truto->>Lock: Acquire lock for account
        Lock-->>Truto: Lock acquired
        Truto->>Upstream: POST /oauth/token (Refresh)
        Upstream-->>Truto: New Access Token
        Truto->>Lock: Release lock
    end
    Truto->>Upstream: GET /contacts (with valid token)
    Upstream-->>Truto: 200 OK
    Truto-->>Client: Mapped Response
Tip

Test before you cut over. Create integrated accounts for a handful of test customers first. Make a simple API call through Truto's proxy API (GET /proxy/contacts) to verify the imported tokens work. If the token has expired, Truto will attempt a refresh using the OAuth credentials configured on the integration. Watch for needs_reauth status—that signals the refresh failed and the token is irrecoverable.

Handling Rate Limits and Retries Post-Migration

One of the most dangerous assumptions engineering teams make during a migration is assuming the new platform handles rate limits exactly like the old one. Many unified APIs attempt to be overly helpful by silently absorbing 429 Too Many Requests errors. They implement internal exponential backoff and retry the request on your behalf.

While this sounds convenient, it is an architectural anti-pattern for enterprise systems. When a unified API silently retries a request for 45 seconds, your frontend connection times out. Your background workers hang. Your engineers have zero visibility into the actual upstream rate limit consumption until the system completely falls over. Transparency beats magic.

Truto takes a radically honest approach to rate limits: Truto does not retry, throttle, or apply backoff on rate limit errors. When an upstream provider returns an HTTP 429, Truto passes that status code directly back to your application. There is no hidden retry loop and no absorption of errors behind a queue.

What Truto does do is solve the actual hard problem: normalizing the chaotic mess of upstream rate limit metadata. Every SaaS platform communicates rate limits differently. HubSpot uses X-HubSpot-RateLimit-Daily and X-HubSpot-RateLimit-Daily-Remaining. Salesforce returns Sforce-Limit-Info. Zendesk uses X-Rate-Limit.

Truto intercepts these disparate formats and normalizes them into standard response headers based on the IETF RateLimit header specification:

Header Meaning
ratelimit-limit The maximum number of requests permitted in the current window.
ratelimit-remaining The number of requests remaining before hitting the limit.
ratelimit-reset The number of seconds until the rate limit window resets.

This standardization is significant. Your engineering team gets consistent, predictable rate limit data regardless of whether you are calling HubSpot, Salesforce, or Zendesk through Truto. Your application is responsible for reading these standardized headers and implementing its own retry or backoff logic using tools like BullMQ or standard queueing systems.

Here is what a well-behaved client-side handler looks like in TypeScript:

async function callTrutoWithBackoff(url: string, options: RequestInit, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(url, options);
 
    if (response.status === 429) {
      const resetSeconds = parseInt(
        response.headers.get('ratelimit-reset') || '60', 10
      );
      const jitter = Math.random() * 2;
      await sleep((resetSeconds + jitter) * 1000);
      continue;
    }
 
    return response;
  }
  throw new Error('Rate limit retries exhausted');
}

For a deeper look at designing resilient architectures around this pattern, review our best practices for handling API rate limits.

Matching the Schema: Zero-Code Endpoint Configuration

The final hurdle in your migration is the data schema. Your frontend components and backend database models are currently tightly coupled to the exact JSON structure that Merge.dev returns. If migrating to a new unified API requires rewriting thousands of lines of parsing logic across your codebase, the migration will stall into a multi-sprint project.

Truto solves this through its declarative mapping configuration layer. Instead of forcing you to adopt Truto's default schema, Truto allows you to define integration-specific behavior entirely as data using JSONata expressions.

JSONata is a functional query and transformation language purpose-built for reshaping JSON objects. In Truto, every field mapping between a provider's native response and the unified output—as well as query translation and conditional logic rules—is defined as data, not hardcoded into the runtime engine.

This means you can configure Truto's unified response to perfectly mimic Merge's exact response shape without changing a single line of your application code. For example, if your application expects a remote_id field and a specific nested account_details object, you can write a JSONata response mapping like this:

(
  {
    "id": response.id,
    "remote_id": response.original_id,
    "first_name": response.properties.firstname,
    "last_name": response.properties.lastname,
    "account_details": {
      "company_name": response.company.name,
      "domain": response.company.domain
    },
    "remote_data": response
  }
)

This flexibility extends to query parameters, request bodies, and even which provider endpoint gets called for a given operation. If Merge routed GET /contacts?email=jane@example.com to HubSpot's search endpoint with filterGroups, Truto's integration config defines the same routing through declarative expressions. The runtime engine evaluates the expression against each response item, producing whatever output shape you need.

The override system adds another layer of flexibility. You can customize mappings at three levels: platform-wide, per-environment, and per-integrated-account. If one enterprise customer's Salesforce instance has custom fields that need special handling, you apply a custom override to your Truto environment for just that account.

Zero code changes are required in your application repository. You simply point your base URL to Truto, ensure your authentication headers are updated, and your application continues functioning as if nothing changed.

The Migration Checklist

Here is the condensed, actionable sequence for a zero-downtime migration:

  1. Audit credential ownership: For every integration in Merge, confirm whether you configured your own OAuth app (partner credentials) or used Merge's defaults. This determines which accounts can be token-migrated.
  2. Export credentials: Use the appropriate extraction method (support request, passthrough API, or direct provider access) to collect access tokens, refresh tokens, and API keys for each linked account.
  3. Configure integrations in Truto: Set up each integration with your OAuth Client ID and Client Secret. Truto's "Bring Your Own OAuth App" support means your credentials go into the integration config, and every connected account under that integration uses them.
  4. Import integrated accounts: Create integrated accounts via API, injecting the exported credentials into the generic context field. Verify token validity by making a test API call for each account.
  5. Configure JSONata Schema Mappings: Set up environment-level overrides in Truto using JSONata expressions to format the unified API response so it perfectly matches the legacy schema your application expects.
  6. Update redirect URIs: Change the redirect URI on your OAuth apps from Merge's callback to Truto's. Existing tokens are unaffected; this only impacts new connections.
  7. Run parallel for 48-72 hours: Keep both platforms active. Route a percentage of traffic through Truto while monitoring for needs_reauth events and data consistency issues.
  8. Cut over and Decommission: Once you have confidence in the imported connections, switch your application to point entirely at Truto's API endpoints. Monitor your webhooks for authentication errors, and delete the linked accounts in Merge to stop their sync jobs and avoid unnecessary billing.

Strategic Next Steps and Vendor Evaluation

Escaping vendor lock-in requires a deliberate architectural strategy. You cannot afford to treat integration infrastructure as a black box. The lesson from migration pain is simple: always own your OAuth credentials from day one.

Any unified API vendor should support bringing your own OAuth apps. If they do not, or if they make it difficult, you are accepting vendor lock-in as a hidden cost of adoption. By ensuring you own your OAuth applications, carefully extracting your token pairs, and importing them into a pass-through architecture like Truto, you can eliminate per-connection pricing penalties and reclaim control over your data model—all without forcing a single customer to re-authenticate.

During your next vendor evaluation, ask these critical questions:

  • Can I use my own OAuth Client ID and Secret for every provider?
  • Are the raw OAuth tokens accessible through an API, or are they abstracted behind a proprietary identifier?
  • If I leave, can I export my customers' credentials without forcing re-authentication?
  • Does the platform store a copy of my customers' data, or does it operate as a pass-through?

The answers to these questions will save you (or cost you) months of engineering work down the road. Credential portability is not a nice-to-have. It is the difference between a vendor relationship and vendor captivity.

Frequently Asked Questions

Can I migrate my users if I used Merge.dev's default OAuth apps?
No. If you used the vendor's default OAuth applications, the refresh tokens are cryptographically bound to their Client ID and cannot be migrated. You must use a Bring Your Own (BYO) OAuth app approach (partner credentials) to own your credentials and successfully migrate.
How do I export OAuth tokens from Merge.dev?
Merge does not natively expose raw OAuth tokens via standard API endpoints. You can extract them by contacting Merge support for a credential export, using their passthrough API with template variables to extract API keys, or calling provider token endpoints directly with your own OAuth app credentials.
What happens if I import an OAuth token that is about to expire?
Truto's architecture evaluates the expiration timestamp immediately upon import. It uses distributed locks and scheduling primitives to proactively refresh the token 60 to 180 seconds before it expires, preventing race conditions and unauthorized errors without any user intervention.
Do I have to rewrite my frontend code to handle Truto's data schema?
No. Truto uses declarative JSONata expressions for mapping configurations. This allows you to customize the API output to perfectly mimic your legacy provider's response structure, meaning you do not have to change your application code.
How does Truto handle rate limits compared to other unified APIs?
Truto does not absorb 429 errors or silently retry requests. It passes the 429 status directly back to your application while normalizing the disparate upstream provider headers into the standard IETF RateLimit specification (limit, remaining, reset).

More from our Blog