agentsclimarketplace

Onboarding

Skill merge-api/merge-unified-skills/skills/onboarding

Claude Code skills for the Merge Unified API

Install
npx -y skills add merge-api/merge-unified-skills --skill onboarding

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Step-by-step onboarding for the Merge Unified API. Use when a developer says "set up Merge", "integrate Merge", "Merge Unified API", "create a Linked Account", "Merge Link", "generate a link token", "exchange account token", "pull HRIS data", "sync employee data", "sync candidate data", "sync CRM contacts", "sync invoices", "sync tickets", "sync files", or asks how to connect to HRIS, ATS, CRM, Accounting, Ticketing, File Storage, Knowledge Base, or Marketing systems via a single unified API. Covers signup → first API call → Merge Link integration → webhooks → production checklist.

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

34.6 KB, as published. Nobody here has run it

Merge Integration Assistant

Get a developer from Merge signup to a working production Linked Account. The hero output is a working Merge Link embed they can drop into their app and see the Link UI open.

When to use this skill

Activate when a developer asks anything that maps to integrating the Merge Unified API:

  • "Help me set up Merge for [HRIS / ATS / CRM / Accounting / Ticketing / File Storage / Knowledge Base / Marketing]"
  • "How do I integrate [any third-party SaaS platform]" (if the platform falls under a Merge category, this skill applies)
  • "Generate a link_token", "exchange a public_token", "use account_token"
  • "Embed Merge Link", "use @mergeapi/react-merge-link"
  • "Set up webhooks for sync events"
  • "Why is my Merge API call returning an empty array"
  • Any question mentioning "Linked Account", "Common Model", or "Merge Unified API"

Do NOT activate for: generic OAuth questions unrelated to Merge, or questions about other unified API providers (Apideck, Finch, Codat, Kombo, Nango).

First activation: self-introduce

When this skill activates for the first time in a conversation, say:

I'm the Merge Integration Assistant (v0.4.0). I'll help you get from signup to a working production Linked Account. Tell me which Merge category and SDK language you want to use, and where you are in the journey.

Overview

Merge is a Unified API that abstracts integrations across categories. One integration with Merge gives your app access to many providers. Categories:

CategoryProviders (examples)Primary Common Model
HRISe.g. Workday, BambooHR, GustoEmployee
ATSe.g. Greenhouse, LeverCandidate
CRMe.g. Salesforce, HubSpotContact
Accountinge.g. QuickBooks, XeroInvoice
Ticketinge.g. Jira, ZendeskTicket
File Storagee.g. Google Drive, DropboxFile
Knowledge Basee.g. Confluence, NotionArticle
Marketinge.g. Mailchimp, HubSpotCampaign

This is not exhaustive — Merge supports many more providers per category. See the full list at https://merge.dev/integrations.

Common Model: a normalized data shape across providers. Whether the developer connects to any HRIS provider, they query the same Employee shape with the same fields.

Linked Account: one end-customer's connection to one provider. Each Linked Account has an account_token that authenticates API calls for that customer's data.

All code examples below use the developer's chosen category. Replace the category slug (hris, crm, ats, accounting, ticketing, filestorage, knowledgebase, mktg) in SDK method paths, endpoint URLs, and categories arrays. The SDK method path matches the category: merge.hris.link_token.create(...), merge.crm.contacts.list(...), etc.

Step 0: Confirm context

Ask the developer (one at a time, skip questions whose answers are obvious from their first message):

  1. Which Merge category? HRIS, ATS, CRM, Accounting, Ticketing, File Storage, Knowledge Base, or Marketing.
  2. Which SDK language? Python, Node.js (TypeScript), Java/Kotlin, Go, Ruby, or C#/.NET. (Or vanilla HTTP if they prefer.)
  3. Where are you in the journey?
    • Just signed up, no API key yet
    • Have API key, no Linked Account yet
    • Have a test Linked Account, want to go to production
    • Production live, debugging an issue

If the developer names a specific provider, infer the category and ask only for SDK language.

Step 1: Get your API key

Direct them to: https://app.merge.dev/keys

Key prefixTypeCreatesVisible in dashboard
test_xxxTestTest Linked Accounts"Test Linked Accounts" page
production_xxxProductionReal Linked Accounts (billed, counted against quota)"Production Linked Accounts" page

