For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Logo
Resources
Log inGet a demo
Get startedAPI referenceImplementation
Get startedAPI referenceImplementation
  • Authentication
    • Merge Link
    • Prompt guide for Merge Link
    • Magic Link
    • Single integration
  • Reading data
    • Syncing best practices
  • Supplemental data
    • Overview
    • Remote Data
    • Custom objects
  • Writing data
    • Overview
  • Platform and account metadata
    • Integration metadata
    • Linked Accounts
  • Testing
    • Testing Merge's Unified API via Postman
  • Specifications
    • Schema properties
    • Model Context Protocol (MCP)
UnifiedAgent HandlerGateway
UnifiedAgent HandlerGateway
Resources
Log inGet a demo
On this page
  • Please read before beginning
  • Step 1: Context setting
  • Step 1.1: Merge Link context
  • Step 1.2: End user origin ID considerations
  • Step 2: Embedding Merge Link
  • Option 1: Connect integration button
  • Step 2.1.1: Link token creation (backend)
  • Step 2.1.2: Merge Link modal & token exchange (frontend & backend)
  • Step 2.1.3: Relinking & deleting linked accounts
  • Option 2: App center / integration marketplace
  • Step 2.2.1: Link token creation (backend)
  • Step 2.2.2: Merge Link modal & token exchange (frontend & backend)
  • Step 2.2.3: Relinking & deleting linked accounts
Authentication

Implementing Merge Link with a coding agent

Was this page helpful?
Previous

Merge Link

Next

Magic Link

Built with

This article is part of a series to help developers accelerate a Merge implementation by leveraging coding agents (e.g., Claude Code, Copilot, or similar). These prompts aren’t intended to fully replace developer work, but to streamline implementation and reduce repetitive coding overhead.

The goal of this article is to implement the Merge Link authentication flow in a way that feels seamless to end users. On the frontend, users should only see business-relevant messaging about connecting their third-party system, without exposure to Merge-specific terminology. Token generation and exchanges needed for linking an integration (link_token → public_token → account_token) will be handled on the backend. By the end of this section, you’ll have a working integration button or app center flow that establishes and manages linked accounts, setting the foundation for syncing data.

Agents can hallucinate. Always review the code changes that they suggest.


Please read before beginning

There are a few pre-requisities and things to keep in mind before continuing through this article:

  • You should already have a valid Merge API key stored securely.
  • Prompts you should provide to the agent are shown in blockquotes below.

Step 1: Context setting

The following markdown files should be added to your codebase for additional context. Before getting started, ask the agent to read through them so it understands the Merge implementation workflow:

