From merge-unified
Implement the four Merge Link backend API endpoints: link token creation, public token exchange, relinking, and deletion. Use as Step 3 of Merge Link implementation.
How this skill is triggered — by the user, by Claude, or both
Slash command
/merge-unified:link-implement-backendThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Implements the server-side API that powers the Merge Link flow. These endpoints are called by the frontend to initiate connections, exchange tokens, refresh credentials, and delete integrations.
Implements the server-side API that powers the Merge Link flow. These endpoints are called by the frontend to initiate connections, exchange tokens, refresh credentials, and delete integrations.
link-setup-database complete (linked_accounts table exists with end_user_origin_id, account_token, integration_slug, category, status columns)MERGE_API_KEY environment variable setBefore writing any code, confirm or gather the following:
hris, ats, crm, accounting, ticketing, filestorage, knowledgebase) — used to scope endpoints.end_user_origin_id generation in Endpoint 1.If invoked from implementing-link, these were answered in Step 1d — use that context. Otherwise, ask the user now before proceeding.
If using the Merge SDK (recommended): Merge ships official SDKs for six backend languages — pick the one matching your stack.
| Language | Install | Import |
|---|---|---|
| Python | pip install MergePythonClient | from merge import Merge |
| Node / TypeScript | npm install @mergeapi/merge-node-client | import { MergeClient } from "@mergeapi/merge-node-client" |
| Java / Kotlin | Maven dev.merge:merge-java-client (Gradle: implementation "dev.merge:merge-java-client:<version>") | import com.merge.api.MergeApiClient; |
| Go | go get github.com/merge-api/merge-go-client/v2 | import mergeclient "github.com/merge-api/merge-go-client/v2/client" |
| Ruby | gem install merge_ruby_client (or gem "merge_ruby_client" in Gemfile) | require "merge_ruby_client" |
| C# / .NET | dotnet add package Merge.Client | using Merge.Client; |
Check if the SDK is already installed before adding it. Do not add a dependency that's already present. For per-language initialize / list / paginate samples, see ../onboarding/references/sdk-quickstarts.md.
If using raw HTTP: use whatever HTTP client your stack already has — requests / httpx (Python), axios / fetch (Node), net/http (Go), Net::HTTP / Faraday (Ruby), HttpClient (.NET), OkHttp (JVM). The endpoint patterns shown below work for any.
These are routes on your server, not Merge's API. The
/api/merge/...paths below are endpoints you add to your own backend. They wrap Merge API calls with your authentication middleware and business logic. Name them however fits your existing API structure.
Implement all four endpoints with authentication middleware on each. Use the existing DB model and HTTP client pattern already in the codebase.
category (validated against ["hris", "ats", "crm", "accounting", "ticketing", "filestorage", "knowledgebase"]) and optional integration from request body. Reject unknown category values.end_user_origin_id based on the strategy chosen in Step 1:
end_user_origin_id + category for uniqueness, so the same stable ID will produce one Linked Account per category per org.pending record for this org+category first. If one exists (incomplete prior attempt), reuse its end_user_origin_id. If none exists, generate a new GUID. Do NOT generate a new GUID on every click — that creates duplicate Linked Accounts on every open-and-abandon. Full deduplication logic: see ../implementing-link/references/backend-implementation.md under "Handling Incomplete Linking Attempts."linked_accounts record NOW with status = "pending" — do this BEFORE calling the Merge API (prevents duplicate accounts if the modal is opened multiple times)end_user_origin_id, reuse it. Dedup pattern: INSERT ... ON CONFLICT (end_user_origin_id) WHERE status = 'pending' DO NOTHING, or delete the prior pending row before inserting.POST https://api.merge.dev/api/{category}/v1/link-token with Authorization: Bearer {MERGE_API_KEY}, passing end_user_origin_id, end_user_email_address, end_user_organization_name, categories, and optional integration{ link_token } to the frontendpublic_token and end_user_origin_id from frontend (the frontend must send the origin ID alongside the public token — the exchange response does NOT contain it)POST https://api.merge.dev/api/{category}/v1/account-token/{public_token} with Authorization: Bearer {MERGE_API_KEY} to get account_tokenGET https://api.merge.dev/api/{category}/v1/account-details with both Authorization: Bearer {MERGE_API_KEY} and X-Account-Token: {account_token} headersend_user_origin_id, integration, and integration_slug from the account details response (top level, not nested — see gotchas)linked_accounts record by end_user_origin_idaccount_token, integration_slug, status = "active"Account-token response (from step 2):
| Field | Type | Notes |
|---|---|---|
account_token | string | The long-lived credential |
integration | SDK model object | Has .name (string). Not a dict — use .name for the string |
id | string (UUID) | Merge's ID for this Linked Account |
⚠️ This response does NOT include end_user_origin_id. Pass it from the frontend or use the end_user_origin_id from the account-details call below.
Account-details response (from step 3):
| Field | Type | Notes |
|---|---|---|
end_user_origin_id | string | The ID you sent in step 1 — use to look up the pending record |
integration | string | Provider name (this IS a plain string, unlike the account-token response) |
integration_slug | string | Provider slug |
status | string | Connection status |
id | string (UUID) | Merge's Linked Account ID |
SDK type warning: When using the Merge SDK,
account_token_response.integrationis an SDK model object (use.name), butaccount_details.integrationis a plain string. They are different types despite the same field name.
linked_account_idlinked_accounts record — verify it belongs to the current userend_user_origin_id — do NOT generate a new ID or create a new DB record{ link_token } to the frontendThe reconnect data flow that almost everyone gets wrong on the first try:
// Server-side: pull the broken row's exact end_user_origin_id and reuse it.
app.post("/api/merge/relink-integration", async (req, res) => {
const { linkedAccountId } = req.body;
const { rows } = await db.query(
`SELECT end_user_origin_id, end_user_email, organization_name, category
FROM linked_accounts WHERE id = $1`,
[linkedAccountId],
);
const broken = rows[0];
// Reuse the stored origin_id verbatim — do NOT compute a new one.
const merge = new MergeClient({ apiKey: process.env.MERGE_API_KEY });
const response = await merge[broken.category].linkToken.create({
endUserOriginId: broken.end_user_origin_id, // KEY POINT — same value
endUserEmailAddress: broken.end_user_email,
endUserOrganizationName: broken.organization_name,
categories: [broken.category],
});
// Flip the row back to pending so /exchange updates it (instead of inserting a new row).
await db.query(
`UPDATE linked_accounts SET status='pending', updated_at=NOW() WHERE id=$1`,
[linkedAccountId],
);
res.json({ link_token: response.linkToken });
});
⚠️ Wiring the Reconnect button to your regular create-link-token endpoint will silently create a duplicate Linked Account every time a user clicks Reconnect — your app keeps working, but you accumulate orphans. The Reconnect path must call this dedicated endpoint with the broken row's id, and this endpoint must reuse the stored end_user_origin_id exactly.
linked_account_idlinked_accounts record — verify it belongs to the current userPOST https://api.merge.dev/api/{category}/v1/delete-account with Authorization: Bearer {MERGE_API_KEY} and X-Account-Token: {account_token} headers (Merge uses POST, not DELETE, for this operation)linked_accounts record from the DBend_user_origin_id must be stored before calling Merge API. If the DB write happens after the Merge API call, repeated modal opens can create duplicate Merge accounts with no local record to match against.
Account details response is flat. integration and integration_slug are top-level fields (not nested). When using the SDK:
# Account-details: integration IS a plain string
integration_name = account_details.integration # provider name string
integration_slug = account_details.integration_slug # provider slug string
# WRONG — on account-details, "integration" is a string, not an object
integration_name = account_details["integration"]["name"] # TypeError
# Account-token: integration is an SDK MODEL OBJECT (different!)
integration_name = account_token_response.integration.name # provider name string
# account_token_response.integration is NOT a string — don't pass it directly to JSON
Relinking reuses the existing record. Pass the stored end_user_origin_id to Merge — do not generate a new one or insert a new row.
Relink UX: Show a "Reconnect" button on the frontend when status = "relink_needed". On click, call your /api/merge/relink-integration endpoint to get a fresh link_token, then re-open Merge Link with it. The user re-authenticates and the existing Linked Account is updated — no new record created. Example:
// Frontend: show reconnect when status is relink_needed
if (account.status === "relink_needed") {
showButton("Reconnect " + account.integrationName, async () => {
const { link_token } = await fetch("/api/merge/relink-integration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ linked_account_id: account.id }),
}).then(r => r.json());
openMergeLink(link_token); // Same openMergeLink from Step 4
});
}
Delete uses POST, not HTTP DELETE. Merge's delete-account endpoint is POST /delete-account, not DELETE /linked-accounts/{id}.
Wrap every Merge API call in error handling. Merge returns standard HTTP status codes:
| Status | Meaning | Action |
|---|---|---|
200 | Success | Process the response |
401 | API key or account_token invalid | Surface to user: "Check your API key" or trigger relink flow |
429 | Rate limited | Retry with exponential backoff (1s, 2s, 4s), max 3 retries |
500 | Merge server error | Retry once after 2s, then fail gracefully with a user-facing message |
Rate limit retry pattern:
import time
def merge_api_call(method, url, headers, **kwargs):
for attempt in range(3):
resp = getattr(requests, method)(url, headers=headers, **kwargs)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 2 ** attempt))
time.sleep(retry_after)
continue
return resp
return resp # Return last response even if still 429
Idempotence on failure: If create-link-token fails after the DB record is created, the record stays as status = "pending". The next attempt finds the existing pending record and reuses it — no duplicate is created. This is by design.
linked_accounts record created BEFORE Merge API call in endpoint 1account_token in DBintegration_slug and category populated correctly after exchangeCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub merge-api/merge-unified-skills --plugin merge-unified