⚠️ Verify your key prefix before connecting. Production keys create real Linked Accounts that count against your plan. Free tier caps production Linked Accounts at 3. Use a test_xxx key for all development and testing.

⚠️ Dashboard views are key-specific. Accounts created with a test key only appear on the "Test Linked Accounts" page — not the "Production Linked Accounts" page. If you can't find your account, check you're looking at the right view.

Tell them: "Copy your test key. We'll need it in the next step. Do NOT commit it to git — store it in .env or your secrets manager."

Step 2: Install the SDK

Pick the language. Detailed code in references/sdk-quickstarts.md.

Python:

pip install "MergePythonClient>=2.0.0"

Node.js / TypeScript:

npm install @mergeapi/merge-node-client

Java / Kotlin (JVM):

// build.gradle
implementation 'dev.merge:merge-java-client'

For React frontend (Merge Link component):

npm install @mergeapi/react-merge-link

Step 3: Generate a link_token (backend)

A link_token authorizes one Merge Link session for one end-user. Generated server-side with the developer's API key. Default expiry is 30 minutes; configurable via link_expiry_mins (range: 30–720 minutes, up to 10,080 minutes for Magic Link).

Endpoint: POST https://api.merge.dev/api/integrations/create-link-token

Required fields (EndUserDetailsRequest):

  • end_user_email_address — your customer's email
  • end_user_organization_name — your customer's company name
  • end_user_origin_id — your unique, stable ID for this customer (your user ID or org ID in your system)
  • categories — array of categories, e.g. ["crm"]

⚠️ end_user_origin_id must be stable across sessions. If this changes between re-link sessions for the same user, Merge creates a new Linked Account instead of updating the existing one. Use a permanent identifier, not a session token or random value.

The correct pattern: create a pending DB record BEFORE calling the Merge API. This prevents duplicates if the user opens Merge Link multiple times.

Python — link_token handler:

@app.route("/api/merge/link-token", methods=["POST"])
def create_link_token():
    data = request.json
    user_id = data["user_id"]          # Your internal user/org ID — must be stable across sessions
    email = data["email"]              # Your customer's email from your auth context
    org_name = data["organization"]    # Your customer's company name from your DB

    # 1. Create pending record BEFORE calling Merge
    pending = LinkedAccount.query.filter_by(end_user_origin_id=user_id, status="pending").first()
    if not pending:
        pending = LinkedAccount(end_user_origin_id=user_id, end_user_email=email,
                                organization_name=org_name, status="pending")
        db.session.add(pending)
        db.session.commit()

    # 2. Call Merge API
    merge = Merge(api_key=os.environ["MERGE_API_KEY"])
    response = merge.crm.link_token.create(           # Replace .crm with your category
        end_user_email_address=email,
        end_user_organization_name=org_name,
        end_user_origin_id=user_id,
        categories=["crm"],                            # Replace with your category
    )
    return jsonify({"link_token": response.link_token})

Node — link_token handler:

app.post("/api/merge/link-token", async (req, res) => {
  const { userId, email, organizationName } = req.body;
  // 1. Create pending record BEFORE calling Merge
  await db.query(
    `INSERT INTO linked_accounts (end_user_origin_id, end_user_email, organization_name, status)
     VALUES ($1, $2, $3, 'pending')
     ON CONFLICT (end_user_origin_id) WHERE status = 'pending' DO NOTHING`,
    [userId, email, organizationName]);
  // 2. Call Merge API
  const merge = new MergeClient({ apiKey: process.env.MERGE_API_KEY });
  const response = await merge.crm.linkToken.create({  // Replace .crm with your category
    endUserEmailAddress: email, endUserOrganizationName: organizationName,
    endUserOriginId: userId, categories: ["crm"],       // Replace with your category
  });
  res.json({ linkToken: response.linkToken });
});

Multi-category integrations. If the same end-user needs to connect to more than one category (e.g., CRM + ATS), generate a separate link_token per category. Each category becomes its own Linked Account with its own account_token — track them as separate rows keyed by (end_user_origin_id, category). Don't pass multiple categories in a single link_token unless you've confirmed the UX with Merge.

