Skip to content

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

Migrate from Merge.dev to another unified API without re-authenticating customers: export tokens via /linked-accounts, import into Truto, and validate refresh.

Yuvraj Muley Yuvraj Muley · · 36 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.

Can You Migrate Without Re-Authenticating Customers?

The short answer: it depends on whether you own your OAuth applications.

If you registered your own OAuth apps (your own Client ID and Client Secret) in each provider's developer portal and configured them as "partner credentials" in Merge.dev, then yes - you can export those tokens and import them into a new unified API platform like Truto without any customer-facing disruption. The tokens were issued under your application, and you can move them wherever you want.

If you used Merge's default OAuth applications - the ones that work out of the box with zero configuration - those tokens are bound to Merge's Client ID. There is no technical workaround. Those customers will need to re-authenticate. This is not a limitation specific to Merge; it is how OAuth works. A refresh token can only be used by the OAuth application that originally issued it.

For API key-based integrations (like BambooHR or 15Five), the credentials are static strings. They are always portable regardless of which unified API platform holds them.

Here is the realistic breakdown for most teams migrating from Merge.dev to another unified API:

Credential Type Can You Migrate Without Re-Auth? Action Required
OAuth with your own app (BYO/partner credentials) Yes Export tokens, import into new platform, update redirect URIs
OAuth with Merge's default app No Staged re-authentication required
API key integrations Yes Extract key, import into new platform
Service account / basic auth Yes Extract credentials, import into new platform

Most teams will have a mix. The rest of this guide covers both paths: the token transfer for accounts you can migrate, and a staged re-authentication strategy for the ones you cannot.

Quickstart: One-Page Migration Checklist

If you just want the executive summary before diving into the details, here is the entire migration compressed into ten steps:

  1. Audit ownership. In your Merge dashboard, check every integration. Partner credentials (your OAuth app) = portable. Merge defaults = not portable.
  2. Pull the linked account inventory. Call GET /api/{category}/v1/linked-accounts and filter by status - options are COMPLETE, IDLE, INCOMPLETE, RELINK_NEEDED. Paginate through every category (hris, ats, crm, ticketing, accounting, mktg, filestorage).
  3. Request raw token export from Merge support. Merge does not expose raw upstream OAuth tokens through the public API. Open a ticket with a specific request (template below).
  4. Extract API-key credentials via passthrough. For API-key integrations, use the passthrough endpoint with double-bracket variables like {{API_KEY}} to access stored credentials programmatically.
  5. Encrypt exports at rest. Store the export in a KMS-encrypted bucket with a 7-14 day retention window. Never in git, Slack, or a wiki.
  6. Configure integrations in Truto. For each integration, put your OAuth Client ID and Secret in the integration config so refreshes use the same OAuth app that originally issued the tokens.
  7. Import integrated accounts. POST each token record to https://api.truto.one/integrated-account, injecting credentials into the context field.
  8. Validate refresh + one live API call. For each imported account, call POST /integrated-account/refresh-credentials and then make one real proxy or unified API call.
  9. Watch for needs_reauth. This is Truto's equivalent of Merge's RELINK_NEEDED. Route any failure into your staged re-auth flow.
  10. Run parallel for 48-72 hours, then cut over. Do not dual-write refresh operations - pick one platform to own each account's credential lifecycle at any given time.

Everything below is the expanded version of these ten steps.

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.

Token Transfer Risks to Plan For

Even when you own the OAuth app, transferring tokens is not risk-free. Plan for these specific failure modes before you start the migration:

Scope mismatches. If your new platform requests different OAuth scopes than the original authorization, the imported tokens may lack permissions for certain API calls. For example, if Merge originally requested read scope on a provider but your new integration config also needs write, the imported token will fail on write operations. Audit the scopes granted on each token and compare them against what your new platform requires. Any mismatch means that specific account will need re-authentication to grant the additional scopes.

Token expiry during migration. Access tokens for most providers expire in 60 minutes or less. If you extract tokens and do not import them quickly, the access token will expire. This is usually fine - your new platform will use the refresh token to obtain a new access token. But some providers (notably Google) limit refresh token lifetimes or revoke them after extended inactivity. Verify that your refresh tokens are still valid before you begin, and prioritize speed in your import pipeline.

