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.7.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:
| Category | Providers (examples) | Primary Common Model |
|---|---|---|
| HRIS | e.g. Workday, BambooHR, Gusto | Employee |
| ATS | e.g. Greenhouse, Lever | Candidate |
| CRM | e.g. Salesforce, HubSpot | Contact |
| Accounting | e.g. QuickBooks, Xero | Invoice |
| Ticketing | e.g. Jira, Zendesk | Ticket |
| File Storage | e.g. Google Drive, Dropbox | File |
| Knowledge Base | e.g. Confluence, Notion | Article |
| Marketing | e.g. Mailchimp, HubSpot | Campaign |
This is not exhaustive — Merge supports many more providers per category. See the full list at https://merge.dev/integrations.
⚠️ Marketing Automation is the thin one. The mktg endpoints exist and mktg is a valid categories value on a link_token, but the category has no public API reference (docs.merge.dev redirects /mktg/* to the HRIS overview) and no SDK exposes merge.mktg.*. If a developer asks for Marketing, say so up front, build against the raw REST endpoints, and verify each call against a live Linked Account. Every other category in the table has both a reference page and SDK coverage in at least Python.
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 endpoint URLs andcategoriesarrays. The SDK method path matches the category:merge.hris.link_token.create(...),merge.crm.contacts.list(...), etc.
⚠️ The SDKs cover fewer categories than the API does. All eight slugs are valid on the REST API and in a link_token's categories array, but the clients only namespace some of them:
| SDK | Namespaces available |
|---|---|
| Python | accounting, ats, crm, filestorage, hris, knowledgebase, ticketing (+ calendar, chat, email) |
| Java / Kotlin | accounting, ats, crm, filestorage, hris, knowledgebase, ticketing (+ chat) |
| Node, Go, Ruby, C#/.NET | accounting, ats, crm, filestorage, hris, ticketing |
No SDK has a mktg namespace, and only Python and Java have knowledgebase. For a category your SDK doesn't cover, call the REST endpoints directly with Authorization: Bearer + X-Account-Token — the link_token and account_token flow is identical.
Step 0: Confirm context
Ask the developer (one at a time, skip questions whose answers are obvious from their first message):
- Which Merge category? HRIS, ATS, CRM, Accounting, Ticketing, File Storage, Knowledge Base, or Marketing.
- Which SDK language? Python, Node.js (TypeScript), Java/Kotlin, Go, Ruby, or C#/.NET. (Or vanilla HTTP if they prefer.)
- 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 prefix | Type | Creates | Visible in dashboard |
|---|---|---|---|
test_xxx |
Test | Test Linked Accounts | "Test Linked Accounts" page |
production_xxx |
Production | Real 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, and the included count varies by plan — check https://www.merge.dev/pricing or your Billing page rather than assuming. 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>=4.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 emailend_user_organization_name— your customer's company nameend_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_tokenper category. Each category becomes its own Linked Account with its ownaccount_token— track them as separate rows keyed by(end_user_origin_id, category). Don't pass multiple categories in a singlelink_tokenunless 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)— calllink_token.createtwice 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 toend_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 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 LinkedAccount.linked 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 status | Meaning | Action |
|---|---|---|
COMPLETE |
Healthy, syncing | None |
INCOMPLETE |
Linking flow not finished | Prompt user to complete Merge Link |
RELINK_NEEDED |
Credentials expired or revoked at source | Trigger re-link flow |
IDLE |
Active but no recent sync | Investigate, may be normal |
Local DB convention. The
linked_accounts.statuscolumn 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}
| Field | Type | Notes |
|---|---|---|
account_token |
string | Long-lived credential — store in DB (column must be nullable) |
integration |
SDK model object | .name = string. Not a dict — use .name, not the raw object |
id |
string (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.integrationis a pydantic model (Python) / typed object (Node), not a plain dict. Use.namefor the string. Don't pass the raw object tojsonify()/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_tokencome 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 → copyaccount_token.
All examples below use
merge.crm.*as a placeholder. Replace.crmwith your category slug (hris,ats,crm,accounting,ticketing,filestorage,knowledgebase,mktg) and swapcontactsfor 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:
| Field | Type | Extract |
|---|---|---|
emailAddresses |
[{emailAddress, emailAddressType}] |
c.emailAddresses?.[0]?.emailAddress |
phoneNumbers |
[{phoneNumber, phoneNumberType}] |
c.phoneNumbers?.[0]?.phoneNumber |
account |
Reference object {id, name} or string ID |
typeof c.account === "object" ? c.account?.name : null |
modifiedAt |
ISO 8601 timestamp | Key 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: the Daily tier syncs every 24 hours. Sync frequency is a plan setting applied per organization and category (Highest / Daily / Monthly / Quarterly / Manual) — it is not a per-Linked-Account toggle. Check yours on the Billing page; changing it goes through Merge.
Configure in dashboard:
- Emitters (Merge → your app — sync-completed events, Linked Account lifecycle, what most apps want): https://app.merge.dev/configuration/webhooks/emitters
- Receivers (third-party provider → Merge — real-time updates from Jira/Salesforce/etc. for sub-24h freshness on supported providers): https://app.merge.dev/configuration/webhooks/receivers
Local dev tunnel hostnames rotate.
cloudflared tunnel --url localhost:3000(default quick mode) andngrok 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 sameend_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_tokenat 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 and Contact on read+write, and Tag, Role, Team, Collection, Permission, RemoteFieldClass on read — with 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)
| Category | Primary Model | Key fields |
|---|---|---|
| HRIS | Employee |
first_name, last_name, work_email, employments[], manager |
| ATS | Candidate |
first_name, last_name, company, title |
| CRM | Contact |
first_name, last_name, account {id, name}, email_addresses [{emailAddress, emailAddressType}], phone_numbers [{phoneNumber, phoneNumberType}] |
| Accounting | Invoice |
type, contact, number, issue_date, due_date |
| Ticketing | Ticket |
name, status, assignees[], creator, due_date |
| File Storage | File |
name, file_url, size, mime_type, folder |
| Knowledge Base | Article |
title, description, author, visibility |
| Marketing | Campaign |
name, unique_opens, emails_sent |
email_addressesandphone_numbersare arrays of objects, not strings.accountis a reference object with{id, name}, not a name string. Seereferences/common-models.mdfor 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 LinkedAccount.linked 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