Two integrations in the SAME category for the same end-user. Merge keys Linked Accounts on (end_user_origin_id, category) — call link_token.create twice with the same pair and the second call hits the relink flow, not a new connection. To support "ops uses Jira AND eng uses GitLab" under one customer, append a stable disambiguator to end_user_origin_id: ${customer_id}:${integration_slug} (e.g. acme-corp:jira, acme-corp:gitlab). Treat the suffix as part of the canonical ID — never compute it on the fly with a random or counter value, or you'll create duplicate Linked Accounts on every reconnect. The cleaner alternative when you can scope it up front is the Marketplace pattern (see /merge-unified:link-implement-frontend-marketplace), where each integration is a distinct user choice.

Step 4: Open Merge Link (frontend)

First run: use the Test integration

For your first build, select the "Test" integration inside Merge Link. It accepts any credentials and creates a Linked Account with sample data — no real provider account needed. This lets you verify the full flow before dealing with real provider sandboxes.

To test API calls without going through Merge Link at all, create a Test Linked Account from https://app.merge.dev/linked-accounts/test and use its account_token directly in Step 6.

⚠️ Real-named integrations behave like Test in test mode. With a test_xxx API key, picking GitLab, Jira, Linear, etc. in Merge Link skips real OAuth and returns a shared Merge demo dataset (the same sample tickets/contacts/etc. across every Linked Account you create). This is by design. It means you cannot validate cross-customer data isolation visually with a test key — different account_tokens, same data. Use a production_xxx key + real provider accounts to exercise true isolation.

No frontend? If you're building a B2B integration with no customer-facing UI, use Magic Link — a hosted URL your customer opens to complete auth. See references/auth-flow.md.

React

The hook returns { open, isReady } and accepts a linkToken. Set linkToken in state on click, then let a useEffect call open() once isReady is true — calling open() directly in the click handler races the hook's initialization. Reset linkToken to null after onSuccess and onExit so the next click fetches a fresh token (a spent token won't re-open).

import { useState, useEffect } from "react";
import { useMergeLink } from "@mergeapi/react-merge-link";

function ConnectButton({ category }: { category: string }) {
  const [linkToken, setLinkToken] = useState<string | null>(null);

  const { open, isReady } = useMergeLink({
    linkToken,
    shouldSendTokenOnSuccessfulLink: true,
    onSuccess: async (publicToken) => {
      await fetch("/api/merge/exchange", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ publicToken, endUserOriginId: "your_user_id" }),
      });
      setLinkToken(null); // reset so next click fetches a fresh token
    },
    onExit: () => setLinkToken(null),
  });

  // Open once the hook is ready after the token is set
  useEffect(() => {
    if (isReady && linkToken) open();
  }, [isReady, linkToken, open]);

  const handleConnect = async () => {
    const res = await fetch("/api/merge/create-link-token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ category }),
    });
    const { link_token } = await res.json();
    setLinkToken(link_token);
  };

  // Replace the label with your category — "Connect your CRM" / "Connect your HRIS" /
  // "Connect your ticketing system" / etc. — or use the integration name if known.
  return <button onClick={handleConnect}>Connect your provider</button>;
}

Vanilla JS

<script src="https://cdn.merge.dev/initialize.js"></script>
<script>
  function openMergeLink(linkToken) {
    MergeLink.initialize({
      linkToken,
      onReady: () => { MergeLink.openLink(); },   // MUST wait for onReady
      onSuccess: (publicToken) => {
        fetch("/api/merge/exchange", {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ publicToken, endUserOriginId: "your_user_id" }),
        });
      },
      onExit: () => console.log("User closed Merge Link"),
    });
  }
</script>

⚠️ initialize() is async. Always call openLink() inside the onReady callback. Calling before onReady results in an invisible iframe. Callbacks: onReady, onSuccess(publicToken), onExit, onValidationError(error).

onSuccess fires with a public_token — a one-time token with a short TTL (~10 min). Send it and the end_user_origin_id to your backend immediately and call /exchange synchronously inside onSuccess. Don't store the public_token to retry later — design for "exchange right now."

⚠️ onSuccess is not the source of truth. Merge creates the Linked Account on its side as soon as OAuth succeeds — regardless of whether your /api/merge/exchange ever runs. If the user closes the modal before exchange completes (network blip, accidental close, Finish-button skipped), you end up with an account on Merge that your DB doesn't know about. Reopening Merge Link with the same end_user_origin_id then shows "You're connected!" with no integration picker, looking like a frontend bug. Production-grade fix: subscribe to the linked_account.created webhook and create your local row from the webhook handler, not just from onSuccess. The onSuccess exchange becomes the fast path; the webhook is the backstop that closes the race. See Step 7.

