# Onboarding

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

- Skill: `merge-api/onboarding` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add merge-api/onboarding`
- Raw SKILL.md: https://api.skillmd.com/api/skills/merge-api/onboarding/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- License: MIT
- Author: merge-api (https://skillmd.com/u/merge-api)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/merge-api/onboarding

---


# 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 and `categories` arrays. 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**):

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 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:**
```bash
pip install "MergePythonClient>=4.0.0"
```

**Node.js / TypeScript:**
```bash
npm install @mergeapi/merge-node-client
```

**Java / Kotlin (JVM):**
```groovy
// build.gradle
implementation 'dev.merge:merge-java-client'
```

For React frontend (Merge Link component):
```bash
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:**
```python
@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:**
```typescript
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_token`s, 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).

```tsx
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

```html
<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.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}`

| 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:**
```python
@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:**
```typescript
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:**
```python
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:**
```typescript
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:

```typescript
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) 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:**
```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):**
```typescript
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 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_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 `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](https://docs.merge.dev/) · [API status](https://status.merge.dev) · [Sign up](https://app.merge.dev/signup) · [API keys](https://app.merge.dev/keys)