📄 merge_platform_overview.md
1# Merge Platform Overview
2
3## What is Merge.dev?
4
5Merge is a unified API platform that eliminates the need to build and maintain individual integrations with different vendor APIs. Instead of handling dozens of different schemas, auth methods, versions, and edge cases, you integrate once with Merge.
6
7## Core Benefits
8
9- **Single Integration**: One API instead of dozens of vendor-specific integrations
10- **Unified Schema**: Consistent data structure across all providers
11- **Automatic Updates**: Merge handles API changes, versioning, and maintenance
12- **Built-in Auth**: OAuth, API keys, and authentication handled automatically
13
14## Key Concepts
15
16### Categories
17Merge organizes integrations by business function:
18- **HRIS**: Human Resources (Workday, BambooHR, HiBob)
19- **ATS**: Applicant Tracking (Greenhouse, Lever, Workable)
20- **CRM**: Customer Relationship Management (Salesforce, HubSpot)
21- **Accounting**: Financial systems (QuickBooks, Xero)
22- **Ticketing**: Support systems (Zendesk, Intercom)
23- **FileStorage**: File Management (Google Drive, Dropbox, Box)
24- **Knowledge Base**: Knowledge management systems (Confluence, Notion, Guru)
25
26### End User Origin ID
27- **Purpose**: Unique identifier for each integration within a category
28- **Critical Rule**: Must be stored before calling Merge API to prevent duplicate incomplete accounts
29- **Lifecycle**: Permanent - never changes during relinking
30- **Strategic Decision**: Format depends on your integration architecture (see End User Origin ID Strategy below)
31
32### Account Token
33- **Purpose**: Authenticates API requests to Merge for specific integration
34- **Lifecycle**: Permanent - never changes during relinking
35- **Usage**: Combined with API key for all data sync requests
36
37### Link Token
38- **Purpose**: Short-lived token for initializing Merge Link modal
39- **Lifecycle**: Single-use - generate fresh token for every modal opening
40- **Critical Rule**: Never cache or reuse link tokens
41
42## Authentication Flow (3 Steps)
43
441. **Backend**: Create link_token with end_user_origin_id and category → receive link_token
452. **Frontend**: User completes Merge Link modal → receive public_token
463. **Backend**: Exchange public_token for account_token → store for API calls
47
48**Multi-Category Architecture**: Each category (HRIS, ATS, CRM, etc.) requires its own authentication flow and maintains separate account tokens. A single organization can have multiple active integrations across different categories simultaneously.
49
50## Key Requirements Discovered
51
52### Fresh Tokens Always
53- Generate new link_token on every button click
54- Never reuse or cache link tokens between attempts
55- Each modal opening requires fresh authentication
56
57### End User Origin ID Storage
58- Must be stored immediately when generating link_token
59- Prevents creating multiple incomplete accounts in Merge's system
60- Required for proper integration lifecycle management
61
62### Invisible Integration
63- Zero Merge terminology exposed in user interface
64- Business-focused messaging only ("Connect HR System" not "Connect via Merge")
65- Seamless user experience that feels native to your application
66
67## Data Syncing Strategy
68
69### Hybrid Approach (Recommended)
70- **Webhooks**: Real-time updates with double-webhook system for reliability
71- **Polling**: Backup sync (minimum every 24 hours) since webhooks can fail
72- **Forced Resyncs**: Available when needed for data consistency
73
74### Reliability Features
75- Exponential backoff for failed webhook deliveries
76- Multiple retry attempts for network issues
77- Real-time data payload delivery to your application
78
79## Data Syncing Concepts
80
81### Initial Sync Lifecycle
82
83When a user successfully completes the Merge Link authentication flow, Merge automatically initiates an **initial sync** in the background. This is a one-time process that fetches the complete historical dataset from the connected third-party system.
84
85**Key Characteristics**:
86- **Automatic**: Begins immediately after authentication, no manual triggering required
87- **Background Process**: Happens asynchronously on Merge's infrastructure
88- **Duration**: Can take minutes to hours depending on dataset size
89- **Scope**: Fetches all available data for enabled models (Employee, Company, TimeOff, etc.)
90- **One-Time**: Only runs once per integration connection
91
92**Initial Sync States**:
93Your application should detect when the initial sync completes before attempting to fetch data. This prevents requesting data that hasn't finished syncing yet.
94
95### Sync Status Monitoring
96
97Merge provides the `GET /api/{category}/v1/sync-status` endpoint to monitor sync progress for each data model.
98
99**Response Structure**:
100```json
101{
102 "next": "https://api.merge.dev/api/hris/v1/sync-status?cursor=abc123",
103 "previous": null,
104 "results": [
105 {
106 "model_name": "Employee",
107 "model_id": "hris.Employee",
108 "last_sync_start": "2024-01-15T10:30:00Z",
109 "next_sync_start": "2024-01-15T22:30:00Z",
110 "status": "SYNCING",
111 "is_initial_sync": true,
112 "last_sync_result": null,
113 "last_sync_finished": null
114 },
115 {
116 "model_name": "Company",
117 "model_id": "hris.Company",
118 "last_sync_start": "2024-01-15T10:28:00Z",
119 "next_sync_start": "2024-01-15T22:28:00Z",
120 "status": "DONE",
121 "is_initial_sync": false,
122 "last_sync_result": "SUCCESSFUL",
123 "last_sync_finished": "2024-01-15T10:29:15Z"
124 },
125 {
126 "model_name": "TimeOff",
127 "model_id": "hris.TimeOff",
128 "last_sync_start": null,
129 "next_sync_start": null,
130 "status": "DISABLED",
131 "is_initial_sync": false,
132 "last_sync_result": null,
133 "last_sync_finished": null
134 }
135 ]
136}
137```
138
139**Status Values**:
140- `SYNCING`: Sync currently in progress
141- `DONE`: Sync completed successfully
142- `FAILED`: Sync encountered errors
143- `DISABLED`: Model not enabled for this integration
144- `PARTIALLY_SYNCED`: Partial data retrieved
145- `PAUSED`: Sync temporarily paused
146
147**Key Fields**:
148- `model_id`: Fully qualified model identifier (e.g., "hris.Employee")
149- `is_initial_sync`: `true` indicates first sync still running
150- `last_sync_start`: Timestamp when sync began (use for `modified_after`)
151- `last_sync_finished`: Timestamp when sync completed
152- `next_sync_start`: Scheduled time for next automatic sync
153
154**Pagination**: Results are paginated. Use `next` URL to retrieve additional models.
155
156### Detecting Data Readiness
157
158Determining when a model's data is ready to fetch requires different logic for initial syncs versus subsequent syncs. This distinction is critical for data integrity.
159
160#### Initial Sync Readiness
161
162For the **first sync** after a user connects their integration, you must wait for complete data before fetching.
163
164**Initial Sync is Ready When:**
165- `status == "DONE"` **OR** `is_initial_sync == false`
166
167**Why This Logic**:
168- **DONE required**: Initial sync must fully complete to ensure complete historical dataset
169- **PARTIALLY_SYNCED not acceptable**: Partial data during initial sync represents incomplete dataset
170- **is_initial_sync == false**: If you check after initial sync already completed, this flag signals readiness
171
172**Initial Sync Pattern**:
173```
174for each model in sync_status.results:
175 if model.status == "DISABLED":
176 continue
177
178 # Initial sync readiness check
179 initial_sync_ready = (
180 model.status == "DONE"
181 OR
182 model.is_initial_sync == false
183 )
184
185 if initial_sync_ready:
186 # Safe to fetch complete historical data
187 fetch_initial_data(model.model_id)
188```
189
190#### Subsequent Sync Readiness
191
192After the initial sync completes, subsequent syncs can accept partial data since you already have the historical baseline.
193
194**Subsequent Sync is Ready When:**
195- `status == "DONE"` **OR** `status == "PARTIALLY_SYNCED"` **OR** `is_initial_sync == false`
196
197**Why This Logic**:
198- **PARTIALLY_SYNCED acceptable**: You already have baseline data, partial updates are safe
199- **More frequent updates**: Allows fetching incremental changes even if sync still in progress
200- **is_initial_sync == false**: Indicates initial sync completed previously
201
202**Subsequent Sync Pattern**:
203```
204for each model in sync_status.results:
205 if model.status == "DISABLED":
206 continue
207
208 # Check if initial sync ever completed
209 if model.is_initial_sync == false:
210 # Subsequent sync - can accept partial data
211 subsequent_sync_ready = (
212 model.status in ["DONE", "PARTIALLY_SYNCED"]
213 OR
214 model.is_initial_sync == false
215 )
216
217 if subsequent_sync_ready:
218 # Fetch incremental updates
219 fetch_incremental_data(model.model_id, last_sync_timestamp)
220```
221
222#### Combined Implementation Pattern
223
224```
225for each model in sync_status.results:
226 if model.status == "DISABLED":
227 continue
228
229 # Determine if this is initial or subsequent sync
230 is_first_sync = model.is_initial_sync == true
231
232 if is_first_sync:
233 # Initial sync - require DONE status
234 model_ready = (model.status == "DONE")
235 else:
236 # Subsequent sync - accept DONE or PARTIALLY_SYNCED
237 model_ready = (
238 model.status in ["DONE", "PARTIALLY_SYNCED"]
239 )
240
241 if model_ready:
242 fetch_data_for_model(model.model_id)
243```
244
245**Common Mistakes**:
246- ❌ Accepting `PARTIALLY_SYNCED` during initial sync leads to incomplete historical data
247- ❌ Only checking `status == "DONE"` will miss models ready from previous syncs
248- ❌ Not distinguishing between initial and subsequent sync logic
249
250✅ Use stricter logic for initial sync, more permissive logic for subsequent syncs
251
252**Edge Cases**:
253- **DISABLED models**: Always skip - integration doesn't support this model
254- **FAILED status with is_initial_sync=false**: Data from previous successful sync may still be available
255- **SYNCING status with is_initial_sync=false**: Previous sync data is available, new sync in progress for updates
256
257### Trigger Detection Methods
258
259Your application needs a mechanism to detect when new data becomes available from Merge. There are two approaches: webhooks (real-time) and polling (periodic checks). Both provide the same `last_sync_finished` timestamp needed for incremental fetching.
260
261#### Webhooks (Real-Time)
262
263Merge sends HTTP POST requests to your application's webhook endpoint when syncs complete. Webhooks provide the `last_sync_finished` timestamp immediately, eliminating the need to poll `/sync-status`.
264
265##### Webhook Types
266
267Merge offers two webhook types for sync detection, each suited for different use cases:
268
269**1. Linked Account Synced Webhook** (`LinkedAccount.sync_completed`)
270
271Best for detecting initial sync completion and account-level sync events.
272
273**When to Use**:
274- Detecting initial sync completion (payload includes `is_initial_sync` flag)
275- Getting sync status for all models in one notification
276- Simpler setup - one webhook subscription per linked account
277
278**Sample Payload**:
279```json
280{
281 "hook": {
282 "id": "e8affe31-8ae0-4b37-8c50-d86303094dc4",
283 "event": "LinkedAccount.sync_completed",
284 "target": "https://yourapp.com/webhooks/merge"
285 },
286 "linked_account": {
287 "id": "4ac10f37-c656-4e9a-89a1-1b04f9e9a343",
288 "integration": "BambooHR",
289 "integration_slug": "bamboohr",
290 "category": "hris",
291 "end_user_origin_id": "org_123_hris",
292 "status": "COMPLETE"
293 },
294 "data": {
295 "is_initial_sync": true,
296 "integration_name": "BambooHR",
297 "integration_id": "bamboohr",
298 "sync_status": {
299 "hris.Employee": {
300 "last_sync_finished": "2024-01-15T10:29:15Z",
301 "last_sync_result": "DONE"
302 },
303 "hris.Company": {
304 "last_sync_finished": "2024-01-15T10:28:30Z",
305 "last_sync_result": "DONE"
306 },
307 "hris.TimeOff": {
308 "last_sync_finished": "2024-01-15T10:30:45Z",
309 "last_sync_result": "PARTIALLY_SYNCED"
310 }
311 }
312 }
313}
314```
315
316**Key Fields**:
317- `data.is_initial_sync`: Boolean indicating if this is the first sync
318- `data.sync_status`: Object with all models and their sync results
319- Each model includes `last_sync_finished` and `last_sync_result`
320
321**Use Case**: Process webhook, extract `last_sync_finished` for each model, trigger data fetching
322
323---
324
325**2. Common Model Synced Webhook** (`{common_model}.synced`)
326
327Best for granular, model-specific sync notifications during subsequent syncs.
328
329**When to Use**:
330- High-volume data scenarios ("best if you have a lot of data to keep in sync")
331- Want immediate notification when specific models complete syncing
332- Need fine-grained control over which models trigger fetching
333
334**Sample Payload**:
335```json
336{
337 "hook": {
338 "id": "e8affe31-8ae0-4b37-8c50-d86303094dc4",
339 "event": "Employee.synced",
340 "target": "https://yourapp.com/webhooks/merge"
341 },
342 "linked_account": {
343 "id": "a3602c03-aba7-4d9d-a349-dbc338504092",
344 "integration": "BambooHR",
345 "integration_slug": "bamboohr",
346 "category": "hris",
347 "end_user_origin_id": "org_123_hris",
348 "status": "COMPLETE"
349 },
350 "data": {
351 "integration_name": "BambooHR",
352 "integration_id": "bamboohr",
353 "synced_fields": ["first_name", "last_name", "work_email"],
354 "sync_status": {
355 "model_name": "Employee",
356 "model_id": "hris.Employee",
357 "last_sync_start": "2024-01-15T10:25:00Z",
358 "next_sync_start": "2024-01-15T22:25:00Z",
359 "status": "DONE",
360 "last_sync_result": "DONE",
361 "last_sync_finished": "2024-01-15T10:29:15Z",
362 "is_initial_sync": false
363 }
364 }
365}
366```
367
368**Key Fields**:
369- `data.sync_status.last_sync_finished`: Timestamp for incremental fetching
370- `data.sync_status.last_sync_result`: Sync outcome (DONE, PARTIALLY_SYNCED, FAILED)
371- `data.synced_fields`: List of fields that were updated (useful for optimization)
372
373**Use Case**: Subscribe to `Employee.synced`, `Company.synced`, etc. individually, trigger fetching for only that model
374
375---
376
377##### Webhook Implementation Strategy
378
379**For Initial Sync Detection**:
380- Use `LinkedAccount.sync_completed` webhook
381- Check `data.is_initial_sync == true`
382- Process all models in `data.sync_status`
383- Extract `last_sync_finished` for each model
384- Trigger data fetching for models with `last_sync_result == "DONE"`
385
386**For Subsequent Sync Detection**:
387- **Option A**: Continue using `LinkedAccount.sync_completed` for simplicity
388- **Option B**: Use `{CommonModel}.synced` webhooks for granular, per-model notifications
389- Compare webhook's `last_sync_finished` with your stored value
390- If newer, trigger incremental data fetch
391
392**Webhook Processing Pattern**:
393```
3941. Receive webhook with sync_status data
3952. Extract last_sync_finished timestamp
3963. Compare with your stored last_sync_finished
3974. If newer:
398 - Record your last_synced_at (current time)
399 - Fetch: GET /model?modified_after={your_last_synced_at}&modified_before={webhook_last_sync_finished}
400 - Store both timestamps
401```
402
403**Advantages**:
404- Real-time updates (typically within seconds)
405- No unnecessary API calls to `/sync-status`
406- Efficient for frequently changing data
407- Webhook provides same `last_sync_finished` data that polling would detect
408
409**Considerations**:
410- Requires publicly accessible HTTPS endpoint
411- Must implement webhook signature verification for security
412- Webhooks can occasionally fail (network issues, downtime)
413- Should not be sole sync mechanism (use polling as backup)
414
415**Webhook Timeout and Retry Behavior**:
416- **Process asynchronously**: Webhook processing must be asynchronous to ensure prompt responses
417- **30-second timeout**: If your endpoint doesn't respond within 30 seconds, Merge considers the delivery failed
418- **Automatic retries**: Merge will retry failed webhooks up to 2 additional times (3 total attempts)
419- **Best practice**: Return 200 OK immediately upon receipt, then process webhook payload asynchronously in background job
420
421#### Polling (Periodic Checks)
422
423Your application periodically calls the `/sync-status` endpoint to check if new syncs have completed.
424
425**How It Works**:
4261. Set up scheduled job (cron, task queue, etc.)
4272. Periodically call `/sync-status` (frequency depends on sync cadence)
4283. Compare current `last_sync_finished` with stored value
4294. If newer, trigger data fetching
430
431**Advantages**:
432- Simple to implement
433- No infrastructure requirements (no public endpoint needed)
434- Reliable and predictable
435- Works even if webhooks fail
436
437**Considerations**:
438- Delayed updates (depends on polling frequency)
439- Regular API calls consume rate limits
440- Trade-off between freshness and API usage
441
442**Polling provides same data as webhooks**: The `/sync-status` response includes the same `last_sync_finished` timestamps that webhooks deliver. The only difference is timing - webhooks push immediately, polling pulls periodically.
443
444#### Hybrid Approach (Recommended)
445
446Use webhooks as primary mechanism with polling as backup:
447- **Webhooks**: Handle most updates in real-time via `LinkedAccount.sync_completed` or `{CommonModel}.synced`
448- **Polling**: Run periodically (e.g., every 1-6 hours) to catch missed webhooks
449- **Result**: Guaranteed data freshness with real-time benefits
450
451**Implementation**:
452- Subscribe to webhooks for real-time notifications
453- Implement polling job as safety net
454- Both use same timestamp comparison logic
455- Deduplication happens naturally (compare `last_sync_finished` before fetching)
456
457### Incremental Syncing with Timestamp Parameters
458
459After the initial sync, use `modified_after` and `modified_before` query parameters to fetch only records that changed within specific time windows.
460
461**Pattern**:
462```
463GET /api/{category}/v1/{model}?modified_after=2024-01-15T10:30:00Z&modified_before=2024-01-15T22:46:41Z
464```
465
466**Timestamp Strategy**:
467
468You need to track TWO timestamps per model:
469
4701. **`last_synced_at`** (Your timestamp): When YOUR backend started fetching data from Merge
4712. **`last_sync_finished`** (Merge's timestamp): When Merge completed syncing from the third-party system
472
473**How It Works**:
474
475**Detecting New Data**:
4761. Poll `/sync-status` to get Merge's current `last_sync_finished` timestamp
4772. Compare with your stored `last_sync_finished` from previous fetch
4783. If newer → new data available from Merge
479
480**Fetching New Data**:
4811. Record current time as `last_synced_at` (when you start fetching)
4822. Fetch data: `GET /employees?modified_after={your_last_synced_at}&modified_before={merge_last_sync_finished}`
4833. Store both timestamps for next fetch:
484 - Store your `last_synced_at` for next `modified_after` parameter
485 - Store Merge's `last_sync_finished` for detecting future syncs
486
487**Why This Pattern**:
488- `modified_after` = YOUR last fetch start time (prevents duplicate fetches)
489- `modified_before` = Merge's sync finish time (creates bounded window)
490- Creates precise time window capturing only new data since your last fetch
491- Prevents missing records modified during sync process
492
493**Example Flow**:
494
495```
496# Initial state: No previous fetch
497Poll /sync-status → last_sync_finished = 2024-01-15T10:30:00Z
498Detect: New data available (first fetch)
499
500# Start fetching
501last_synced_at = current_time() = 2024-01-15T10:35:00Z
502Fetch: GET /employees?modified_before=2024-01-15T10:30:00Z
503Store: last_synced_at = 2024-01-15T10:35:00Z, last_sync_finished = 2024-01-15T10:30:00Z
504
505# Next poll
506Poll /sync-status → last_sync_finished = 2024-01-15T22:46:41Z
507Detect: New data (timestamp changed from 10:30 to 22:46)
508
509# Fetch incremental update
510last_synced_at = current_time() = 2024-01-15T22:50:00Z
511Fetch: GET /employees?modified_after=2024-01-15T10:35:00Z&modified_before=2024-01-15T22:46:41Z
512Store: last_synced_at = 2024-01-15T22:50:00Z, last_sync_finished = 2024-01-15T22:46:41Z
513```
514
515**Benefits**:
516- Reduced API response sizes (only fetch changes)
517- Faster data processing
518- Lower bandwidth usage
519- Prevents duplicate data fetches
520- More efficient rate limit usage
521- Precise time windows for data consistency
522
523**Applies To**:
524All standard models (Employee, Company, TimeOff, Team, Location, etc.)
525
526### On-Demand Resyncs
527
528Beyond automatic syncing, Merge allows manual sync triggers when you need immediate data refresh.
529
530**Use Cases**:
531- User requests data refresh
532- Debugging data inconsistencies
533- Initial data population for new features
534- Recovery after extended downtime
535
536**API Endpoint**:
537```
538POST /api/{category}/v1/sync-status/resync
539```
540
541**Sync Modes**:
542
543**Full Resync**:
544```json
545{
546 "sync_type": "FULL"
547}
548```
549- Fetches complete dataset (like initial sync)
550- Resets sync state for all models
551- Use sparingly (resource intensive)
552
553**Incremental Resync**:
554```json
555{
556 "target": "hris.Employee",
557 "sync_type": "INCREMENTAL"
558}
559```
560- Fetches only changes since last sync
561- Targets specific model
562- Lighter weight, faster completion
563
564**Granular Control**:
565- Specify individual models to resync
566- Avoid unnecessary full syncs
567- Balance freshness needs with API efficiency
568
569**Response**:
570Returns sync status showing updated sync states for affected models.
571
572### Sync Timing Considerations
573
574#### Automatic Sync Frequency
575
576Merge's sync frequency varies significantly based on the customer's plan tier, specific integration, and category.
577
578**Frequency by Plan Tier**:
579- **Default/Standard Plan**: ~24 hours between syncs
580- **Higher-Tier Plans**: As frequent as every 5 minutes (varies by integration and category)
581- Frequency is determined by Merge and cannot be manually configured
582- Check `next_sync_start` field in `/sync-status` to see when next sync is scheduled
583
584**Why This Matters for Implementation**:
585
586The sync frequency directly impacts your polling and data fetching strategy:
587
588**For 24-Hour Sync Frequency (Standard Plan)**:
589- Polling every 10 minutes is inefficient - wastes API rate limits
590- Consider polling once per hour or relying primarily on webhooks
591- `PARTIALLY_SYNCED` status less common since syncs are infrequent
592- Data freshness naturally limited to 24-hour windows
593
594**For High-Frequency Syncs (5-15 minutes)**:
595- More frequent polling (every 5-10 minutes) becomes valuable
596- Accepting `PARTIALLY_SYNCED` status provides near-real-time updates
597- Higher data freshness expectations from users
598- Webhooks become more critical for real-time responsiveness
599
600**Dynamic Polling Strategy**:
601
602Consider adapting your polling interval based on the integration's sync frequency:
603
604```
605if next_sync_start - last_sync_start < 1 hour:
606 # High-frequency integration
607 poll_interval = 5-10 minutes
608 accept_partial_synced = true
609else:
610 # Standard frequency integration
611 poll_interval = 30-60 minutes
612 accept_partial_synced = false (unless initial sync complete)
613```
614
615**Key Takeaway**: Use the `next_sync_start` field to understand each integration's sync cadence and adjust your polling strategy accordingly. One-size-fits-all polling may be inefficient for 24-hour syncs or too slow for 5-minute syncs.
616
617#### Rate Limits
618
619- Respect Merge's rate limits when polling or fetching data
620- Webhook processing happens outside your rate limit
621- On-demand resyncs count toward limits
622- Frequent polling for high-frequency syncs consumes more rate limit quota
623
624#### Data Freshness Trade-offs
625
626- **Real-time**: Webhooks (seconds to minutes) - best for high-frequency syncs
627- **Near real-time**: Frequent polling (5-15 minutes) - matches high-frequency sync cadence
628- **Hourly**: Moderate polling (30-60 minutes) - appropriate for standard 24-hour syncs
629- **Batch**: Daily polling (24-hour lag) - minimum viable for standard plan integrations
630- Choose based on integration's sync frequency, business requirements, and API budget
631
632## Common Gotchas
633
634### MergeLink Initialization
635- `MergeLink.initialize()` prepares the modal but doesn't show it
636- Must explicitly call `MergeLink.openLink()` to display modal
637- This is the most common integration mistake
638
639### Relinking Process
640- Same end_user_origin_id and account_token preserved
641- Only refreshes credentials/permissions on Merge's side
642- No database changes needed on your side during relinking
643
644### Delete Account API
645- Uses POST method, not DELETE: `POST /api/{category}/v1/delete-account`
646- Requires both Authorization header and X-Account-Token header
647- Should clean up both Merge account and local database records
648
649## End User Origin ID Strategy
650
651The `end_user_origin_id` format is a foundational architectural decision that affects your entire integration system. Choose your strategy early - it impacts database design, UX patterns, and migration complexity.
652
653### Strategy 1: One Integration per Category
654**Best for: Standard applications with straightforward integration needs**
655
656**Format**: `{organization_id}_{category}`
657- Example: `"org_123_hris"`, `"org_123_ats"`, `"org_123_crm"`
658
659**What it means**:
660- Each organization can connect one HR system, one ATS, one CRM, etc.
661- One integration per category
662- If they want to switch from BambooHR to Workday, they replace the existing integration
663
664**Database Pattern:**
665```sql
666-- Unique constraint per category
667UNIQUE(organization_id, category)
668```
669
670**Pros**:
671- Simple to implement and maintain
672- Clear, predictable structure
673- Easy to understand and debug
674
675**Cons**:
676- Cannot connect multiple systems in the same category
677- Less flexible for complex organizational needs
678
679---
680
681### Strategy 2: Multiple Integrations per Category
682**Best for: Applications requiring flexibility and data aggregation**
683
684**Format**: `{organization_id}_{category}_{unique_id}`
685- Example: `"org_123_hris_550e8400-e29b-41d4-a716-446655440000"`
686
687**What it means**:
688- Organizations can connect unlimited integrations per category
689- Multiple HR systems, multiple ATS platforms, etc. can coexist
690- Each connection gets a unique identifier
691- Good for companies with multiple subsidiaries, regions, or environments
692
693**Database Pattern:**
694```sql
695-- No constraint on integration_slug - multiple connections allowed per category
696-- Only constraint is on end_user_origin_id uniqueness
697UNIQUE(end_user_origin_id)
698```
699
700**ID Generation**:
701```python
702import uuid
703
704def generate_end_user_origin_id(organization_id, category):
705 """Strategy 2: Multiple integrations per category"""
706 unique_id = str(uuid.uuid4()) # Full UUID for guaranteed uniqueness
707 return f"{organization_id}_{category}_{unique_id}"
708```
709
710**Key Implementation Details**:
711- Integration details (name, slug) are populated **after** the user completes linking
712- User selects the integration through Merge's interface (not predetermined)
713- Optional: Add `display_name` field for user-facing organization (e.g., "Production", "US Region")
714- **⚠️ CRITICAL**: Must implement incomplete attempt handling (see merge_backend_implementation.md)
715 - Save `end_user_origin_id` BEFORE user opens Merge Link
716 - Reuse same ID for retry attempts to avoid duplicate incomplete accounts in Merge dashboard
717
718**Pros**:
719- Unlimited flexibility - connect as many integrations as needed
720- Works with simple "Connect" button (no custom UI required)
721- Supports complex organizational structures
722- More secure (doesn't expose predictable patterns)
723- Guaranteed uniqueness (UUID collision is virtually impossible)
724
725**Cons**:
726- Slightly more complex implementation
727- Need to handle potential data conflicts across multiple sources
728- Longer ID strings (but this doesn't impact functionality)
729
730---
731
732## Decision Guide
733
734**Choose Strategy 1 if**:
735- Users will only need one integration per category
736- You want the simplest possible implementation
737- Your application has standardized, single-platform workflows
738- Target market: Small to medium businesses with straightforward needs
739
740**Choose Strategy 2 if**:
741- Users might need multiple integrations per category
742- You want flexibility without complex UI requirements
743- You need to support subsidiaries, regions, or multiple environments
744- You might not know all use cases upfront
745- Target market: Growing companies, mid-market, or enterprise customers
746
747---
748
749## Migration Warning
750
751⚠️ **Choose carefully** - migrating from Strategy 1 to Strategy 2 later is complex because:
752- The `end_user_origin_id` is permanent in Merge's system and cannot be changed
753- Existing connections would need to be deleted and recreated with new ID format
754- This causes disruption for users and potential data loss
755
756**Recommendation**: If unsure, **choose Strategy 2 from the start**. The additional complexity is minimal, and it provides flexibility for future growth without requiring migration.
📄 merge_frontend_implementation.md
1# Merge Frontend Implementation Guide
2
3## Implementation Styles
4
5### 1. Connect Integration Button (Standard)
6A single button that directly opens the Merge Link modal for connecting integrations. Best for applications with focused integration needs.
7
8**User Experience:**
9- Click "Connect HRIS Integration" → Modal opens → User authenticates → Integration connected
10- Simple, direct flow with minimal cognitive overhead
11
12### 2. App Center/Integration Marketplace (Advanced)
13A comprehensive integration marketplace showing available providers with search, filtering, and categorization. Best for platforms offering many integration options across multiple categories (HRIS, ATS, CRM, Accounting, Ticketing, FileStorage, Knowledge Base).
14
15**User Experience:**
16- Browse integration catalog → Select provider → Modal opens → User authenticates → Integration connected
17- Discovery-focused experience for users exploring integration options
18
19**Key Merge Concepts:**
20- **Single Integration Mode**: Skip Merge's marketplace by targeting specific integrations
21- **Same Authentication Flow**: Uses identical 3-step token process as Connect Button
22- **Multi-Integration State Management**: Handle connection states across multiple integration options
23
24## Connect Integration Button Implementation
25
26### Core User Experience Principles
27
28#### Invisible Integration
29- **Zero Merge terminology** exposed anywhere in UI
30- **Business-focused messaging**: "Connect HR System" not "Connect via Merge"
31- **Seamless experience** that feels native to your application
32- **No technical references** to tokens, APIs, or authentication flows
33
34#### Progressive Enhancement
35- **Simple entry point**: Single prominent button
36- **Clear value proposition**: "Connect your HR platform to automatically sync employee data"
37- **Contextual help**: Explain benefits without technical jargon
38
39### Frontend Architecture
40
41#### HTML Structure
42```html
43<!-- Integration connection button -->
44<button id="connectHRISBtn" class="btn btn-primary w-100 mb-3">
45 <i data-feather="link" class="me-1"></i>Connect HRIS Integration
46</button>
47
48<!-- Merge Link SDK -->
49<script src="https://cdn.merge.dev/initialize.js"></script>
50```
51
52#### JavaScript Implementation
53```javascript
54document.addEventListener('DOMContentLoaded', function() {
55 const connectBtn = document.getElementById('connectHRISBtn');
56 let mergeLinkInitialized = false;
57
58 // Reset button to normal state
59 function resetButton() {
60 connectBtn.disabled = false;
61 connectBtn.innerHTML = '<i data-feather="link" class="me-1"></i>Connect HRIS Integration';
62 feather.replace();
63 }
64
65 // Handle successful integration completion
66 function onSuccess(public_token) {
67 // Send public_token to backend for exchange
68 fetch('/api/merge/exchange-public-token', {
69 method: 'POST',
70 headers: { 'Content-Type': 'application/json' },
71 body: JSON.stringify({ public_token: public_token })
72 })
73 .then(response => response.json())
74 .then(data => {
75 if (data.success) {
76 // Refresh page to show new integration
77 window.location.reload();
78 }
79 });
80 }
81
82 // Handle modal exit/cancellation
83 function onExit(error) {
84 resetButton();
85 }
86
87 // Handle integration errors
88 function onError(error) {
89 console.error('Integration error:', error);
90 alert('An error occurred during setup. Please try again.');
91 resetButton();
92 }
93
94 // Initialize Merge Link with fresh token
95 function initializeMergeLink(linkToken) {
96 MergeLink.initialize({
97 linkToken: linkToken,
98 onSuccess: onSuccess,
99 onExit: onExit,
100 onError: onError,
101 onReady: () => {
102 resetButton();
103 // CRITICAL: Must explicitly call openLink() to show modal
104 MergeLink.openLink();
105 },
106 shouldSendTokenOnSuccessfulLink: true,
107 });
108 }
109
110 // Button click handler - always generates fresh token
111 connectBtn.addEventListener('click', function() {
112 connectBtn.disabled = true;
113 connectBtn.innerHTML = '<i data-feather="loader" class="me-1"></i>Initializing...';
114 feather.replace();
115
116 // Generate fresh link token on every click
117 fetch('/api/merge/create-link-token', {
118 method: 'POST',
119 headers: { 'Content-Type': 'application/json' },
120 body: JSON.stringify({
121 category: 'hris' // Can be 'hris', 'ats', 'crm', 'accounting', 'ticketing', 'filestorage', 'knowledgebase'
122 })
123 })
124 .then(response => response.json())
125 .then(data => {
126 if (data.success) {
127 // Reset initialization state for fresh setup
128 mergeLinkInitialized = false;
129 initializeMergeLink(data.link_token);
130 } else {
131 alert('Failed to initialize: ' + data.error);
132 resetButton();
133 }
134 });
135 });
136});
137```
138
139### Integration Management
140
141#### Connected Integrations Display
142```html
143<!-- Show active integrations -->
144<div class="card mb-3">
145 <div class="card-body">
146 <div class="d-flex align-items-center">
147 <div class="bg-success rounded-circle me-3">
148 <i data-feather="users" class="text-white"></i>
149 </div>
150 <div>
151 <h6 class="mb-1">BambooHR</h6>
152 <small class="text-success">Connected</small>
153 <small class="text-muted">Automatic sync</small>
154 </div>
155 </div>
156 <div class="btn-group">
157 <button onclick="relinkIntegration(123)">Relink</button>
158 <button onclick="deleteIntegration(123, 'BambooHR')">Delete</button>
159 </div>
160 </div>
161</div>
162```
163
164#### Relinking Implementation
165```javascript
166function relinkIntegration(integrationId) {
167 // Generate fresh token for existing integration
168 fetch('/api/merge/relink-integration', {
169 method: 'POST',
170 headers: { 'Content-Type': 'application/json' },
171 body: JSON.stringify({ integration_id: integrationId })
172 })
173 .then(response => response.json())
174 .then(data => {
175 if (data.success) {
176 // Initialize modal with relink token
177 initializeMergeLinkForRelink(data.link_token);
178 }
179 });
180}
181```
182
183#### Deletion Implementation
184```javascript
185function deleteIntegration(integrationId, integrationName) {
186 if (confirm('This action is permanent. Do you want to proceed?')) {
187 fetch('/api/merge/delete-integration', {
188 method: 'POST',
189 headers: { 'Content-Type': 'application/json' },
190 body: JSON.stringify({ integration_id: integrationId })
191 })
192 .then(response => response.json())
193 .then(data => {
194 if (data.success) {
195 window.location.reload();
196 }
197 });
198 }
199}
200```
201
202## Key Implementation Patterns
203
204### Fresh Token Generation
205- **Always generate new link_token** on every button click
206- **Never cache or reuse** tokens between attempts
207- **Reset MergeLink state** for clean initialization each time
208- **Specify category** in token requests ('hris', 'ats', 'crm', 'accounting', 'ticketing', 'filestorage', 'knowledgebase')
209
210### Callback Management
211- **Handle all callbacks**: onSuccess, onExit, onError, onReady
212- **Reset button state** in exit/error scenarios
213- **Provide user feedback** for error conditions without technical details
214
215### State Management
216- **Track initialization state** to prevent conflicts
217- **Clean button states** on all completion scenarios
218- **Handle page refresh** to show updated integrations
219
220## Testing Checklist
221
222### Initial Connection
223- [ ] Button click generates fresh link token
224- [ ] Modal opens automatically when ready
225- [ ] User can complete authentication flow
226- [ ] Integration appears in connected list after success
227- [ ] Button resets properly on modal exit/error
228
229### Relinking
230- [ ] Relink button opens modal with same integration
231- [ ] Completed relink maintains same integration display
232- [ ] No duplicate records created in database
233- [ ] Same integration name and status preserved
234
235### Deletion
236- [ ] Delete button shows confirmation dialog
237- [ ] Integration removed from display after confirmation
238- [ ] Integration removed from Merge system
239- [ ] Database record properly cleaned up
240
241### Error Handling
242- [ ] Network errors show user-friendly messages
243- [ ] Button state resets on all error conditions
244- [ ] Failed integrations don't create incomplete records
245- [ ] Console logging for debugging without user exposure
246
247## App Center/Integration Marketplace Implementation
248
249### Single Integration Mode
250
251The key difference between a Connect Button and an App Center is **Single Integration Mode** - directing users to a specific integration rather than showing Merge's full marketplace.
252
253#### Core Concept
254When users select an integration from your marketplace (BambooHR, Workday, etc.), they should go directly to that provider's authentication screen, not Merge's integration selection page.
255
256#### Backend Implementation
257**Minimal Change Required**: Add optional `integration` parameter to existing link token creation:
258
259```javascript
260// Connect Button approach - shows Merge marketplace
261{
262 category: 'hris'
263}
264
265// App Center approach - targets specific integration
266{
267 category: 'hris',
268 integration: 'bamboohr' // NEW: Skip marketplace, go directly to BambooHR
269}
270```
271
272**Backend accepts both patterns** - same endpoint, same logic, just one additional optional parameter.
273
274#### Integration Slug Mapping
275Common HRIS integration slugs:
276- `bamboohr` → BambooHR
277- `workday` → Workday
278- `adp-workforce-now` → ADP Workforce Now
279- `hibob` → HiBob
280- `officient` → Officient
281
282**Discovery**: Use Merge's `/api/organizations/integrations` endpoint to get available integrations and their slugs.
283
284### Architecture Patterns
285
286#### Multi-Integration State Management
287**Challenge**: Managing connect button states across multiple integrations simultaneously.
288
289**Solution Patterns**:
290```javascript
291// Track current connection attempt
292let currentConnectButton = null;
293
294// Button state management
295function resetConnectButton(button) {
296 if (button) {
297 button.disabled = false;
298 button.innerHTML = 'Connect';
299 }
300}
301
302// Handle per-integration loading states
303function connectIntegration(category, integration) {
304 const button = findButtonForIntegration(integration);
305
306 // Show loading state
307 button.disabled = true;
308 button.innerHTML = 'Connecting...';
309
310 // Store for later reset
311 currentConnectButton = button;
312
313 // Same link token flow as Connect Button
314 generateLinkToken(category, integration);
315}
316```
317
318#### Category Organization
319**Implementation Agnostic**: However you organize integrations (tabs, sidebar, dropdown, etc.), the Merge concepts remain:
320
321- **Categories**: HRIS, ATS, CRM, Accounting, Ticketing, FileStorage, Knowledge Base
322- **Integration Selection**: User picks specific provider within category
323- **Same Token Flow**: Generate → Authenticate → Exchange (unchanged)
324
325### Callback Considerations
326
327**Identical to Connect Button**: All Merge Link callbacks work the same way.
328
329```javascript
330// Same onSuccess handler
331function onSuccess(public_token) {
332 // Exchange token via same backend endpoint
333 fetch('/api/merge/exchange-public-token', {
334 method: 'POST',
335 body: JSON.stringify({ public_token })
336 })
337 .then(response => response.json())
338 .then(data => {
339 if (data.success) {
340 // Refresh to show connected integration
341 window.location.reload();
342 }
343 });
344}
345
346// Reset the current button state on exit/error
347function onExit(error) {
348 resetConnectButton(currentConnectButton);
349}
350
351function onError(error) {
352 resetConnectButton(currentConnectButton);
353}
354```
355
356### Error Handling Patterns
357
358#### Network Failures
359**Graceful Degradation**: When integration metadata API fails, provide fallback options:
360
361```javascript
362// Primary: Dynamic integration list from Merge API
363fetchAvailableIntegrations()
364 .then(integrations => renderIntegrations(integrations))
365 .catch(error => {
366 // Fallback: Static integration list
367 renderIntegrations(fallbackIntegrations);
368 });
369```
370
371#### Button State Recovery
372**Critical**: Always reset button states on modal close, error, or success to prevent stuck loading states.
373
374### Integration Discovery
375
376#### Available Integrations API
377```javascript
378// Fetch integrations user can connect to
379fetch('/api/merge/integrations') // Your backend proxies Merge API
380 .then(response => response.json())
381 .then(data => {
382 const hrisIntegrations = data.integrations.filter(
383 integration => integration.categories.includes('hris')
384 );
385 renderHRISIntegrations(hrisIntegrations);
386 });
387```
388
389#### Integration Metadata
390Each integration includes:
391- `name`: Display name (e.g., "BambooHR")
392- `slug`: API identifier (e.g., "bamboohr")
393- `categories`: Supported categories (e.g., ["hris"])
394- `image`/`square_image`: Logos for UI
395- `color`: Brand color for theming
396
397### Testing Approach
398
399#### Single Integration Flow
400- [ ] Correct integration slug passed to link token creation
401- [ ] Modal opens directly to selected provider (not Merge marketplace)
402- [ ] Successful authentication creates correct database record
403- [ ] Integration appears in connected state after completion
404
405#### Multi-Integration Management
406- [ ] Multiple connect buttons work independently
407- [ ] Button states reset correctly on modal close/error
408- [ ] No interference between different integration attempts
409- [ ] State management handles concurrent connection attempts gracefully
410
411#### Category Switching
412- [ ] Integration filtering works correctly by category
413- [ ] Category changes don't break connection flows
414- [ ] State resets appropriately when switching categories
415
416## Key Implementation Differences
417
418### From Merge Perspective: Minimal
419- **Same authentication flow** (3 steps unchanged)
420- **Same database patterns** (End User Origin ID strategy unchanged)
421- **Same backend endpoints** (existing endpoints work with one parameter addition)
422- **Same error handling** (onSuccess/onExit/onError patterns identical)
423
424### From User Experience Perspective: Significant
425- **Discovery-focused**: Users browse and select integrations
426- **Category-organized**: Logical grouping of integration types
427- **Multi-option management**: Handle many integration states simultaneously
428- **Scalable architecture**: Easy to add new categories and integrations
429
430### Implementation Complexity: Frontend Heavy
431**Backend Changes**: Minimal (accept `integration` parameter)
432**Frontend Changes**: Significant (marketplace UI, state management, category organization)
433**Merge Integration**: Identical patterns, just parameter variation
📄 merge_backend_implementation.md
1# Merge Backend Implementation Guide
2
3## API Endpoint Architecture
4
5### Core Backend Flow
6Backend handles the server-side portion of Merge's 3-step authentication flow and ongoing API operations.
7
8**Flow Overview:**
91. **Link Token Creation**: Generate single-use tokens for modal initialization
102. **Public Token Exchange**: Convert frontend tokens to permanent account tokens
113. **Account Management**: Handle integration lifecycle and data operations
12
13### Database Schema
14
15#### MergeLinkedAccount Model
16```python
17class MergeLinkedAccount(db.Model):
18 id = db.Column(db.Integer, primary_key=True)
19 user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
20 end_user_origin_id = db.Column(db.String(100), nullable=False, unique=True)
21 category = db.Column(db.String(50), nullable=False) # 'hris', 'ats', 'crm', etc.
22 integration_name = db.Column(db.String(100)) # 'BambooHR', 'Workday', etc.
23 account_token = db.Column(db.String(500)) # Permanent API token
24 status = db.Column(db.String(20), default='pending') # 'pending', 'active', 'error'
25 is_active = db.Column(db.Boolean, default=True)
26 last_sync = db.Column(db.DateTime)
27
28 # Sync tracking columns (critical for initial sync detection)
29 last_sync_time = db.Column(db.DateTime) # For incremental sync with modified_after
30 sync_status = db.Column(db.String(20)) # SYNCING, DONE, FAILED, etc. (from Merge API)
31 initial_sync_completed = db.Column(db.Boolean, default=False) # Flag for first sync completion
32 created_at = db.Column(db.DateTime, default=datetime.utcnow)
33
34 # Relationships
35 user = db.relationship('User', backref='merge_linked_accounts')
36```
37
38**Critical Schema Decisions:**
39- **end_user_origin_id**: Must be unique and deterministic (format depends on chosen strategy)
40- **Immediate Creation**: Record created during link token generation, not completion
41- **Multi-Category Support**: Single organization can have integrations across different categories
42- **Strategy-Dependent**: Schema constraints vary by End User Origin ID strategy (see Architecture Patterns below)
43
44## API Endpoint Implementations
45
46### 1. Link Token Creation
47
48#### Endpoint: `POST /api/merge/create-link-token`
49```python
50@app.route('/api/merge/create-link-token', methods=['POST'])
51@login_required
52def create_link_token():
53 try:
54 data = request.get_json()
55 category = data.get('category', 'hris') # hris, ats, crm, accounting, ticketing, filestorage, knowledgebase
56 integration = data.get('integration') # Optional: for single integration mode (e.g., 'bamboohr', 'workday')
57
58 # Generate end_user_origin_id based on chosen strategy
59 # Strategy 1 (One per category): f"{organization_id}_{category}"
60 # Strategy 2 (Multiple per category): f"{organization_id}_{category}_{integration_slug}"
61 # Strategy 3 (Multiple instances): f"{organization_id}_{integration_slug}_{instance_id}"
62 end_user_origin_id = generate_end_user_origin_id(current_user.id, category, data)
63
64 # Check for existing integration in this category
65 existing = MergeLinkedAccount.query.filter_by(
66 end_user_origin_id=end_user_origin_id,
67 is_active=True
68 ).first()
69
70 if existing and existing.status == 'active':
71 return jsonify({
72 'success': False,
73 'error': f'Active {category} integration already exists'
74 }), 400
75
76 # Create or update database record IMMEDIATELY to prevent Merge conflicts
77 if not existing:
78 merge_account = MergeLinkedAccount(
79 user_id=current_user.id,
80 end_user_origin_id=end_user_origin_id,
81 category=category,
82 status='pending'
83 )
84 db.session.add(merge_account)
85 else:
86 # Reset existing record for fresh attempt
87 existing.status = 'pending'
88 existing.account_token = None
89 existing.integration_name = None
90
91 db.session.commit()
92
93 # Generate link token from Merge API
94 link_token = generate_merge_link_token(end_user_origin_id, category, integration)
95
96 return jsonify({
97 'success': True,
98 'link_token': link_token
99 })
100
101 except Exception as e:
102 db.session.rollback()
103 return jsonify({
104 'success': False,
105 'error': str(e)
106 }), 500
107```
108
109#### Link Token Generation Function
110```python
111def generate_merge_link_token(end_user_origin_id, category, integration=None):
112 """Generate fresh link token for Merge Link modal"""
113 url = f"https://api.merge.dev/api/{category}/v1/link-token"
114
115 headers = {
116 'Authorization': f'Bearer {os.getenv("MERGE_API_KEY")}',
117 'Content-Type': 'application/json'
118 }
119
120 payload = {
121 'end_user_origin_id': end_user_origin_id,
122 'integration_category': category
123 }
124
125 # Add integration parameter for single integration mode (App Center)
126 if integration:
127 payload['integration'] = integration
128
129 response = requests.post(url, headers=headers, json=payload)
130 response.raise_for_status()
131
132 return response.json()['link_token']
133```
134
135### 2. Public Token Exchange
136
137#### Endpoint: `POST /api/merge/exchange-public-token`
138```python
139@app.route('/api/merge/exchange-public-token', methods=['POST'])
140@login_required
141def exchange_public_token():
142 try:
143 data = request.get_json()
144 public_token = data.get('public_token')
145
146 if not public_token:
147 return jsonify({
148 'success': False,
149 'error': 'Missing public_token'
150 }), 400
151
152 # Exchange public token for account token
153 account_token = retrieve_account_token(public_token)
154
155 # Get integration details using the new account token
156 account_details = get_account_details(account_token)
157
158 # Find the pending integration record
159 end_user_origin_id = account_details.get('end_user_origin_id')
160 merge_account = MergeLinkedAccount.query.filter_by(
161 end_user_origin_id=end_user_origin_id,
162 user_id=current_user.id
163 ).first()
164
165 if not merge_account:
166 return jsonify({
167 'success': False,
168 'error': 'Integration record not found'
169 }), 404
170
171 # Update record with integration details
172 merge_account.account_token = account_token
173 merge_account.integration_name = account_details.get('integration') # "BambooHR", "Officient", etc.
174 merge_account.integration_slug = account_details.get('integration_slug') # "bamboohr", "officient", etc.
175 merge_account.status = 'active'
176 merge_account.last_sync = datetime.utcnow()
177
178 db.session.commit()
179
180 return jsonify({
181 'success': True,
182 'integration_name': merge_account.integration_name
183 })
184
185 except Exception as e:
186 db.session.rollback()
187 return jsonify({
188 'success': False,
189 'error': str(e)
190 }), 500
191```
192
193#### Token Exchange Functions
194```python
195def retrieve_account_token(public_token):
196 """Exchange public_token for permanent account_token"""
197 url = "https://api.merge.dev/api/mktg/v1/create-link-token"
198
199 headers = {
200 'Authorization': f'Bearer {os.getenv("MERGE_API_KEY")}',
201 'Content-Type': 'application/json'
202 }
203
204 payload = {'public_token': public_token}
205
206 response = requests.post(url, headers=headers, json=payload)
207 response.raise_for_status()
208
209 return response.json()['account_token']
210
211def get_account_details(account_token, category='hris'):
212 """Retrieve integration details using account token"""
213 url = f"https://api.merge.dev/api/{category}/v1/account-details"
214
215 headers = {
216 'Authorization': f'Bearer {os.getenv("MERGE_API_KEY")}',
217 'X-Account-Token': account_token
218 }
219
220 response = requests.get(url, headers=headers)
221 response.raise_for_status()
222
223 return response.json()
224```
225
226**Important: Account Details Response Structure**
227
228The `account-details` endpoint returns integration information at the **top level** of the response, not nested:
229
230```json
231{
232 "id": "746ef4c0-5dbf-4999-89dd-ebc623cf3c1e",
233 "integration": "Officient", // STRING (integration name)
234 "integration_slug": "officient", // STRING (integration identifier)
235 "category": "hris",
236 "end_user_origin_id": "67f452a4-493c-414a-9f09-0873c003701e_hris",
237 "end_user_organization_name": "Merge",
238 "end_user_email_address": "ashwin.prakash@merge.dev",
239 "status": "COMPLETE",
240 "webhook_listener_url": "https://api.merge.dev/api/integrations/webhook-listener/...",
241 "is_duplicate": null,
242 "account_type": "TEST",
243 "completed_at": "2025-10-22T22:45:17.910266Z"
244}
245```
246
247**Common Mistake**: Do NOT treat `integration` as a nested object:
248```python
249# ❌ WRONG - This will cause errors
250integration_name = account_details['integration']['name'] # ERROR: str has no attribute 'name'
251
252# ✅ CORRECT - Extract from top level
253integration_name = account_details.get('integration') # "Officient"
254integration_slug = account_details.get('integration_slug') # "officient"
255```
256
257### 3. Integration Relinking
258
259#### Endpoint: `POST /api/merge/relink-integration`
260```python
261@app.route('/api/merge/relink-integration', methods=['POST'])
262@login_required
263def relink_integration():
264 try:
265 data = request.get_json()
266 integration_id = data.get('integration_id')
267
268 # Find the existing integration
269 merge_account = MergeLinkedAccount.query.filter_by(
270 id=integration_id,
271 user_id=current_user.id
272 ).first()
273
274 if not merge_account:
275 return jsonify({
276 'success': False,
277 'error': 'Integration not found'
278 }), 404
279
280 # Generate relink token using existing end_user_origin_id
281 link_token = generate_merge_link_token(
282 merge_account.end_user_origin_id,
283 merge_account.category
284 )
285
286 return jsonify({
287 'success': True,
288 'link_token': link_token
289 })
290
291 except Exception as e:
292 return jsonify({
293 'success': False,
294 'error': str(e)
295 }), 500
296```
297
298### 4. Integration Deletion
299
300#### Endpoint: `POST /api/merge/delete-integration`
301```python
302@app.route('/api/merge/delete-integration', methods=['POST'])
303@login_required
304def delete_integration():
305 try:
306 data = request.get_json()
307 integration_id = data.get('integration_id')
308
309 # Find the integration to delete
310 merge_account = MergeLinkedAccount.query.filter_by(
311 id=integration_id,
312 user_id=current_user.id
313 ).first()
314
315 if not merge_account:
316 return jsonify({
317 'success': False,
318 'error': 'Integration not found'
319 }), 404
320
321 # Delete from Merge's system first
322 delete_account_from_merge(merge_account.account_token, merge_account.category)
323
324 # Remove from local database
325 db.session.delete(merge_account)
326 db.session.commit()
327
328 return jsonify({'success': True})
329
330 except Exception as e:
331 db.session.rollback()
332 return jsonify({
333 'success': False,
334 'error': str(e)
335 }), 500
336```
337
338#### Account Deletion Function
339```python
340def delete_account_from_merge(account_token, category):
341 """Delete account from Merge's system using POST method"""
342 url = f"https://api.merge.dev/api/{category}/v1/delete-account"
343
344 headers = {
345 'Authorization': f'Bearer {os.getenv("MERGE_API_KEY")}',
346 'X-Account-Token': account_token,
347 'Content-Type': 'application/json'
348 }
349
350 # POST method, not DELETE
351 response = requests.post(url, headers=headers)
352 response.raise_for_status()
353
354 return response.json()
355```
356
357## Data Syncing Implementation
358
359### Hybrid Sync Strategy
360Combine webhooks for real-time updates with polling for reliability.
361
362#### Webhook Endpoint
363```python
364@app.route('/api/merge/webhook/<category>', methods=['POST'])
365def handle_merge_webhook(category):
366 try:
367 # Verify webhook authenticity (implement signature verification)
368 payload = request.get_json()
369 account_token = request.headers.get('X-Account-Token')
370
371 # Find the associated integration
372 merge_account = MergeLinkedAccount.query.filter_by(
373 account_token=account_token,
374 category=category
375 ).first()
376
377 if not merge_account:
378 return jsonify({'error': 'Integration not found'}), 404
379
380 # Process webhook data
381 event_type = payload.get('event_type')
382 model_name = payload.get('model_name')
383
384 if event_type in ['CREATE', 'UPDATE', 'DELETE']:
385 sync_data_for_integration(merge_account, model_name, payload.get('data'))
386
387 # Update last sync timestamp
388 merge_account.last_sync = datetime.utcnow()
389 db.session.commit()
390
391 return jsonify({'status': 'processed'}), 200
392
393 except Exception as e:
394 return jsonify({'error': str(e)}), 500
395```
396
397#### Polling Backup Implementation
398```python
399def sync_all_active_integrations():
400 """Backup sync via polling - run every 24 hours"""
401 active_integrations = MergeLinkedAccount.query.filter_by(
402 status='active',
403 is_active=True
404 ).all()
405
406 for integration in active_integrations:
407 try:
408 sync_integration_data(integration)
409 integration.last_sync = datetime.utcnow()
410 db.session.commit()
411
412 except Exception as e:
413 print(f"Sync failed for integration {integration.id}: {str(e)}")
414 # Don't mark as failed - could be temporary network issue
415 continue
416
417def sync_integration_data(merge_account):
418 """Sync data for a specific integration"""
419 category = merge_account.category
420 account_token = merge_account.account_token
421
422 if category == 'hris':
423 # Sync employees, departments, etc.
424 sync_hris_employees(account_token)
425 sync_hris_departments(account_token)
426 elif category == 'ats':
427 # Sync candidates, jobs, etc.
428 sync_ats_candidates(account_token)
429 sync_ats_jobs(account_token)
430 elif category == 'crm':
431 # Sync contacts, accounts, etc.
432 sync_crm_contacts(account_token)
433 sync_crm_accounts(account_token)
434 elif category == 'accounting':
435 # Sync invoices, payments, etc.
436 sync_accounting_invoices(account_token)
437 sync_accounting_payments(account_token)
438 elif category == 'ticketing':
439 # Sync tickets, users, etc.
440 sync_ticketing_tickets(account_token)
441 sync_ticketing_users(account_token)
442 elif category == 'filestorage':
443 # Sync files, folders, permissions, etc.
444 sync_filestorage_files(account_token)
445 sync_filestorage_folders(account_token)
446 elif category == 'knowledgebase':
447 # Sync articles, spaces, pages, etc.
448 sync_knowledgebase_articles(account_token)
449 sync_knowledgebase_spaces(account_token)
450```
451
452### Initial Sync Detection and Management
453
454**CRITICAL CONCEPT**: After authentication completes, Merge begins an "initial sync" process to normalize data from the 3rd party system. Your application must detect when this process completes before attempting to retrieve normalized data.
455
456#### Why Initial Sync Detection Matters
457- **Data Completeness**: Merge APIs may return partial data during initial sync, but this represents incomplete datasets that shouldn't be used for business logic
458- **User Experience**: Users expect immediate access but need proper expectation setting about data completeness
459- **Reliable Architecture**: Polling sync status prevents retrieval of incomplete datasets during initial normalization
460
461#### Database Schema Requirements
462The sync tracking columns added to `MergeLinkedAccount` are essential:
463
464```python
465# Sync tracking columns (add to existing model)
466last_sync_time = db.Column(db.DateTime) # Timestamp when data retrieval STARTED (for modified_after param)
467sync_status = db.Column(db.String(20)) # SYNCING, DONE, FAILED, etc. (from Merge API)
468initial_sync_completed = db.Column(db.Boolean, default=False) # Flag for first sync completion
469```
470
471#### Sync Status Checking with MergePythonSDK
472
473```python
474from MergePythonSDK.shared.api_client import ApiClient
475from MergePythonSDK.shared.configuration import Configuration
476from MergePythonSDK.hris.api.sync_status_api import SyncStatusApi
477
478def check_sync_status(account_token, category, api_key):
479 """Check sync status for all models in a category using MergePythonSDK"""
480 try:
481 # Configure API client
482 configuration = Configuration()
483 configuration.api_key['Authorization'] = f'Bearer {api_key}'
484 configuration.api_key['X-Account-Token'] = account_token
485
486 api_client = ApiClient(configuration)
487
488 if category == 'hris':
489 sync_api = SyncStatusApi(api_client)
490 sync_statuses = sync_api.sync_status_list()
491 else:
492 logging.error(f"Category {category} not implemented yet")
493 return None
494
495 return sync_statuses
496 except Exception as e:
497 logging.error(f"Error checking sync status: {str(e)}")
498 return None
499
500def is_initial_sync_complete(sync_statuses):
501 """Check if initial sync is complete for all enabled models"""
502 if not sync_statuses or not sync_statuses.results:
503 return False
504
505 for model in sync_statuses.results:
506 if model.status == "DISABLED":
507 continue # Skip disabled models
508
509 # If any enabled model is still syncing, initial sync not complete
510 if model.status == "SYNCING":
511 return False
512
513 # If any enabled model failed, return False (could raise error instead)
514 if model.status in ["FAILED", "PAUSED"]:
515 return False
516
517 # All enabled models are DONE - initial sync complete!
518 return True
519
520def get_latest_sync_time(sync_statuses):
521 """Get the latest sync finished time from all enabled models"""
522 latest_time = None
523
524 if not sync_statuses or not sync_statuses.results:
525 return None
526
527 for model in sync_statuses.results:
528 if model.status == "DISABLED" or not model.last_sync_finished:
529 continue
530
531 from datetime import datetime
532 sync_time = datetime.fromisoformat(model.last_sync_finished.replace('Z', '+00:00'))
533
534 if latest_time is None or sync_time > latest_time:
535 latest_time = sync_time
536
537 return latest_time
538```
539
540#### Sync Status API Endpoint
541
542```python
543@app.route('/api/merge/check-sync-status', methods=['POST'])
544@login_required
545def check_integration_sync_status():
546 """Check and update sync status for user's integrations"""
547 try:
548 data = request.get_json()
549 integration_id = data.get('integration_id')
550
551 if not integration_id:
552 return jsonify({'success': False, 'error': 'Integration ID required'}), 400
553
554 # Get the integration
555 integration = MergeLinkedAccount.query.filter_by(
556 id=integration_id,
557 user_id=current_user.id,
558 is_active=True
559 ).first()
560
561 if not integration:
562 return jsonify({'success': False, 'error': 'Integration not found'}), 404
563
564 # Check current sync status via Merge API
565 sync_statuses = check_sync_status(
566 integration.account_token,
567 integration.category,
568 os.getenv('MERGE_API_KEY')
569 )
570
571 if not sync_statuses:
572 return jsonify({'success': False, 'error': 'Unable to check sync status'}), 500
573
574 # Update database with current status
575 is_complete = is_initial_sync_complete(sync_statuses)
576 latest_sync = get_latest_sync_time(sync_statuses)
577
578 # Determine overall sync status
579 if is_complete:
580 if not integration.initial_sync_completed:
581 integration.initial_sync_completed = True
582 integration.sync_status = 'DONE'
583 logging.info(f"Initial sync completed for integration {integration.id}")
584 integration.last_sync_time = latest_sync
585 else:
586 integration.sync_status = 'SYNCING'
587
588 db.session.commit()
589
590 return jsonify({
591 'success': True,
592 'sync_complete': is_complete,
593 'sync_status': integration.sync_status,
594 'last_sync_time': latest_sync.isoformat() if latest_sync else None
595 })
596
597 except Exception as e:
598 logging.error(f"Error checking sync status: {str(e)}")
599 return jsonify({'success': False, 'error': 'Internal server error'}), 500
600```
601
602#### Sample Sync Status API Response
603
604**Initial Sync (SYNCING):**
605```json
606{
607 "model": "hris.employees",
608 "model_name": "Employee",
609 "status": "SYNCING",
610 "is_initial_sync": true,
611 "last_sync_started": "2024-01-15T10:30:00Z",
612 "last_sync_finished": null
613}
614```
615
616**Initial Sync Complete (DONE):**
617```json
618{
619 "model": "hris.employees",
620 "model_name": "Employee",
621 "status": "DONE",
622 "is_initial_sync": false,
623 "last_sync_started": "2024-01-15T10:30:00Z",
624 "last_sync_finished": "2024-01-15T10:35:00Z"
625}
626```
627
628#### Key Implementation Insights
629
630**Critical Timing**: The `is_initial_sync` flag flips to `false` immediately upon first completion, not after a second sync.
631
632**When Data is Ready to Fetch**: Poll `/sync-status` until **EITHER** condition is true:
633- `status == "DONE"` OR `status == "PARTIALLY_SYNCED"`, **OR**
634- `is_initial_sync == false`
635
636**Why Use OR Logic**:
637- Using **OR** (not AND) ensures you catch data readiness regardless of timing
638- `status == "DONE"` catches the moment sync completes
639- `is_initial_sync == false` catches it if you poll after sync already completed
640- Both signals indicate data is ready, use whichever you detect first
641
642**Status Values**:
6431. **SYNCING**: Initial sync in progress - data may be partially available but incomplete
6442. **DONE**: Sync completed successfully - full dataset ready for retrieval
6453. **PARTIALLY_SYNCED**: Some data synced, enough for retrieval
6464. **FAILED/PAUSED**: Sync encountered issues - requires attention
6475. **DISABLED**: Model not enabled for this integration
648
649**User Experience Patterns**:
650- Set clear expectations: "Initial sync in progress, check back in a few minutes"
651- Provide manual refresh capability for checking sync status
652- Show different UI states based on `initial_sync_completed` flag
653
654#### Frontend Integration Example
655
656```javascript
657function checkSyncStatus(integrationId) {
658 fetch('/api/merge/check-sync-status', {
659 method: 'POST',
660 headers: { 'Content-Type': 'application/json' },
661 body: JSON.stringify({ integration_id: integrationId })
662 })
663 .then(response => response.json())
664 .then(data => {
665 if (data.success) {
666 if (data.sync_complete) {
667 // Update UI to show "Sync complete"
668 // Now safe to retrieve normalized data
669 showSyncComplete();
670 } else {
671 // Show "Initial sync in progress"
672 showSyncInProgress();
673 }
674 }
675 });
676}
677```
678
679**When to Trigger Data Retrieval**: Only call Merge's data endpoints (employees, companies, etc.) AFTER `is_initial_sync_complete()` returns `true`. While partial data may be available during the SYNCING state, this ensures you're retrieving the complete, fully normalized dataset rather than incomplete records.
680
681### Incremental Data Sync with modified_after
682
683After initial sync completes, use the `modified_after` parameter for efficient incremental updates.
684
685#### Key Pattern: Store Start Time, Not End Time
686
687**Critical**: Always store the timestamp when you START pulling data, not when you finish. This ensures no records are missed between sync operations.
688
689```python
690def sync_employees_incremental(account_token, last_sync_time=None):
691 """Sync employees using modified_after for incremental updates"""
692
693 # Record when we START this sync operation
694 sync_start_time = datetime.utcnow()
695
696 # Build API URL with modified_after parameter
697 url = f"{MERGE_API_BASE}/hris/v1/employees"
698 params = {}
699
700 if last_sync_time:
701 # Use ISO format for modified_after parameter
702 params['modified_after'] = last_sync_time.isoformat()
703
704 headers = {
705 'Authorization': f'Bearer {MERGE_API_KEY}',
706 'X-Account-Token': account_token
707 }
708
709 try:
710 response = requests.get(url, headers=headers, params=params)
711 response.raise_for_status()
712 data = response.json()
713
714 # Process the employee data
715 for employee_data in data.get('results', []):
716 process_employee_record(employee_data)
717
718 # IMPORTANT: Update last_sync_time with START time, not current time
719 integration = MergeLinkedAccount.query.filter_by(account_token=account_token).first()
720 integration.last_sync_time = sync_start_time
721 db.session.commit()
722
723 return True
724
725 except Exception as e:
726 logging.error(f"Error syncing employees: {str(e)}")
727 return False
728```
729
730#### Example API Usage Patterns
731
732**Initial Sync (no modified_after)**:
733```
734GET /hris/v1/employees
735```
736
737**Incremental Sync (with modified_after)**:
738```
739GET /hris/v1/employees?modified_after=2024-01-15T10:30:00Z
740```
741
742**Multi-Model Incremental Sync**:
743```python
744def sync_all_hris_data_incremental(integration):
745 """Sync all HRIS models incrementally"""
746 last_sync = integration.last_sync_time
747
748 # Sync all models with same timestamp
749 sync_employees_incremental(integration.account_token, last_sync)
750 sync_departments_incremental(integration.account_token, last_sync)
751 sync_companies_incremental(integration.account_token, last_sync)
752
753 # All models use same start timestamp for consistency
754```
755
756#### Why Start Time vs End Time Matters
757
758**Problem with End Time**: If you store when sync finishes, records modified during the sync operation could be missed in the next sync.
759
760**Solution with Start Time**: Using the start timestamp ensures complete coverage with potential overlap (which is safer than gaps).
761
762```
763Sync 1: Start 10:00, End 10:05, Store: 10:00
764Sync 2: modified_after=10:00, Start 10:15, End 10:18, Store: 10:15
765```
766
767Records modified between 10:00-10:05 are captured in both syncs (safe overlap) rather than potentially missed.
768
769#### Ongoing Sync Status Monitoring
770
771**Continue polling `/sync-status`** even after initial sync completes to detect when new data is available.
772
773```python
774def should_sync_data(integration):
775 """Determine if data sync is needed based on current sync status"""
776
777 # Always check current sync status first
778 sync_statuses = check_sync_status(
779 integration.account_token,
780 integration.category,
781 os.getenv('MERGE_API_KEY')
782 )
783
784 if not sync_statuses:
785 return False, "Unable to check sync status"
786
787 # Check if any models have data available
788 has_data_available = False
789 for model in sync_statuses.results:
790 if model.status in ["DONE", "PARTIALLY_SYNCED"]:
791 has_data_available = True
792 break
793
794 return has_data_available, "Data available for sync"
795
796def sync_data_if_needed(integration):
797 """Smart sync that only pulls data when needed"""
798
799 should_sync, reason = should_sync_data(integration)
800
801 if not should_sync:
802 logging.info(f"Skipping sync for integration {integration.id}: {reason}")
803 return
804
805 # Get last sync time for modified_after parameter
806 last_sync = integration.last_sync_time
807
808 if not last_sync:
809 # First time syncing - no modified_after needed
810 logging.info(f"Performing initial data sync for integration {integration.id}")
811 sync_employees_incremental(integration.account_token)
812 else:
813 # Delta sync with modified_after
814 logging.info(f"Performing incremental sync since {last_sync}")
815 sync_employees_incremental(integration.account_token, last_sync)
816```
817
818#### Efficiency Benefits of modified_after
819
820**Without modified_after** (inefficient):
821```
822GET /hris/v1/employees
823→ Returns all 500 employees every time
824→ Wastes bandwidth and processing
825→ No indication of what actually changed
826```
827
828**With modified_after** (efficient):
829```
830GET /hris/v1/employees?modified_after=2024-01-15T10:30:00Z
831→ Returns only 3 employees that changed since last sync
832→ Minimal bandwidth and processing
833→ Clear delta of actual changes
834```
835
836#### Sync Status Response Handling
837
838**Key Status Values for Ongoing Syncs**:
839- **`DONE`**: Sync completed, data ready for retrieval
840- **`PARTIALLY_SYNCED`**: Some data available, safe to retrieve current delta
841- **`SYNCING`**: New sync in progress, may want to wait or proceed based on business needs
842
843**When No New Data Available**:
844If Merge hasn't started a new sync since your last data pull, the `modified_after` query will return empty results - this is expected and efficient behavior.
845
846```python
847def handle_incremental_sync_response(response_data, integration):
848 """Handle response from incremental sync API call"""
849
850 results = response_data.get('results', [])
851
852 if not results:
853 logging.info(f"No new data since {integration.last_sync_time} - sync up to date")
854 return True # Success, just no changes
855
856 logging.info(f"Processing {len(results)} changed records since last sync")
857
858 # Process only the delta records
859 for record in results:
860 process_record(record)
861
862 return True
863```
864
865This pattern ensures you're only processing actual changes while maintaining real-time awareness of when new data becomes available through continuous sync-status monitoring.
866
867## Error Handling Patterns
868
869### Retry Logic with Exponential Backoff
870```python
871import time
872import random
873
874def make_merge_request_with_retry(url, headers, payload=None, max_retries=3):
875 """Make Merge API request with exponential backoff retry"""
876 for attempt in range(max_retries + 1):
877 try:
878 if payload:
879 response = requests.post(url, headers=headers, json=payload)
880 else:
881 response = requests.get(url, headers=headers)
882
883 response.raise_for_status()
884 return response.json()
885
886 except requests.exceptions.RequestException as e:
887 if attempt == max_retries:
888 raise e
889
890 # Exponential backoff with jitter
891 delay = (2 ** attempt) + random.uniform(0, 1)
892 time.sleep(delay)
893```
894
895### Integration Health Monitoring
896```python
897def check_integration_health(merge_account):
898 """Verify integration is still active and healthy"""
899 try:
900 account_details = get_account_details(
901 merge_account.account_token,
902 merge_account.category
903 )
904
905 # Update integration info in case it changed
906 if account_details.get('integration'):
907 merge_account.integration_name = account_details['integration'] # String value
908 merge_account.integration_slug = account_details.get('integration_slug')
909
910 merge_account.status = 'active'
911 db.session.commit()
912 return True
913
914 except requests.exceptions.HTTPError as e:
915 if e.response.status_code in [401, 403]:
916 # Authentication failed - integration needs relinking
917 merge_account.status = 'error'
918 db.session.commit()
919
920 return False
921```
922
923## Environment Configuration
924
925### Required Environment Variables
926```python
927# .env file requirements
928MERGE_API_KEY="your_merge_api_key_here" # From Merge dashboard
929DATABASE_URL="postgresql://..." # Database connection
930SESSION_SECRET="your_session_secret" # Flask session encryption
931
932# Optional webhook configuration
933MERGE_WEBHOOK_SECRET="webhook_signature_secret" # For webhook verification
934```
935
936### API Key Management
937```python
938import os
939from functools import wraps
940
941def require_merge_api_key(f):
942 """Decorator to ensure Merge API key is available"""
943 @wraps(f)
944 def decorated_function(*args, **kwargs):
945 if not os.getenv('MERGE_API_KEY'):
946 return jsonify({
947 'success': False,
948 'error': 'Merge API key not configured'
949 }), 500
950 return f(*args, **kwargs)
951 return decorated_function
952
953# Usage
954@app.route('/api/merge/create-link-token', methods=['POST'])
955@login_required
956@require_merge_api_key
957def create_link_token():
958 # Implementation here
959 pass
960```
961
962## Testing Strategy
963
964### Unit Tests for Multi-Category Token Operations
965```python
966import unittest
967from unittest.mock import patch, Mock
968
969class TestMergeIntegration(unittest.TestCase):
970
971 @patch('requests.post')
972 def test_generate_link_token_success(self, mock_post):
973 mock_response = Mock()
974 mock_response.json.return_value = {'link_token': 'test_token_123'}
975 mock_response.raise_for_status.return_value = None
976 mock_post.return_value = mock_response
977
978 token = generate_merge_link_token('user_1_hris', 'hris')
979
980 self.assertEqual(token, 'test_token_123')
981 mock_post.assert_called_once()
982
983 def test_end_user_origin_id_format_multiple_categories(self):
984 user_id = 42
985 test_cases = [
986 ('hris', 'user_42_hris'),
987 ('ats', 'user_42_ats'),
988 ('crm', 'user_42_crm'),
989 ('accounting', 'user_42_accounting'),
990 ('ticketing', 'user_42_ticketing'),
991 ('filestorage', 'user_42_filestorage'),
992 ('knowledgebase', 'user_42_knowledgebase')
993 ]
994
995 for category, expected in test_cases:
996 result = f"{user_id}_{category}"
997 self.assertEqual(result, expected)
998```
999
1000### Integration Health Checks
1001```python
1002def run_integration_health_check():
1003 """Manual health check for all active integrations"""
1004 active_integrations = MergeLinkedAccount.query.filter_by(
1005 status='active',
1006 is_active=True
1007 ).all()
1008
1009 results = []
1010 for integration in active_integrations:
1011 health_status = check_integration_health(integration)
1012 results.append({
1013 'integration_id': integration.id,
1014 'integration_name': integration.integration_name,
1015 'user_id': integration.user_id,
1016 'healthy': health_status
1017 })
1018
1019 return results
1020```
1021
1022## Production Considerations
1023
1024### Rate Limiting
1025```python
1026from flask_limiter import Limiter
1027from flask_limiter.util import get_remote_address
1028
1029limiter = Limiter(
1030 app,
1031 key_func=get_remote_address,
1032 default_limits=["200 per day", "50 per hour"]
1033)
1034
1035@app.route('/api/merge/create-link-token', methods=['POST'])
1036@limiter.limit("10 per minute") # Prevent token spam
1037@login_required
1038def create_link_token():
1039 # Implementation
1040 pass
1041```
1042
1043### Logging and Monitoring
1044```python
1045import logging
1046
1047# Configure logging for Merge operations
1048merge_logger = logging.getLogger('merge_integration')
1049merge_logger.setLevel(logging.INFO)
1050
1051handler = logging.FileHandler('logs/merge_integration.log')
1052formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
1053handler.setFormatter(formatter)
1054merge_logger.addHandler(handler)
1055
1056def log_merge_operation(operation, user_id, details):
1057 """Log Merge integration operations for monitoring"""
1058 merge_logger.info(f"{operation} - User: {user_id} - Details: {details}")
1059```
1060
1061### Security Best Practices
1062```python
1063# Never log sensitive tokens
1064def safe_log_token(token):
1065 """Safely log token for debugging (first 8 chars only)"""
1066 if token and len(token) > 8:
1067 return f"{token[:8]}..."
1068 return "invalid_token"
1069
1070# Validate webhook signatures (implement based on Merge documentation)
1071def verify_webhook_signature(payload, signature, secret):
1072 """Verify webhook came from Merge"""
1073 import hmac
1074 import hashlib
1075
1076 expected = hmac.new(
1077 secret.encode('utf-8'),
1078 payload.encode('utf-8'),
1079 hashlib.sha256
1080 ).hexdigest()
1081
1082 return hmac.compare_digest(expected, signature)
1083```
1084
1085## End User Origin ID Architecture Patterns
1086
1087The `end_user_origin_id` generation strategy fundamentally shapes your backend implementation. Each strategy requires different database constraints, validation logic, and API patterns.
1088
1089### Strategy 1: One Integration per Category
1090
1091**Format**: `{organization_id}_{category}`
1092
1093#### Database Schema
1094```python
1095class MergeLinkedAccount(db.Model):
1096 id = db.Column(db.Integer, primary_key=True)
1097 organization_id = db.Column(db.Integer, nullable=False)
1098 category = db.Column(db.String(50), nullable=False)
1099 end_user_origin_id = db.Column(db.String(100), nullable=False, unique=True)
1100 account_token = db.Column(db.String(500))
1101 integration_name = db.Column(db.String(100)) # "BambooHR", populated after linking
1102 integration_slug = db.Column(db.String(50)) # "bamboohr", populated after linking
1103 status = db.Column(db.String(20), default='pending')
1104 created_at = db.Column(db.DateTime, default=datetime.utcnow)
1105
1106 # Enforce one integration per category per organization
1107 __table_args__ = (
1108 db.UniqueConstraint('organization_id', 'category', name='unique_org_category'),
1109 )
1110```
1111
1112#### ID Generation Function
1113```python
1114def generate_end_user_origin_id(organization_id, category):
1115 """Strategy 1: One integration per category"""
1116 return f"{organization_id}_{category}"
1117
1118# Example outputs:
1119# "org_123_hris", "org_123_ats", "org_123_crm"
1120```
1121
1122#### Validation Logic
1123```python
1124def validate_integration_limit(organization_id, category):
1125 """Ensure only one integration per category"""
1126 existing = MergeLinkedAccount.query.filter_by(
1127 organization_id=organization_id,
1128 category=category,
1129 status='active'
1130 ).first()
1131
1132 if existing:
1133 raise ValidationError(f"Active {category} integration already exists")
1134```
1135
1136---
1137
1138### Strategy 2: Multiple Integrations per Category
1139
1140**Format**: `{organization_id}_{category}_{unique_id}`
1141
1142**⚠️ CRITICAL IMPLEMENTATION DETAIL**: When implementing Strategy 2, you MUST handle incomplete linking attempts correctly to avoid cluttering your Merge dashboard with duplicate incomplete accounts. See the "Handling Incomplete Linking Attempts" section below for the required logic.
1143
1144**Key Principles:**
11451. **Save the `end_user_origin_id` BEFORE the user opens Merge Link** (not after)
11462. **Reuse the same `end_user_origin_id` for retry attempts** when status='pending'
11473. **Create NEW `end_user_origin_id` only when:**
1148 - First linking attempt (no existing record)
1149 - Adding a second integration (existing record with status='active')
1150
1151#### Database Schema
1152```python
1153import uuid
1154
1155class MergeLinkedAccount(db.Model):
1156 id = db.Column(db.Integer, primary_key=True)
1157 organization_id = db.Column(db.Integer, nullable=False)
1158 category = db.Column(db.String(50), nullable=False)
1159 unique_id = db.Column(db.String(100), nullable=False) # UUID for this connection
1160 end_user_origin_id = db.Column(db.String(200), nullable=False, unique=True)
1161 account_token = db.Column(db.String(500))
1162 integration_name = db.Column(db.String(100)) # "BambooHR", populated after linking
1163 integration_slug = db.Column(db.String(50)) # "bamboohr", populated after linking
1164 status = db.Column(db.String(20), default='pending')
1165 created_at = db.Column(db.DateTime, default=datetime.utcnow)
1166
1167 # Optional: Add display name for user-facing organization
1168 display_name = db.Column(db.String(100)) # "Production", "US Region", "Subsidiary A"
1169
1170 # No uniqueness constraint on integration_slug - allow multiple connections
1171```
1172
1173#### Critical: Handling Incomplete Linking Attempts
1174
1175**Merge Best Practice**: When a user exits the linking flow without completing it, Merge creates an "Incomplete" account on their side. A common pitfall is generating a **new** `end_user_origin_id` for every retry attempt, which creates duplicate incomplete accounts and clutters your Merge dashboard.
1176
1177**The Solution**: Save the `end_user_origin_id` at the START of the linking flow (before the user opens Merge Link), and **reuse the same ID** for retry attempts.
1178
1179##### Incomplete Attempt Flow Logic
1180
1181```python
1182def create_link_token(user, category):
1183 """Generate link token with proper end_user_origin_id reuse logic"""
1184
1185 # Step 1: Check for existing records for this user + category
1186 existing_account = MergeLinkedAccount.query.filter_by(
1187 user_id=user.id,
1188 category=category
1189 ).order_by(MergeLinkedAccount.created_at.desc()).first()
1190
1191 # Step 2: Determine whether to reuse or create new end_user_origin_id
1192 if existing_account and existing_account.status == 'pending':
1193 # SCENARIO A: Incomplete attempt - REUSE the same end_user_origin_id
1194 # User started linking but didn't finish. Reuse the ID to avoid duplicate incomplete accounts.
1195 end_user_origin_id = existing_account.end_user_origin_id
1196 logging.info(f"Reusing end_user_origin_id for retry: {end_user_origin_id}")
1197
1198 elif existing_account and existing_account.status == 'active':
1199 # SCENARIO B: User already has a completed integration, wants to add another
1200 # This is Strategy 2 in action - create NEW UUID for the second integration
1201 unique_id = str(uuid.uuid4())
1202 end_user_origin_id = f"{user.organization_id}_{category}_{unique_id}"
1203
1204 # Create new pending record for the additional integration
1205 new_account = MergeLinkedAccount(
1206 user_id=user.id,
1207 unique_id=unique_id,
1208 end_user_origin_id=end_user_origin_id,
1209 category=category,
1210 status='pending'
1211 )
1212 db.session.add(new_account)
1213 db.session.commit()
1214 logging.info(f"Adding second integration - new end_user_origin_id: {end_user_origin_id}")
1215
1216 else:
1217 # SCENARIO C: First linking attempt - create NEW UUID
1218 unique_id = str(uuid.uuid4())
1219 end_user_origin_id = f"{user.organization_id}_{category}_{unique_id}"
1220
1221 first_account = MergeLinkedAccount(
1222 user_id=user.id,
1223 unique_id=unique_id,
1224 end_user_origin_id=end_user_origin_id,
1225 category=category,
1226 status='pending'
1227 )
1228 db.session.add(first_account)
1229 db.session.commit()
1230 logging.info(f"First linking attempt - new end_user_origin_id: {end_user_origin_id}")
1231
1232 # Step 3: Generate fresh link token with the end_user_origin_id (reused or new)
1233 # Link tokens expire quickly (~1 hour), so always generate fresh token
1234 # But reuse the same end_user_origin_id for incomplete attempts
1235 link_token = generate_merge_link_token(end_user_origin_id, category)
1236 return link_token
1237```
1238
1239##### Why This Matters
1240
1241**Without this logic (WRONG):**
1242```
1243User clicks "Connect" → New UUID → "pending" record created
1244User exits modal → Orphaned "pending" record + Incomplete account in Merge
1245User clicks "Connect" again → NEW UUID → NEW "pending" record created
1246User exits again → Another orphaned record + Another incomplete account in Merge
1247Result: 2 pending records in your DB, 2 incomplete accounts in Merge dashboard
1248```
1249
1250**With this logic (CORRECT):**
1251```
1252User clicks "Connect" → New UUID → "pending" record created
1253User exits modal → Record stays "pending"
1254User clicks "Connect" again → SAME UUID reused → Same record found
1255User completes linking → Existing record updated to "active"
1256Result: 1 clean record in your DB, 1 complete account in Merge dashboard
1257```
1258
1259##### Visual Flow Diagram
1260
1261```
1262┌─────────────────────────────────────────────────────────────────┐
1263│ User First Attempt │
1264└─────────────────────────────────────────────────────────────────┘
1265 User clicks "Connect"
1266 ↓
1267 Check database: No existing record
1268 ↓
1269 Generate NEW UUID: abc-123
1270 Create record: status='pending', end_user_origin_id='org_hris_abc-123'
1271 ↓
1272 Generate link token with end_user_origin_id='org_hris_abc-123'
1273 ↓
1274 User opens Merge Link modal
1275 ↓
1276 User exits without completing
1277 ↓
1278 Record remains: status='pending' ✓
1279
1280┌─────────────────────────────────────────────────────────────────┐
1281│ User Retry Attempt │
1282└─────────────────────────────────────────────────────────────────┘
1283 User clicks "Connect" again
1284 ↓
1285 Check database: Found record with status='pending'
1286 ↓
1287 REUSE EXISTING: end_user_origin_id='org_hris_abc-123' ✓
1288 ↓
1289 Generate NEW link token with SAME end_user_origin_id='org_hris_abc-123'
1290 ↓
1291 User opens Merge Link modal
1292 ↓
1293 User completes linking to BambooHR
1294 ↓
1295 Update record: status='active', integration_name='BambooHR' ✓
1296
1297┌─────────────────────────────────────────────────────────────────┐
1298│ User Adds Second Integration │
1299└─────────────────────────────────────────────────────────────────┘
1300 User clicks "Connect" again
1301 ↓
1302 Check database: Found record with status='active'
1303 ↓
1304 Generate NEW UUID: xyz-789 (Strategy 2!)
1305 Create NEW record: status='pending', end_user_origin_id='org_hris_xyz-789'
1306 ↓
1307 Generate link token with NEW end_user_origin_id='org_hris_xyz-789'
1308 ↓
1309 User completes linking to Workday
1310 ↓
1311 Update new record: status='active', integration_name='Workday' ✓
1312 ↓
1313 Result: 2 active integrations (BambooHR + Workday) ✓
1314```
1315
1316##### Testing the Flow
1317
1318To verify this is working correctly:
1319
13201. **First attempt**: Click "Connect HRIS Integration"
1321 - Check logs: `"First linking attempt - new end_user_origin_id: org_123_hris_abc..."`
1322 - Exit the modal without completing
1323
13242. **Retry attempt**: Click "Connect HRIS Integration" again
1325 - Check logs: `"Reusing end_user_origin_id for retry: org_123_hris_abc..."` (same ID!)
1326 - Complete the linking flow
1327 - Database record updates from `status='pending'` to `status='active'`
1328
13293. **Adding second integration**: Click "Connect HRIS Integration" again
1330 - Check logs: `"Adding second integration - new end_user_origin_id: org_123_hris_xyz..."` (NEW ID!)
1331 - This allows multiple integrations per category (Strategy 2)
1332
1333#### ID Generation Function
1334```python
1335def generate_end_user_origin_id(organization_id, category):
1336 """Strategy 2: Multiple integrations per category
1337
1338 NOTE: Don't call this directly in create_link_token!
1339 Use the logic above to check for existing pending records first.
1340 """
1341 unique_id = str(uuid.uuid4()) # Full UUID for guaranteed uniqueness
1342 return f"{organization_id}_{category}_{unique_id}"
1343
1344# Example outputs:
1345# "org_123_hris_550e8400-e29b-41d4-a716-446655440000"
1346```
1347
1348#### Complete Implementation Flow
1349
1350**Important: Merge Best Practice for Incomplete Attempts**
1351
1352When a user exits the linking flow without completing it, Merge creates an "Incomplete" account. To avoid cluttering your Merge dashboard with duplicate incomplete accounts, **reuse the same end_user_origin_id** for retry attempts.
1353
1354```python
1355# Step 1: Create Link Token
1356@app.route('/api/merge/create-link-token', methods=['POST'])
1357@login_required
1358def create_link_token():
1359 try:
1360 data = request.get_json()
1361 category = data.get('category', 'hris')
1362
1363 # Check for existing records for this user + category
1364 existing_account = MergeLinkedAccount.query.filter_by(
1365 user_id=current_user.id,
1366 category=category
1367 ).order_by(MergeLinkedAccount.created_at.desc()).first()
1368
1369 if existing_account and existing_account.status == 'pending':
1370 # There's an incomplete linking attempt - REUSE the same end_user_origin_id
1371 # This prevents creating duplicate incomplete accounts in Merge's dashboard
1372 end_user_origin_id = existing_account.end_user_origin_id
1373 logging.info(f"Reusing existing end_user_origin_id for retry: {end_user_origin_id}")
1374
1375 elif existing_account and existing_account.status == 'active':
1376 # User already has a completed integration and wants to add another one
1377 # Strategy 2: Create NEW UUID for the second integration in same category
1378 unique_id = str(uuid.uuid4())
1379 end_user_origin_id = f"{current_user.organization_id}_{category}_{unique_id}"
1380
1381 # Create new pending record for the additional integration
1382 merge_account = MergeLinkedAccount(
1383 user_id=current_user.id,
1384 category=category,
1385 unique_id=unique_id,
1386 end_user_origin_id=end_user_origin_id,
1387 integration_name=None, # Don't know yet - user will pick in Merge modal
1388 integration_slug=None, # Don't know yet
1389 status='pending'
1390 )
1391 db.session.add(merge_account)
1392 db.session.commit()
1393 logging.info(f"Creating second integration - new end_user_origin_id: {end_user_origin_id}")
1394
1395 else:
1396 # No existing record - this is the first linking attempt
1397 # Generate new UUID and create new record
1398 unique_id = str(uuid.uuid4())
1399 end_user_origin_id = f"{current_user.organization_id}_{category}_{unique_id}"
1400
1401 merge_account = MergeLinkedAccount(
1402 user_id=current_user.id,
1403 category=category,
1404 unique_id=unique_id,
1405 end_user_origin_id=end_user_origin_id,
1406 integration_name=None, # Don't know yet - user will pick in Merge modal
1407 integration_slug=None, # Don't know yet
1408 status='pending'
1409 )
1410 db.session.add(merge_account)
1411 db.session.commit()
1412 logging.info(f"First linking attempt - created new end_user_origin_id: {end_user_origin_id}")
1413
1414 # Generate link token from Merge (always create fresh link token, but reuse end_user_origin_id)
1415 link_token = generate_merge_link_token(end_user_origin_id, category)
1416
1417 return jsonify({
1418 'success': True,
1419 'link_token': link_token
1420 })
1421
1422 except Exception as e:
1423 db.session.rollback()
1424 return jsonify({
1425 'success': False,
1426 'error': str(e)
1427 }), 500
1428
1429# Step 2: Exchange Public Token
1430@app.route('/api/merge/exchange-public-token', methods=['POST'])
1431@login_required
1432def exchange_public_token():
1433 try:
1434 data = request.get_json()
1435 public_token = data.get('public_token')
1436
1437 if not public_token:
1438 return jsonify({
1439 'success': False,
1440 'error': 'Missing public_token'
1441 }), 400
1442
1443 # Exchange for account_token
1444 account_token = retrieve_account_token(public_token)
1445
1446 # Get account details to learn what they connected
1447 account_details = get_account_details(account_token)
1448
1449 # Find the pending record
1450 end_user_origin_id = account_details.get('end_user_origin_id')
1451 merge_account = MergeLinkedAccount.query.filter_by(
1452 end_user_origin_id=end_user_origin_id,
1453 organization_id=current_user.organization_id
1454 ).first()
1455
1456 if not merge_account:
1457 return jsonify({
1458 'success': False,
1459 'error': 'Integration record not found'
1460 }), 404
1461
1462 # Update with integration details (extract from top level of response)
1463 merge_account.account_token = account_token
1464 merge_account.integration_name = account_details.get('integration') # "BambooHR", "Officient", etc.
1465 merge_account.integration_slug = account_details.get('integration_slug') # "bamboohr", "officient", etc.
1466 merge_account.status = 'active'
1467
1468 db.session.commit()
1469
1470 return jsonify({
1471 'success': True,
1472 'integration_name': merge_account.integration_name
1473 })
1474
1475 except Exception as e:
1476 db.session.rollback()
1477 return jsonify({
1478 'success': False,
1479 'error': str(e)
1480 }), 500
1481```
1482
1483---
1484
1485### Strategy Selection in Code
1486
1487#### Configuration-Driven Strategy
1488```python
1489class IntegrationConfig:
1490 # Choose your strategy: 1 or 2
1491 END_USER_ORIGIN_STRATEGY = 2 # 1 = one per category, 2 = multiple per category
1492
1493 # Strategy-specific settings
1494 ALLOW_MULTIPLE_PER_CATEGORY = END_USER_ORIGIN_STRATEGY == 2
1495
1496def generate_end_user_origin_id(organization_id, category):
1497 """Dynamic strategy selection"""
1498 if IntegrationConfig.END_USER_ORIGIN_STRATEGY == 1:
1499 return f"{organization_id}_{category}"
1500
1501 elif IntegrationConfig.END_USER_ORIGIN_STRATEGY == 2:
1502 unique_id = str(uuid.uuid4())
1503 return f"{organization_id}_{category}_{unique_id}"
1504
1505 else:
1506 raise ValueError(f"Unknown strategy: {IntegrationConfig.END_USER_ORIGIN_STRATEGY}")
1507```
1508
1509## Key Implementation Requirements
1510
1511### Critical Success Factors
15121. **Strategy Decision Early**: Choose end_user_origin_id strategy before implementation
15132. **Database Record Timing**: Always store end_user_origin_id immediately during link token creation
15143. **Fresh Token Generation**: Never cache or reuse link tokens between attempts
15154. **Strategy-Appropriate Constraints**: Implement database constraints matching chosen strategy
15165. **Multi-Category Architecture**: Support integrations across different categories
15176. **Account Token Preservation**: Never change account tokens during relinking
15187. **POST Method for Deletion**: Use POST, not DELETE for account deletion API
15198. **Proper Error Recovery**: Reset integration states cleanly on failures
1520
1521### Common Backend Mistakes
15221. **Storing records too late**: Causes duplicate Merge accounts
15232. **Caching link tokens**: Violates Merge's single-use requirement
15243. **Wrong deletion endpoint**: Using DELETE instead of POST method
15254. **Missing error handling**: Not cleaning up partial states on failures
15265. **Inadequate logging**: Not tracking operations for debugging production issues

Start by providing the coding agent context on how Merge works and what changes it may need to make to your code.

Parse through these links to understand what Merge.dev implementation entails. Merge is a unified API and instead of our engineering team having to build and maintain dozens of individual integrations to different vendor APIs (each with its own schemas, edge cases, auth methods, versions, quirks), we will integrate once with Merge. Integrating into Merge looks like: using the Merge SDK or calling the REST API directly → embedding Merge Link, a prebuilt widget, into our product (the connection flow) → syncing data via Merge’s unified API → handling updates via polling and webhooks.

Links to parse through: Merge link guide (https://r.jina.ai/https://docs.merge.dev/get-started/link/), syncing data guide (https://r.jina.ai/https://docs.merge.dev/basics/syncing-data/), and end-user-origin-id guide (https://r.jina.ai/https://help.merge.dev/articles/8032943-end-user-origin-id-best-practices?lang=en)

Do not make changes.

Step 1.1: Merge Link context

I want to start by implementing Merge into my frontend. There should be no mention of any Merge-related concepts on the frontend UI. The Merge Link authentication flow is a series of token exchanges. The entire Merge authentication flow should be completely invisible in the user experience, outside of the Merge Link modal. They should only see business-relevant messaging about connecting their 3rd party system, with zero technical Merge terminology exposed anywhere in the UI. Here is the 3-step authentication flow:

  1. Backend: Create link-token with end_user_origin_id → receive link_token. A new link token must be generated for every open of Merge Link, and a unique identifier must be passed in as end_user_origin_id for every integration within a given Merge category (e.g. “hris”, “ats”, “accounting”, “ticketing”, “crm”).

  2. Frontend: User completes Merge Link modal → receive public_token

  3. Backend: Exchange public_token for account_token → store for API calls

The term “linked account” will mean an integration connection that has completed the 3-step authentication flow (link_token → public_token → account_token). It represents an active, authorized connection that can sync data between the 3rd party system and our application. Each linked account has a unique account_token which, in combination with the API key we have stored, allows API calls to fetch data from Merge pertaining to that specific 3rd party system.

Do not make any changes.

Step 1.2: End user origin ID considerations

You’ll need to consider how your organization will generate an end_user_origin_id in accordance with best practice recommendations. It is the main factor in the determination of a unique linked account. The combination of end_user_origin_id and the category selected during the Merge Link flow will determine a unique linked account.

At a minimum, a table needs to be created in your database with the following fields:

PropertyPurpose
idPrimary key
organization_idForeign key → your organizations table
end_user_origin_idThe ID used in the linking process. Format: {organization_id}_{category}_{UUID}
categoryCategory of the linked account (e.g. hris, ats)
integrationSlug of the Merge integration (e.g. bamboohr, google-drive)
statusStatus of the linked account (COMPLETE, INCOMPLETE, RELINK_NEEDED)
linked_account_idMerge’s identifier for the linked account
last_successful_sync_atUsed to seed modified_after in incremental fetches
account_tokenThe permanent token generated through the Merge linking process
created_at / updated_atAudit timestamps

The prompts below assume a multiple-integrations-per-organization implementation strategy with organization/admin-based authentication. If your use case requires individual auth (e.g. file storage workflows), pass in {user_id} instead of {organization_id} as the base for end_user_origin_id.


Step 2: Embedding Merge Link

Two different UI patterns are shown below. Pick the one that suits your desired user experience.

  • Option 1 — Standard “Connect Integration” button where users select from all available integrations
  • Option 2 — App center / integration marketplace where users select a specific integration before connecting

Option 1: Connect integration button

Connect HR button

Step 2.1.1: Link token creation (backend)

The goal of this step is to create the backend API call to generate a Merge link token — the first of three tokens used during the linking flow.

The prompts below specify the hris category. Replace it with your respective category as needed.

Implement the first part of the Merge Link authentication flow - creating the link token. This should look like a button on the frontend that says “Connect [Category] Integration”. Upon selection of this button, a new link token should be generated.

Generate a unique end_user_origin_id using the organization ID and category as the unique identifier (e.g., {organization_id}_{category}_{UUID}). Before creating a link token, check if a record already exists for this organization_id + category combination. If an existing record exists and status = ‘pending’, reuse its end_user_origin_id and generate a fresh link token with that same end_user_origin_id. If no record exists, create a new pending record with the generated end_user_origin_id, category, status='pending', organization_id. This ensures the prevention of duplicate incomplete accounts in Merge’s database.

For the purposes of this implementation, we will be creating a button for the HRIS category. IMPORTANT: A new link_token must be generated on every button click. Never reuse or cache link tokens between attempts.

The code to generate link_token will look like the following:

1import requests
2
3# Replace api_key with your Merge API Key
4def create_link_token(user, api_key):
5 body = {
6 "end_user_origin_id": end_user_origin_id, # Generated above with format: {org_id}_{category}_{UUID}
7 "end_user_organization_name": user.organization.name,
8 "end_user_email_address": user.email_address,
9 "categories": ["hris"],
10 }
11
12 headers = {"Authorization": f"Bearer {api_key}"}
13
14 link_token_url = "https://api.merge.dev/api/integrations/create-link-token"
15 link_token_result = requests.post(link_token_url, data=body, headers=headers)
16 link_token = link_token_result.json().get("link_token")
17
18 return link_token

Test the button. Though we are testing, there should not be any frontend success message because this process should be completely invisible to the end user. For now, are you successfully retrieving a link_token in the backend?

Step 2.1.2: Merge Link modal & token exchange (frontend & backend)

The goal of this step is to initialize a Merge Link session on your frontend using the link token and retrieve an account token for future Merge API requests.

We now need to make Merge Link appear in the frontend by using the link token to open Merge Link. Make sure to handle all MergeLink callbacks (onSuccess, onExit, onError). Reset button states on modal close. You must explicitly call openLink() to get the modal to appear. This is sample code in HTML/JS:

1<button id="open-link-button">Start linking</button>
2<script src="https://cdn.merge.dev/initialize.js"></script>
3<script type="text/javascript">
4 const button = document.getElementById("open-link-button");
5 button.disabled = true;
6
7 function onSuccess(public_token) {
8 // Send public_token to server (Step 3)
9 }
10
11 MergeLink.initialize({
12 linkToken: "ADD_GENERATED_LINK_TOKEN",
13 onSuccess: (public_token) => onSuccess(public_token),
14 onReady: () => (button.disabled = false),
15 shouldSendTokenOnSuccessfulLink: true,
16 });
17
18 button.addEventListener("click", function () {
19 MergeLink.openLink();
20 });
21</script>

In the backend, we need to swap the short-lived public_token for a permanent account_token. We need to store this account_token, because it’s used to authenticate API requests to Merge.

Here is sample code in Python for the exchange from public_token to account_token:

1import requests
2
3def retrieve_account_token(public_token, api_key):
4 headers = {"Authorization": f"Bearer {api_key}"}
5
6 account_token_url = "https://api.merge.dev/api/integrations/account-token/{}".format(public_token)
7 account_token_result = requests.get(account_token_url, headers=headers)
8
9 account_token = account_token_result.json().get("account_token")
10 return account_token # Save this in your database

Once we receive the account_token from a successful integration link, store it, then hit GET /account-details (https://api.merge.dev/api/hris/v1/account-details) using the API key and account_token, which returns a response like:

1{
2 "id": "0496d4c2-42e6-4072-80b3-7b69bfdc76fd",
3 "integration": "BambooHR",
4 "integration_slug": "bamboohr",
5 "category": "hris",
6 "end_user_origin_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
7 "end_user_organization_name": "Waystar Royco",
8 "end_user_email_address": "user@example.com",
9 "status": "COMPLETE",
10 "webhook_listener_url": "https://api.merge.dev/api/integrations/webhook-listener/7fc3mee0UW8ecV4",
11 "is_duplicate": true,
12 "account_type": "PRODUCTION",
13 "completed_at": "2024-08-26T20:11:19.277118Z"
14}

This will allow us to populate the integration name (account-details.integration), which can be reflected in our existing “Connected Integrations” menu. We do not need any additional notifications that a link is successful — Merge will already display that within the Merge Link modal.

When the integration completes successfully, find the existing database record by organization_id + category combination and update it with status='active' (if it was pending).

Implement this, and then I’ll test the Merge Link modal by linking an integration and testing the account_token swap.

Step 2.1.3: Relinking & deleting linked accounts

The goal of this step is to handle the full lifecycle of a linked account after the initial Merge Link flow. Specifically, implement the ability for users to:

  • Relink an integration to refresh credentials or permissions without altering existing database records
  • Delete an integration to permanently remove a linked account, including its stored account_token and end_user_origin_id

For a given connected integration, I want the following buttons to appear: “Relink integration” and “Delete integration”.

For the “Relink integration” button, pass in the same end_user_origin_id for the given integration into the POST /link-token endpoint to initiate the Merge Link modal. During relinking, the end_user_origin_id, account_token, and integration_name all remain unchanged — relinking only refreshes credentials/permissions on Merge’s side. From our application’s perspective, nothing in the database changes during relinking except potentially updating the status back to ‘active’ if it was marked as having issues.

For the “Delete integration” button, hit POST /delete-account (https://api.merge.dev/api/hris/v1/delete-account). This will delete the associated linked account in Merge, so the account_token and end_user_origin_id of that integration should also be deleted from our database. Only the deletion flow will need a warning: ‘This action is permanent. Do you want to proceed?’ If the user proceeds, the integration should also be removed from our integrations page.


Option 2: App center / integration marketplace

App marketplace

This pattern guides users directly to a specific integration’s configuration step, skipping the integration selection screen in Merge Link. This is useful for marketplace-style UIs where users browse and select an integration before connecting.

Merge provides an endpoint to fetch integration names and logos programmatically. Use the following prompt to set it up:

“Parse through the Integration Metadata guide (https://r.jina.ai/https://docs.merge.dev/basics/integration-metadata/) to understand how the integration metadata endpoint works. In the backend, use Merge’s Integration Metadata endpoint (GET /api/organizations/integrations) to fetch integration names, identifiers, images, brand colors, and other details. You’ll need to use the Merge API key. The response is paginated. Don’t make any immediate changes.”

Step 2.2.1: Link token creation (backend)

Begin by parsing through this link on setting up a single integration for Merge Link (https://r.jina.ai/http://docs.merge.dev/guides/merge-link/single-integration/). This will guide our user directly to a specific integration’s configuration step by showing them a single integration in Merge’s component. The integration identifier passed into POST /link-token will be the application the user selects on the frontend. For example, if they select BambooHR, “hris” will be passed as the category and “bamboohr” as the integration identifier. Don’t make any immediate changes.

Implement the first part of the Merge Link authentication flow - creating the link token. This will be a “Connect” button for a selected integration in the marketplace. Upon selection of this button, a new link token should be generated.

Generate a unique end_user_origin_id using the organization ID and category (e.g., {organization_id}_{category}_{UUID}). Before creating a link token, check if a record already exists for this organization_id + category combination. If an existing record exists and status = 'pending', reuse its end_user_origin_id and generate a fresh link token. If no record exists, create a new pending record.

IMPORTANT: A new link_token must be generated on every button click. Never reuse or cache link tokens between attempts.

Here is sample code in Python for the single-integration Merge Link flow:

1import requests
2
3def create_link_token(user, api_key, integration_slug):
4 body = {
5 "end_user_origin_id": end_user_origin_id, # Format: {org_id}_{category}_{UUID}
6 "end_user_organization_name": user.organization.name,
7 "end_user_email_address": user.email_address,
8 "categories": ["hris"],
9 "integration": integration_slug, # e.g. "bamboohr"
10 }
11
12 headers = {"Authorization": f"Bearer {api_key}"}
13
14 link_token_url = "https://api.merge.dev/api/integrations/create-link-token"
15 link_token_result = requests.post(link_token_url, data=body, headers=headers)
16 link_token = link_token_result.json().get("link_token")
17
18 return link_token

Test the button. Though we are testing, there should not be any frontend success message. For now, are you successfully retrieving a link_token in the backend?

Step 2.2.2: Merge Link modal & token exchange (frontend & backend)

We now need to make Merge Link appear in the frontend by using the link token to open Merge Link. Make sure to handle all MergeLink callbacks (onSuccess, onExit, onError). Reset button states on modal close. You must explicitly call openLink() to get the modal to appear. This is sample code in HTML/JS:

1<button id="open-link-button">Start linking</button>
2<script src="https://cdn.merge.dev/initialize.js"></script>
3<script type="text/javascript">
4 const button = document.getElementById("open-link-button");
5 button.disabled = true;
6
7 function onSuccess(public_token) {
8 // Send public_token to server (Step 3)
9 }
10
11 MergeLink.initialize({
12 linkToken: "ADD_GENERATED_LINK_TOKEN",
13 onSuccess: (public_token) => onSuccess(public_token),
14 onReady: () => (button.disabled = false),
15 shouldSendTokenOnSuccessfulLink: true,
16 });
17
18 button.addEventListener("click", function () {
19 MergeLink.openLink();
20 });
21</script>

In the backend, swap the short-lived public_token for a permanent account_token and store it.

Here is sample code in Python:

1import requests
2
3def retrieve_account_token(public_token, api_key):
4 headers = {"Authorization": f"Bearer {api_key}"}
5
6 account_token_url = "https://api.merge.dev/api/integrations/account-token/{}".format(public_token)
7 account_token_result = requests.get(account_token_url, headers=headers)
8
9 account_token = account_token_result.json().get("account_token")
10 return account_token # Save this in your database

We do not need any additional notifications that a link is successful — Merge will already display that within the Merge Link modal. The integration should appear in the “Connected Apps” management page once we receive and store the account_token. When the integration completes successfully, find the existing database record by organization_id + category combination and update it with status='active'.

Implement this, and then I’ll test the Merge Link modal by linking an integration and testing the account_token swap.

Step 2.2.3: Relinking & deleting linked accounts

Within the integration management page, I want the following buttons to appear for each connected integration: “Relink integration” and “Delete integration”.

For the “Relink integration” button, pass in the same end_user_origin_id for the given integration into the POST /link-token endpoint. During relinking, the end_user_origin_id, account_token, and integration_name all remain unchanged — relinking only refreshes credentials/permissions on Merge’s side.

For the “Delete integration” button, hit Merge’s POST /delete-account endpoint (https://api.merge.dev/api/hris/v1/delete-account) for that integration. The account_token and end_user_origin_id should also be deleted from our database. Only the deletion flow will need a warning: ‘This action is permanent. Do you want to proceed?’ If the user proceeds, the integration should also be deleted from the application’s UI.