Step 5: Exchange public_token for account_token (backend)

Linked Account states

The Merge API returns the Linked Account status as uppercase strings on GET /account-details and on every webhook payload. Match against these values exactly:

Merge API statusMeaningAction
COMPLETEHealthy, syncingNone
INCOMPLETELinking flow not finishedPrompt user to complete Merge Link
RELINK_NEEDEDCredentials expired or revoked at sourceTrigger re-link flow
IDLEActive but no recent syncInvestigate, may be normal

Local DB convention. The linked_accounts.status column in the example schemas in this skill uses lowercase values (pending, active, relink_needed, incomplete) — these track your application state (have we exchanged the token yet?), not Merge's. Don't compare these to webhook payloads or API responses; compare those to the uppercase values in the table above.

AccountToken response schema

Endpoint: GET https://api.merge.dev/api/integrations/account-token/{public_token}

FieldTypeNotes
account_tokenstringLong-lived credential — store in DB (column must be nullable)
integrationSDK model object.name = string. Not a dict — use .name, not the raw object
idstring (UUID)Merge's Linked Account ID. Store this — needed for webhook matching

⚠️ Response does NOT contain end_user_origin_id. Pass it from the frontend alongside public_token.

Python — complete exchange handler:

@app.route("/api/merge/exchange", methods=["POST"])
def exchange_token():
    data = request.json
    public_token = data["public_token"]
    origin_id = data["end_user_origin_id"]

    merge = Merge(api_key=os.environ["MERGE_API_KEY"])
    result = merge.crm.account_token.retrieve(public_token=public_token)

    linked = LinkedAccount.query.filter_by(end_user_origin_id=origin_id, status="pending").first()
    if not linked:
        return jsonify({"error": "No pending record found"}), 404
    linked.account_token = result.account_token
    linked.merge_account_id = result.id               # Store Merge's UUID for webhook matching
    linked.integration_name = result.integration.name if result.integration else None
    linked.status = "active"
    db.session.commit()
    return jsonify({"status": "connected", "integration": linked.integration_name})

Node — complete exchange handler:

app.post("/api/merge/exchange", async (req, res) => {
  const { publicToken, endUserOriginId } = req.body;
  const merge = new MergeClient({ apiKey: process.env.MERGE_API_KEY });

  // Wrap the SDK call. A reused or expired public_token throws MergeError 404 —
  // unhandled, this kills the Node process and your dev server stops responding.
  let result;
  try {
    result = await merge.crm.accountToken.retrieve(publicToken);
  } catch (e: any) {
    console.warn("[exchange] retrieve failed:", e?.statusCode, e?.body?.detail);
    return res.status(400).json({ error: "exchange_failed", detail: e?.body?.detail });
  }

  const integrationName = result.integration?.name ?? null;
  const { rowCount, rows } = await db.query(
    `UPDATE linked_accounts SET account_token=$1, integration_name=$2, merge_account_id=$3,
       status='active', updated_at=NOW()
     WHERE end_user_origin_id=$4 AND status='pending' RETURNING id`,
    [result.accountToken, integrationName, result.id ?? null, endUserOriginId]);
  if (!rowCount) return res.status(404).json({ error: "No pending record" });
  res.json({ status: "connected", integration: integrationName, linkedAccountId: rows[0].id });
});

// Dev-server backstop — keeps Express alive on unhandled SDK errors so a bad
// request doesn't take down the whole process. NOT a substitute for per-request try/catch.
process.on("unhandledRejection", (e) => console.error("[unhandledRejection]", e));
process.on("uncaughtException",  (e) => console.error("[uncaughtException]", e));

SDK objects vs JSON: result.integration is a pydantic model (Python) / typed object (Node), not a plain dict. Use .name for the string. Don't pass the raw object to jsonify() / res.json().

Step 6: Make your first API call

Two headers on every call — both reads AND writes: Authorization: Bearer YOUR_API_KEY + X-Account-Token: ACCOUNT_TOKEN. SDKs handle this when you pass account_token at init. Forgetting X-Account-Token on a write returns 401 with no clear hint that the missing header is the cause.

Where does account_token come from? Either (a) the value persisted by Step 5 for a real Linked Account, or (b) for a quick smoke test, copy a Test Linked Account's token directly from https://app.merge.dev/linked-accounts/test → click your test account → copy account_token.