Provider-specific revocation policies. Some providers revoke existing refresh tokens when a new one is issued (refresh token rotation). If both Merge and your new platform attempt to refresh the same token concurrently during the transition window, one of them will invalidate the other's token. Coordinate the cutover so that only one platform is actively refreshing tokens for a given account at any point in time.

Redirect URI validation on re-consent. While existing refresh tokens do not require the redirect URI to match for token refresh calls, some providers (like Google) may force a re-consent screen if they detect the redirect URI has changed since the original authorization. This is rare for background refresh operations but can surface during edge cases.

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.

Step 0: Pull the Full Linked Account Inventory

Before extracting any credential, build a complete inventory. Merge exposes a /linked-accounts endpoint under each category (hris, ats, crm, ticketing, accounting, mktg, filestorage). Add your API key with a "Bearer " prefix as a header called Authorization to authorize your Merge API requests:

curl -X GET "https://api.merge.dev/api/hris/v1/linked-accounts?page_size=100&status=COMPLETE" \
  -H "Authorization: Bearer YOUR_MERGE_API_KEY"

You can filter by status with options COMPLETE, IDLE, INCOMPLETE, RELINK_NEEDED. Skip RELINK_NEEDED accounts - they already need re-authentication regardless of your migration plans. Loop through every category to get everything.

A typical response looks like:

{
  "next": "cD0yMDIxLTAxLTA2KzAzJTNBMjQlM0E1My40MTQyNjElMkIwMCUzQTAw",
  "previous": null,
  "results": [
    {
      "id": "e59b1821-f85c-4e28-a6b3-1804156f3563",
      "integration": "BambooHR",
      "integration_slug": "bamboohr",
      "category": "hris",
      "end_user_origin_id": "customer_xyz",
      "end_user_organization_name": "Acme Corp",
      "end_user_email_address": "admin@acme.com",
      "status": "COMPLETE",
      "webhook_listener_url": "https://api.merge.dev/api/integrations/webhook-listener/...",
      "is_duplicate": false,
      "account_type": "PRODUCTION"
    }
  ]
}

Save the id field - that becomes the X-Account-Token header for every per-account request. Save end_user_origin_id too - that is your internal tenant_id and is how you will map Merge accounts to your new Truto integrated accounts.

Here is a Python paginator that dumps every linked account across every category to a JSON file:

import os, json, requests
 
CATEGORIES = ["hris", "ats", "crm", "ticketing", "accounting", "mktg", "filestorage"]
HEADERS = {"Authorization": f"Bearer {os.environ['MERGE_API_KEY']}"}
 
def inventory():
    accounts = []
    for cat in CATEGORIES:
        cursor = None
        while True:
            params = {"page_size": 100, "status": "COMPLETE"}
            if cursor:
                params["cursor"] = cursor
            r = requests.get(
                f"https://api.merge.dev/api/{cat}/v1/linked-accounts",
                headers=HEADERS,
                params=params,
            )
            r.raise_for_status()
            data = r.json()
            for acc in data["results"]:
                acc["_category"] = cat
                accounts.append(acc)
            cursor = data.get("next")
            if not cursor:
                break
    return accounts
 
if __name__ == "__main__":
    accounts = inventory()
    with open("merge-inventory.json", "w") as f:
        json.dump(accounts, f, indent=2)
    print(f"Exported {len(accounts)} linked accounts")

Option 1: Request a Raw Token Export from Merge Support

This is the most straightforward path for OAuth accounts under your partner credentials. Merge does not expose upstream OAuth tokens (the ones issued by Salesforce, HubSpot, etc.) through the public API - the only path is a support request. Depending on your plan and contract terms, Merge may accommodate this request. Here is a template that works:

Subject: Credential export request for account migration

We are migrating our integration infrastructure to a different vendor and need to export the raw upstream OAuth credentials for linked accounts that were created using our own partner credentials (Client ID: <your_client_id>).

Since these tokens were issued under our own registered OAuth application, they are portable to any infrastructure that can present the same client_id and client_secret. Specifically, we need for each affected linked account:

  • access_token
  • refresh_token
  • expires_at (or expires_in)
  • scope
  • Any provider-specific context (Salesforce instance_url, HubSpot portal ID / hub ID, Zendesk subdomain, etc.)