All examples below use merge.crm.* as a placeholder. Replace .crm with your category slug (hris, ats, crm, accounting, ticketing, filestorage, knowledgebase, mktg) and swap contacts for the matching Common Model (employees, candidates, tickets, etc.). Querying with the wrong category against an account_token that's scoped to a different category returns an empty array, not an error — silent footgun.

Always paginate

Merge returns paginated results. Without cursor handling you only get page 1. Real accounts have thousands of records.

Python — paginated list:

merge = Merge(api_key="YOUR_TEST_KEY", account_token=account_token)
all_results, cursor = [], None
while True:
    page = merge.crm.contacts.list(cursor=cursor, page_size=100)   # replace .crm + .contacts
    all_results.extend(page.results)
    if page.next is None: break
    cursor = page.next
print(f"Fetched {len(all_results)} contacts")

Node — paginated list:

const merge = new MergeClient({ apiKey: "YOUR_TEST_KEY", accountToken });
const all = [];
let cursor: string | undefined;
do {
  const page = await merge.crm.contacts.list({ cursor, pageSize: 100 });  // replace .crm + .contacts
  all.push(...(page.results ?? []));
  cursor = page.next ?? undefined;
} while (cursor);
console.log(`Fetched ${all.length} contacts`);

Common Model field shapes

Fields are NOT all strings. Some are arrays of nested objects:

FieldTypeExtract
emailAddresses[{emailAddress, emailAddressType}]c.emailAddresses?.[0]?.emailAddress
phoneNumbers[{phoneNumber, phoneNumberType}]c.phoneNumbers?.[0]?.phoneNumber
accountReference object {id, name} or string IDtypeof c.account === "object" ? c.account?.name : null
modifiedAtISO 8601 timestampKey for incremental sync — use with modified_after query param

Provider-specific fields: Use Remote Data (enable in Configuration → Common Model Scopes) or the Field Mappings API. See /merge-unified:post-connection-enable-custom-fields.

Incremental sync: modifiedAt + the modified_after query param = only fetch changed records. See /merge-unified:implementing-sync for the full pattern.

Full schemas: references/common-models.md.

Writing data back

Write operations (tickets.create, contacts.create, etc.) return a triple, not just the created object:

const result = await merge.ticketing.tickets.create({
  model: { name: "Bug: login failure" },
});
// result.model     → the created object on the provider side (may be partial)
// result.warnings  → array of provider-side issues that did NOT block creation
// result.errors    → array of structured errors (rare; usually warnings, not errors)

if (result.warnings?.length) {
  console.warn("provider rejected fields:", result.warnings);
  // e.g. [{ source: { pointer: "/model/collections" }, title: "Required field missing" }]
}

⚠️ Provider field requirements differ from Common Model fields. The Common Model schema lets you submit { name } and the SDK accepts it, but the underlying provider may require additional fields (e.g. GitLab tickets need collections). Missing required fields show up in result.warnings, not as a thrown error. Always inspect result.warnings before treating a write as successful.

Step 7: Set up webhooks (recommended)

Default sync cadence: 24 hours in production (configurable per Linked Account).

Configure in dashboard:

Local dev tunnel hostnames rotate. cloudflared tunnel --url localhost:3000 (default quick mode) and ngrok http 3000 (free tier) issue a fresh random subdomain on every restart, breaking any emitter URL you've already configured in the dashboard. For repeated dev work, use a named cloudflared tunnel (cloudflared tunnel create + DNS) or an ngrok reserved domain so the URL is stable across restarts.

⚠️ The "Send test" button sends a connectivity ping, NOT a real event. You'll see {"response": "Success! This URL will be notified."} — your handler will get event_type=undefined. This is normal. To test real events, reconnect via Merge Link with the Test integration.

Signature verification (REQUIRED)

Header: X-Merge-Webhook-Signature. Algorithm: HMAC-SHA256, base64url (not standard base64).

⚠️ Verify against raw body bytes BEFORE JSON parsing. In Express, mount the webhook route with express.raw() BEFORE express.json() middleware.

Python:

import hmac, hashlib, base64

def verify_merge_webhook(payload_bytes: bytes, signature: str, secret: str) -> bool:
    digest = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).digest()
    expected = base64.urlsafe_b64encode(digest).decode().rstrip("=")
    return hmac.compare_digest(expected, signature.rstrip("="))

Node (Express):

import { raw } from "express";
import crypto from "node:crypto";

// Mount BEFORE express.json() middleware
app.post("/webhook", raw({ type: "application/json" }), (req, res) => {
  const sig = req.header("X-Merge-Webhook-Signature") ?? "";
  const rawBody = req.body as Buffer;
  const expected = crypto.createHmac("sha256", process.env.MERGE_WEBHOOK_SECRET!)
    .update(rawBody).digest("base64url").replace(/=+$/, "");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.replace(/=+$/, "")))) {
    return res.status(401).send("invalid signature");
  }
  const event = JSON.parse(rawBody.toString("utf8"));
  res.sendStatus(200);  // ACK fast — Merge times out after 10s; aim to respond in <5s
  setImmediate(() => processEvent(event));  // Process async; use a real queue in production
});

More webhook event types and payload schemas: references/webhooks.md.

Step 8: Production checklist

Frontend

  • Merge Link embedded, link_tokens from backend (never client-side)
  • Re-connect flow: "Reconnect" button when status = relink_needed, using same end_user_origin_id

Backend

  • Webhook listeners with HMAC-SHA256 base64url signature verification
  • Async webhook processing (queue — Merge times out after 10s and counts the delivery as failed; aim to ACK in under 5s)
  • Pagination on all list endpoints (cursor loop)
  • API error handling per status: 401 → relink, 403 → enable scope at https://app.merge.dev/configuration/common-model-scopes, 429 → exponential backoff, 5xx → retry then alert
  • Encrypt account_token at rest (KMS / pgcrypto)

Configuration

  • Common Model scopes enabled at https://app.merge.dev/configuration/common-model-scopes
  • Selective Sync configured for end-users with large datasets — filters at the source so Merge fetches only what you need (configure per Linked Account in the dashboard)
  • Tested with a production Linked Account (not just sandbox)

⚠️ Default scopes are minimal and category-specific. A fresh Ticketing org has Ticket, Contact, Tag, Role, Team, Collection, Permission, RemoteFieldClass enabled by default — and User, Account, Project, Comment, Attachment, Viewer disabled. Most ticketing dashboards need User (resolve assignees) and Comment (ticket replies); both off until you flip them. Other categories have different defaults — verify yours at the URL above before assuming a query will return data.

Switch from test_xxx to production_xxx key and ship.

Common Model reference (quick)

CategoryPrimary ModelKey fields
HRISEmployeefirst_name, last_name, work_email, employments[], manager
ATSCandidatefirst_name, last_name, company, title
CRMContactfirst_name, last_name, account {id, name}, email_addresses [{emailAddress, emailAddressType}], phone_numbers [{phoneNumber, phoneNumberType}]
AccountingInvoicetype, contact, number, issue_date, due_date
TicketingTicketname, status, assignees[], creator, due_date
File StorageFilename, file_url, size, mime_type, folder
Knowledge BaseArticletitle, description, author, visibility
MarketingCampaignname, unique_opens, emails_sent

email_addresses and phone_numbers are arrays of objects, not strings. account is a reference object with {id, name}, not a name string. See references/common-models.md for full schemas.

Troubleshooting


SYMPTOM: API call returns an empty results array. CAUSE: Diagnose in order: (1) Common Model scope not enabled → enable at https://app.merge.dev/configuration/common-model-scopes. (2) Initial sync still running → check GET /sync-status, look for is_initial_sync: true with status: "SYNCING" (can take 30 min to hours). (3) Sync failed → check Linked Account detail page. FIX: Most common is #1. Enable the scope and re-check.


SYMPTOM: 401 Unauthorized on every API call. CAUSE: Wrong API key, missing header, or key environment mismatch (test key vs production data). FIX: Verify at https://app.merge.dev/keys. Match key environment to data environment.