We are prepared to sign any additional data handling agreement required and will receive the export via a mutually agreed secure channel (encrypted S3 bucket or one-time link).

Attached: a CSV of the linked account IDs in scope (the id values returned from GET /linked-accounts).

Be explicit that the request only covers accounts under your partner credentials - Merge cannot legally hand you tokens issued by their own OAuth application to other customers.

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 securely access a linked account's stored credentials programmatically by including variables in double brackets (e.g. {{API_KEY}}) in a passthrough request.

Send POST requests to https://api.merge.dev/api/{CATEGORY}/v1/passthrough with the required body parameters, replacing CATEGORY with hris, ats, accounting, ticketing, crm, mktg, or filestorage depending on the integration. Because the API key is substituted server-side, the response reveals the exact credential the linked account is holding.

# 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()

Capturing Provider-Specific Context via Passthrough

While you wait on Merge support for the OAuth token export, use passthrough to capture the provider-specific context that lives alongside the credential. This is essential for reconstructing a full account: a Salesforce access token is useless without instance_url, and a Zendesk call needs the customer's subdomain.

For Salesforce, hit the userinfo endpoint through passthrough to capture the instance URL and org ID:

curl -X POST "https://api.merge.dev/api/crm/v1/passthrough" \
  -H "Authorization: Bearer YOUR_MERGE_API_KEY" \
  -H "X-Account-Token: LINKED_ACCOUNT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "GET",
    "path": "/services/oauth2/userinfo",
    "request_format": "JSON"
  }'

The response contains urls.rest (the instance URL Salesforce expects on subsequent API calls) and the organization ID. Store both alongside the token export - you will need them when constructing the Truto integrated account payload.

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.

Secure Storage and Encryption Best Practices for Exported Credentials

The export file you build during migration is the most sensitive artifact in your infrastructure - arguably more sensitive than your production database, because it contains active credentials for every customer's upstream systems. Treat it accordingly.

Encrypt at rest with a customer-managed key. Store the export in an object storage bucket (S3, GCS, or equivalent) with server-side encryption backed by a KMS-managed key that only your migration service role can decrypt. Do not commit exports to git, ever. Do not paste tokens into Slack, Jira, Notion, or an email attachment.

Minimize the retention window. Set a lifecycle policy on the export bucket that deletes objects 7-14 days after upload. Once accounts are imported and validated, there is no reason to keep the raw export around, and every additional day of retention increases blast radius.

Encrypt in transit end-to-end. The path from Merge (or your export script) to storage, and from storage to the import script, should be TLS all the way. Never pass tokens as URL query parameters or shell arguments - they end up in access logs and shell history.

Use short-lived access credentials. The identity that reads the export bucket should be a service role assumed via STS or workload identity federation, not a static long-lived key. Scope the role's permissions to only the specific bucket prefix and the specific KMS key.

Log every access. CloudTrail, GCS Audit Logs, or the equivalent for your cloud. You should be able to answer "who accessed this file, when, and from what IP" for every read of the export.

Rotate what you can, when you can. If a provider supports refresh token rotation and you have any doubt about the security of the export path, force a token refresh immediately after import. That mints a new access token (and possibly a new refresh token), invalidating anything that touched the export file.

Redact from telemetry aggressively. Add explicit filters to your logging library to redact any field containing access_token, refresh_token, api_key, client_secret, password, or token before it ever leaves the process. Preventing a token leak into your observability stack is far cheaper than purging it after the fact.

Once credentials land in Truto, protection continues automatically. Sensitive fields (access tokens, refresh tokens, API keys, client secrets, passwords) are encrypted with AES-GCM before hitting the database and are masked when accounts are listed via the API. That handles what happens after import - what you do with the export in the meantime is entirely on you.

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"
    }
  }'

Sample integrated_account JSON (Full Reference)

After a successful import, a Truto integrated account object looks like this. Use it as a reference for what to expect when you GET the record back and as a template for the payload you POST during import:

{
  "id": "acc_2Z8xJ9V3nQ1234567890",
  "environment_id": "env_abc123",
  "integration_name": "salesforce",
  "tenant_id": "customer_xyz",
  "status": "active",
  "authentication_method": "oauth2",
  "context": {
    "oauth": {
      "scope": "api refresh_token offline_access",
      "token": {
        "access_token": "00D5f0000004n...",
        "refresh_token": "5Aep861z80Xevi...",
        "expires_at": "2026-04-07T13:00:00.000Z",
        "token_type": "Bearer",
        "scope": "api refresh_token offline_access"
      }
    },
    "instance_url": "https://acme.my.salesforce.com",
    "organization_id": "00D5f0000004nXxEAI"
  },
  "last_error": null,
  "created_at": "2026-04-07T12:00:00.000Z",
  "updated_at": "2026-04-07T12:00:00.000Z"
}

The status field is the one to watch. Valid values are active, needs_reauth, connecting, validation_error, and post_install_error. Any newly imported account should land as active. If it lands as needs_reauth immediately, the refresh attempt failed - see the validation section below.

Sensitive fields (access_token, refresh_token, api_key, client_secret, and similar) are returned masked in list responses and accessible in full only when fetching a single account. This matches the on-disk model: those specific paths in the context object are encrypted at rest.

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.

Validation: Refresh Token Checks and Handling needs_reauth

Before you route production traffic through imported accounts, prove that each one can actually refresh. This is the equivalent of Merge's RELINK_NEEDED check. RELINK_NEEDED is returned when authentication expires, meaning credentials must be re-entered in the linking flow, and it will not resolve on its own unless credentials are re-entered. Truto marks an account needs_reauth under the same conditions - refresh failed, and the account cannot recover without user action.

The validation loop for each imported account should do three things:

  1. Force a refresh to prove the refresh token still works.
  2. Fetch the account and confirm status is active.
  3. Make one live upstream API call through the proxy to prove the token has the right scopes.

Here is a runnable validation script in TypeScript:

import fetch from 'node-fetch';
 
const TRUTO_API = 'https://api.truto.one';
const AUTH = { 'Authorization': `Bearer ${process.env.TRUTO_API_KEY!}` };
 
type Result =
  | { accountId: string; ok: true }
  | { accountId: string; ok: false; phase: string; error?: any; status?: string };
 
async function validate(accountId: string, testPath: string): Promise<Result> {
  // 1. Force a refresh
  const refresh = await fetch(`${TRUTO_API}/integrated-account/refresh-credentials`, {
    method: 'POST',
    headers: { ...AUTH, 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: accountId }),
  });
  if (!refresh.ok) {
    return { accountId, ok: false, phase: 'refresh', error: await refresh.text() };
  }
 
  // 2. Fetch the account and check status
  const acc = await fetch(`${TRUTO_API}/integrated-account/${accountId}`, { headers: AUTH });
  const accData: any = await acc.json();
  if (accData.status !== 'active') {
    return {
      accountId,
      ok: false,
      phase: 'status',
      status: accData.status,
      error: accData.last_error,
    };
  }
 
  // 3. Live upstream call via the proxy
  const call = await fetch(
    `${TRUTO_API}/proxy/${testPath}?integrated_account_id=${accountId}`,
    { headers: AUTH }
  );
  if (!call.ok) {
    return { accountId, ok: false, phase: 'live_call', status: String(call.status) };
  }
 
  return { accountId, ok: true };
}
 
async function main(accountIds: string[]) {
  const results: Result[] = [];
  for (const id of accountIds) {
    results.push(await validate(id, 'contacts'));
  }
  const failed = results.filter(r => !r.ok);
  console.log(`Passed: ${results.length - failed.length}/${results.length}`);
  console.log('Failed:', JSON.stringify(failed, null, 2));
}

When an account fails at the refresh phase, Truto marks it needs_reauth and fires an integrated_account:authentication_error webhook. That webhook is your hook point for a reconnect flow: subscribe to it, look up the customer, and drop them into your in-app re-auth UI. When the customer finishes re-authorizing, Truto fires integrated_account:reactivated and flips the status back to active.