SYMPTOM: 400 Bad Request on /account-token/{public_token}. CAUSE: Public token already used (one-time) or expired (TTL is short and not documented; treat as minutes — exchange immediately on receipt, don't store). FIX: Re-trigger Merge Link for a new public_token. Exchange immediately.


SYMPTOM: 400 with "Organization has already reached their maximum number of test accounts." CAUSE: Test tier cap on simultaneous test Linked Accounts. FIX: Delete unused at https://app.merge.dev/linked-accounts/test.


SYMPTOM: link_token rejected as expired. CAUSE: link_tokens expire after 30 minutes by default. Max with link_expiry_mins is 720 minutes (12 hours), or 10,080 minutes (7 days) for Magic Link. FIX: Generate fresh on every Merge Link open.


SYMPTOM: Webhook handler sees event_type=undefined or {"response": "Success!"}. CAUSE: You clicked "Send test" in dashboard — that's a connectivity ping, not a real event. FIX: Reconnect via Merge Link with the Test integration to trigger real events.


SYMPTOM: Webhook signature verification fails. CAUSE: Wrong secret, body parsed before check, standard base64 (not base64url), or = padding mismatch. FIX: Use webhook secret (not API key). Verify raw bytes BEFORE JSON parse. Use base64url. Strip = padding. In Express, mount express.raw() BEFORE express.json().


SYMPTOM: Linked Account shows "relink_needed" or "incomplete". CAUSE: End-user revoked access or credentials expired. FIX: Generate new link_token with same end_user_origin_id, re-open Merge Link.


SYMPTOM: Reopening Merge Link shows "You're connected!" with no integration picker, but your DB has no record of the connection. CAUSE: OAuth completed on Merge's side but your /exchange never ran (modal closed early, network error, Finish-button skipped). Merge has the Linked Account; your DB doesn't. FIX: Reconcile from the linked_account.created webhook (production-correct), or delete the orphan at https://app.merge.dev/linked-accounts/test and reconnect (dev shortcut).


SYMPTOM: After clicking Reconnect, a new Linked Account row appears in your DB instead of the broken one being repaired. CAUSE: Your reconnect handler called the regular connect flow, which generated a fresh end_user_origin_id (or appended a counter suffix) instead of reusing the broken row's exact value. FIX: Reconnect must pass the broken row's end_user_origin_id verbatim to link_token.create — never compute a new one.


SYMPTOM: tickets.create() (or any write) returns successfully but the created object is missing fields you sent. CAUSE: The provider rejected fields silently. Merge surfaces these as result.warnings, not as a thrown error. FIX: Inspect result.warnings after every write. For provider-required fields not in the Common Model, send them via remote_fields or use Field Mappings. For GitLab tickets specifically, include collections.


SYMPTOM: After deleting a Linked Account in the dashboard and clicking Reconnect, you get a new merge_account_id and lose all sync history. CAUSE: "Delete + Reconnect" is not equivalent to "Reconnect." When the Linked Account is deleted, there's nothing on Merge's side to repair — relink degrades to a fresh connect with a brand new merge_account_id and account_token. FIX: For credential issues (token expired, user deauthorized at source), use Reconnect — never Delete first. Delete only when you genuinely want to start over. See /merge-unified:post-connection-implement-relinking.


SYMPTOM: is_initial_sync: true and status: SYNCING for a long time. CAUSE: Initial sync in progress — normal for large accounts (30 min to hours). FIX: Wait. Check Linked Account detail page for progress.


SYMPTOM: last_sync_result: FAILED but status: SYNCING. CAUSE: FAILED is from a previous attempt; current run is still going and may succeed. FIX: Wait for current run to complete.


SYMPTOM: Field exists on source provider but not on Common Model response. CAUSE: Common Model normalizes across providers. Provider-specific fields go on remote_data. FIX: Enable Remote Data in Configuration → Common Model Scopes, or use Field Mappings API.

When to ask the user vs proceed

Always ask at start: which category, which SDK language.

Pick a sensible default without asking: test environment first, both SDK and HTTP examples, end_user_origin_id = "your_user_id" as placeholder.

Ask before proceeding if: multiple categories requested, or production-sensitive concerns (PII, encryption, multi-region).

Next steps: go deeper

Your basic integration is set up. Here's what to do next:

  • Validate: /merge-unified:integration-validator — diagnostic checks on API key, account_token, sync status
  • Full Merge Link (all 4 endpoints, database schema, production frontend): /merge-unified:implementing-link
  • Automated data syncing (polling or webhooks, incremental fetches): /merge-unified:implementing-sync
  • Post-connection (settings page, sync status, relink, custom fields): /merge-unified:implementing-post-connection

Reference docs

  • Common Model schemas per category: references/common-models.md
  • SDK install for all 6 languages: references/sdk-quickstarts.md
  • Full link_token lifecycle + Magic Link variant: references/auth-flow.md
  • Webhook event types + signature verification: references/webhooks.md

External: Merge docs · API status · Sign up · API keys

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.