Common validation failures and what they mean:

  • invalid_grant on refresh: The refresh token was revoked, expired, or was never portable (Merge's default OAuth app issued it). Route to re-auth.
  • invalid_client on refresh: The Client ID or Client Secret configured in Truto does not match the OAuth app that issued the token. Fix your integration config, not the account.
  • Refresh succeeds, live call returns 401: The token has insufficient scopes for the endpoint you called. Either restrict your API surface or force re-auth to grant additional scopes.
  • Refresh succeeds, live call returns 403: Provider-side permission issue (a very common Salesforce scenario when API access was not enabled for the connected app). Not a token problem - a provider config problem.

Run validation against your entire imported set before cutting over any production traffic. It is far cheaper to discover a class of broken accounts in a validation report than in customer support tickets.

Provider-Specific Examples: Salesforce, HubSpot, Zendesk

The generic pattern above works for any OAuth integration, but the top three enterprise providers each have context requirements that trip up first-time migrators. Here is what to grab for each.

Salesforce

Salesforce tokens are useless without the instance_url - it is the per-org base URL that every subsequent API call must hit. Migrate an access token but forget to capture the instance URL, and every call fails.

Required context to migrate:

  • access_token
  • refresh_token
  • instance_url (e.g., https://acme.my.salesforce.com)
  • id (Salesforce's identity URL - contains org ID and user ID)
  • Scopes originally granted (typically api refresh_token offline_access)

Truto integrated_account payload:

{
  "integration_name": "salesforce",
  "environment_id": "env_abc123",
  "tenant_id": "customer_xyz",
  "context": {
    "oauth": {
      "token": {
        "access_token": "00D...",
        "refresh_token": "5Aep...",
        "expires_at": "2026-04-07T13:00:00Z",
        "token_type": "Bearer",
        "scope": "api refresh_token offline_access"
      }
    },
    "instance_url": "https://acme.my.salesforce.com"
  }
}

Migration gotcha: Salesforce periodically enforces new Connected App security requirements that can invalidate existing tokens during the transition. During recent Salesforce security changes, some Linked Accounts entered a "relink needed" state and temporarily stopped syncing, and this behavior is triggered when Salesforce invalidates certain existing authentication tokens. If you hit a wave of invalid_grant errors on Salesforce accounts specifically during your migration window, check Salesforce's status page - this is a common source of token invalidation unrelated to your migration and will require re-auth regardless of platform.

HubSpot

HubSpot uses standard OAuth 2.0. The critical piece of context is the portal ID (also called hub ID), which identifies which HubSpot account the token belongs to. HubSpot's /oauth/v1/access-tokens/:token endpoint returns the portal ID and the granted scopes, so you can populate this without waiting on a support export.

Required context to migrate:

  • access_token
  • refresh_token
  • expires_at
  • hub_id (portal ID)
  • Scopes

Retrieving the portal ID for an existing token:

curl -X GET "https://api.hubapi.com/oauth/v1/access-tokens/YOUR_ACCESS_TOKEN"

The response includes hub_id, app_id, user_id, scopes, and token_type. Grab hub_id and store it in the Truto context.

Truto integrated_account payload:

{
  "integration_name": "hubspot",
  "environment_id": "env_abc123",
  "tenant_id": "customer_xyz",
  "context": {
    "oauth": {
      "token": {
        "access_token": "CO...",
        "refresh_token": "eu1-...",
        "expires_at": "2026-04-07T13:00:00Z",
        "token_type": "Bearer",
        "scope": "crm.objects.contacts.read crm.objects.contacts.write oauth"
      }
    },
    "hub_id": 12345678
  }
}

Zendesk

Zendesk requires the customer's subdomain in every API call. The subdomain is not part of the OAuth token itself - it must be captured separately during account linking.

Required context to migrate:

  • access_token
  • subdomain (e.g., acme from acme.zendesk.com)
  • Scopes
  • refresh_token (only if issued - see gotcha below)

Truto integrated_account payload:

{
  "integration_name": "zendesk",
  "environment_id": "env_abc123",
  "tenant_id": "customer_xyz",
  "context": {
    "oauth": {
      "token": {
        "access_token": "gErUt...",
        "token_type": "Bearer",
        "scope": "read write"
      }
    },
    "subdomain": "acme"
  }
}

Migration gotcha: Zendesk OAuth 2.0 tokens are long-lived and non-expiring by default (no refresh token is issued). If Merge extracted a refresh token for you, the customer originally went through a specific OAuth flow with offline access - preserve that refresh token exactly. If no refresh token was ever issued, the access token remains valid indefinitely unless revoked, so no proactive refresh is needed on Truto's side either.

If Token Transfer Is Not Possible - Staged Re-Auth Strategy

Let's be honest about the common case: many teams that evaluate a migration from Merge.dev will discover that some or all of their linked accounts used Merge's default OAuth apps. Those tokens cannot be transferred. You need a plan that does not involve emailing all your customers on the same day with a "please reconnect" link.

A staged re-authentication strategy minimizes churn risk by spreading the reconnection work across weeks, prioritizing high-value accounts, and making the experience as frictionless as possible for end users.

Step 1: Segment Your Linked Accounts

Pull a full inventory of your linked accounts and classify each one:

  • Tier 1 - Portable (no re-auth needed): Accounts where you own the OAuth app, or API key-based integrations. These migrate silently via token import.
  • Tier 2 - High-value re-auth: Enterprise accounts with high ARR or strategic importance. These get white-glove treatment - a dedicated CSM reaches out, schedules a 5-minute call, and walks them through the reconnect.
  • Tier 3 - Standard re-auth: Mid-market accounts that can be handled via in-app prompts and self-service flows.
  • Tier 4 - Low-activity re-auth: Accounts with minimal recent API activity. These can be batched into a final email campaign. Some may have already churned or gone dormant.

Step 2: Run Parallel Connectors During the Transition

Do not decommission Merge until every account is migrated or re-authenticated. Keep both platforms active simultaneously:

  • Route Tier 1 accounts through your new platform immediately after token import.
  • Continue routing Tier 2-4 accounts through Merge until each one has re-authenticated on the new platform.
  • Use your internal tenant_id mapping to decide which platform handles each request.

This dual-routing period is the most operationally complex part of the migration, but it guarantees zero disruption for accounts that have not yet reconnected.

Step 3: Trigger Re-Auth Through In-App Prompts

The highest-converting re-auth mechanism is an in-app banner or modal that appears when a user logs into your product. Something like: "We've upgraded our integration infrastructure. Click here to reconnect your [Provider Name] account - it takes 30 seconds." This converts far better than email because the user is already in your product and has immediate context.

Design the reconnect flow to use your new platform's OAuth link generation. The user clicks, completes the standard OAuth consent screen (which they have seen before), and the new integrated account is created automatically.

Step 4: Escalate with Email for Stragglers

After 2-3 weeks of in-app prompts, send targeted emails to accounts that have not yet reconnected. Be specific about which integration needs attention, and include a direct deep link to the reconnect flow in your app. Avoid generic "action required" language - tell them exactly what will stop working and when.

Set a hard deadline (typically 4-6 weeks from the start of re-auth) after which you will decommission the old platform. Communicate this deadline clearly and repeat it.

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.

Before you touch a single token, work through this checklist. Skipping any of these items is how migrations stall mid-flight.

Credential Audit

  • For every integration in Merge, document whether it uses your own OAuth app or Merge's defaults.
  • For each OAuth integration, record the Client ID, the scopes originally requested, and the redirect URI currently configured.
  • For API key integrations, confirm you have a method to extract the key (passthrough variables, support export, or direct provider access).
  • Identify any integrations with provider-specific constraints: refresh token rotation policies, token lifetime limits, or IP allowlisting.

Linked Account ID Mapping

  • Export a complete list of Merge linked accounts with their account_token, integration_name, and your internal tenant_id.
  • Create a mapping table between Merge's linked account identifiers and whatever identifier your new platform will use. This mapping is the source of truth for the entire migration.
  • Verify that every linked account maps to an active customer in your system. Remove orphaned or churned accounts - there is no reason to migrate dead connections.

Scope and Permission Verification

  • Compare the OAuth scopes that were granted under Merge's configuration with the scopes your new platform's integration config will request.
  • Flag any accounts where the new platform needs broader scopes. These accounts will require re-authentication regardless of credential ownership.
  • For providers that support incremental authorization (like Google), plan to request only the additional scopes during re-auth rather than forcing a full re-consent.
  • Review your Terms of Service and DPA to confirm you are permitted to transfer customer credentials between infrastructure providers.
  • If your customers signed agreements that reference a specific data processor (Merge), check whether moving tokens to a new processor requires notification or updated consent.
  • For regulated industries (healthcare, finance), verify that your migration plan satisfies audit and data handling requirements.

Communication Plan

  • Draft customer-facing messaging for accounts that will need re-authentication. Be transparent about why ("we are upgrading our integration infrastructure") without creating alarm.
  • Prepare in-app UI components for the reconnect flow before you start the migration - not after.
  • Brief your support team on the migration timeline, expected questions, and escalation paths.
  • Set a clear internal deadline for the parallel-running period to avoid indefinite dual-platform costs.

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.

Example Timeline: 3-Phase Migration

Here is a realistic timeline for migrating from Merge.dev (or any unified API) to Truto, assuming a mix of portable and non-portable accounts across 200-500 linked accounts.

Phase 1: Audit and Preparation (Week 1-2)

Task Owner Duration
Audit all integrations for credential ownership Engineering 2-3 days
Export linked account inventory and build ID mapping table Engineering 1-2 days
Compare OAuth scopes between old and new platform configs Engineering 1 day
Review legal/DPA obligations for credential transfer Legal + Engineering 2-3 days
Configure integrations and OAuth apps in Truto Engineering 2-3 days
Build and test in-app reconnect UI for re-auth accounts Frontend + Engineering 3-5 days
Brief support team and draft customer communications Support + Marketing 1-2 days

Phase 2: Token Migration for Portable Accounts (Week 3-4)

Task Owner Duration
Extract tokens/API keys from Merge (support export or API) Engineering 1-3 days
Import credentials into Truto via batch script Engineering 1 day
Validate imported accounts with test API calls Engineering + QA 2-3 days
Configure JSONata schema mappings to match Merge's response format Engineering 2-3 days
Update redirect URIs on OAuth apps in provider developer portals Engineering 1 day
Route Tier 1 traffic through Truto, keep Merge active for Tier 2-4 Engineering 1 day
Monitor for 48-72 hours: watch for needs_reauth, 401s, data drift Engineering 3 days

Phase 3: Staged Re-Auth for Non-Portable Accounts (Week 5-8)

Task Owner Duration
Enable in-app reconnect prompts for Tier 2-4 accounts Frontend 1 day
White-glove outreach to Tier 2 (high-value) accounts CSM + Support 1-2 weeks
Monitor in-app re-auth conversion rates, iterate on UX Product + Engineering Ongoing
Send targeted email campaign to accounts that have not reconnected after 2 weeks Marketing + Support 1 day
Send final deadline reminder email at week 7 Marketing 1 day
Decommission Merge: delete linked accounts, cancel subscription Engineering + Finance 1 day

Most teams complete the full migration in 6-8 weeks. If all your accounts are portable (you own every OAuth app), you can collapse this to 2-3 weeks by skipping Phase 3 entirely.

Troubleshooting and Common Errors

The failure modes below cover the vast majority of problems teams hit in the first days of running imported accounts.

invalid_grant on every refresh attempt for an integration

Symptom: Every imported account for a specific integration fails to refresh with invalid_grant.

Likely cause: The Client ID or Client Secret in Truto's integration config does not match the OAuth app that originally issued the tokens. This happens when you have multiple OAuth apps registered with the same provider and configured the wrong one.

Fix: In the provider's developer portal, confirm which OAuth app issued the tokens (check the app's connections or authorization logs). Update Truto's integration credentials to match. Existing accounts do not need re-import - the next refresh attempt will succeed.

invalid_grant on some accounts but not others for the same integration

Symptom: Refresh works for most accounts on an integration but fails for a subset.

Likely cause: Those specific accounts were created before you added partner credentials to Merge. Merge does not retroactively rebind existing linked accounts to new partner credentials - once a linked account has been created using a specific OAuth app, it stays bound to that app for the account's lifetime.

Fix: These accounts must go through re-auth. Add them to your Tier 2-4 outreach list. There is no token-migration path for them.

Refresh succeeds but every API call returns 401 with a remote error

Symptom: Token refresh works. The account status is active. But every unified API or proxy call fails with a 401 from the upstream provider.

Likely cause: Missing provider context. Salesforce needs instance_url. Zendesk needs subdomain. HubSpot generally does not need extra context but some Private Apps require hub_id for scoping. Providers reject API calls that hit the wrong base URL even with a valid token.

Fix: Compare the imported account's context field against the provider-specific requirements above. PATCH the account to add the missing fields.

Symptom: The token refreshes, calls to some endpoints work, but calls to specific endpoints return 403 or "insufficient scope" errors.

Likely cause: The new integration is trying to hit endpoints that require scopes the original Merge configuration never requested. Scopes are baked into the token at authorization time - you cannot add scopes to an existing token, only re-auth grants new scopes.

Fix: Two options. (1) Restrict your unified API surface to only use endpoints covered by the granted scopes. (2) Trigger re-auth for affected accounts and request the expanded scopes. Some providers (Google Workspace, Microsoft) support incremental authorization so users do not have to re-grant existing scopes.

Race conditions during dual-write / parallel operation

Symptom: Refresh tokens randomly stop working during the parallel-run window. Accounts flip between active on Truto and RELINK_NEEDED on Merge (or vice versa).

Likely cause: Both platforms are refreshing the same tokens concurrently, and the provider is enforcing refresh token rotation. Only one refresh can "win"; the loser's refresh token is invalidated.

Fix: Do not dual-refresh. For any given account, decide which platform owns the credential refresh cycle at any point in time. The simplest split: portable accounts move to Truto immediately after import and Merge's sync for those accounts is paused (or the linked account is deleted); non-portable accounts stay on Merge until re-auth is complete.

needs_reauth webhook fires immediately after import

Symptom: You import an account, and within minutes Truto fires integrated_account:authentication_error and marks it needs_reauth.

Likely cause: The imported access token was already expired at import time, and the refresh attempt failed. This is common when there is a long delay between extraction and import.

Fix: If the refresh token is still valid, retry the refresh manually via POST /integrated-account/refresh-credentials. If that also fails, the refresh token itself was revoked or expired - route to re-auth. Prevent this in future migrations by keeping the extraction-to-import window under an hour.

Import returns 200 but the account does not appear in the list

Symptom: The POST to /integrated-account returns success, but the account is missing from GET /integrated-account.

Likely cause: The account was created in a different environment than the one you are listing. Environment IDs are scoping boundaries in Truto - production, staging, and development environments have separate account tables.

Fix: Double-check environment_id in the import payload matches the environment whose API key you are using to list. Use ?environment_id= explicitly when listing to be safe.

Sensitive fields appear masked in API responses

Symptom: After import, listing accounts shows access_token: "***" or similar redacted values. You worry the tokens did not import correctly.

Reality check: They imported fine. Truto encrypts sensitive credential paths (context.oauth.token.access_token, context.oauth.token.refresh_token, context.api_key, context.password, context.client_secret, and others) with AES-GCM at rest, and masks them in list responses. To verify a specific account, GET the single account by ID - the token fields are decrypted and returned in full only on individual fetches, and used internally for outbound API calls.

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.

FAQ

Can I migrate from Merge.dev to another unified API without re-authenticating customers?
Yes, but only for accounts where you configured your own OAuth app (partner credentials) in Merge. Tokens issued under Merge's default OAuth apps are cryptographically bound to Merge's Client ID and cannot be transferred. API key-based integrations are always portable.
What happens if I used Merge's default OAuth apps?
Those customers will need to re-authenticate. There is no technical workaround because an OAuth refresh token can only be used by the application that originally issued it. Use a staged re-auth strategy with in-app prompts and tiered outreach to minimize disruption.
How long does a full migration from Merge.dev to Truto take?
Most teams complete the migration in 6-8 weeks across three phases: audit and preparation (weeks 1-2), token migration for portable accounts (weeks 3-4), and staged re-auth for non-portable accounts (weeks 5-8). If all accounts are portable, it can be done in 2-3 weeks.
What are the risks of transferring OAuth tokens between unified API platforms?
Key risks include OAuth scope mismatches (the new platform may need broader permissions), token expiry during migration, provider-specific refresh token rotation that can invalidate tokens if both platforms refresh concurrently, and redirect URI validation issues on some providers.
Do I need to update redirect URIs when migrating OAuth tokens?
Yes, but the timing is flexible. Existing refresh tokens do not require the redirect URI to match for token refresh calls. You must update the redirect URI before any new customers authenticate, but imported tokens will continue to refresh using the old URI configuration.

More from our Blog