Magic Link

A signed URL your agent (or your backend) can hand to the user for authentication.

Magic Link is a signed URL the user opens in their own browser to authenticate a Connector. The user clicks, completes OAuth at the third party, lands either back in your app (via a callback URL) or on a generic “you’re done” page, and credentials are stored against their Registered User.

Both flows produce the same outcome but apply in different contexts.

Embedded LinkMagic Link
What runs in the user’s browserA React component you embedA page Agent Handler hosts
When to useYour product is a web app and you control the frontendEmail flows, mobile apps, agent responses, anywhere you don’t have a browser context to render a component
Where the user startsA button in your UIA link they tap from email, chat, an agent message
Where they end upBack in your app via onSuccessBack at your callback_url, or a generic done page

Use embedded Link when you can. Use Magic Link when you can’t render the component - most commonly when the auth needs to happen in a different browser session than where the user got the link.

Backend call. Hit the same /link-token endpoint as embedded Link; the response includes a magic_link_url you can hand to the user.

$curl -X POST https://ah-api.merge.dev/api/registered-users/$REGISTERED_USER_ID/link-token \
> -H "Authorization: Bearer $MERGE_AGENT_HANDLER_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "connector_slug": "linear" }'
1{
2 "link_token": "ltk_•••••",
3 "magic_link_url": "https://ah-api.merge.dev/magic-link/•••••"
4}

Send magic_link_url to the user - email, chat message, in-app notification, the body of an agent response. They open it in any browser, complete OAuth, and the credential is stored.

At-runtime via the agent

When the agent calls a tool the user hasn’t authenticated, the Connector returns an authenticate_meta payload that includes the Magic Link URL ready to share:

1{
2 "type": "authenticate_meta",
3 "Connector": "linear",
4 "magic_link_url": "https://ah-api.merge.dev/magic-link/•••••",
5 "link_token": "ltk_•••••",
6 "message": "Share this link with the user to connect their Linear account. After they authenticate, refetch tools or restart your MCP connection."
7}

The message field is written for the agent - the model can include it verbatim in its reply to the user. The user clicks the link, authenticates, and the agent’s next attempt at the same tool succeeds.

Callback URLs

Without a callback URL, the user lands on a generic “authentication complete” page after auth. With one, they’re redirected back to your app.

Set up:

  1. Register the origin. At Settings → API Keys → Allowed callback origins, add the origin (scheme + host) of your callback URL. Origins must be HTTPS for web; custom schemes (myapp://, tauri://) are allowed for mobile and desktop apps.
  2. Pass callback_url when minting the token.
1POST /api/registered-users/{id}/link-token
2
3{
4 "connector_slug": "linear",
5 "callback_url": "https://myapp.com/integrations/done"
6}

After the user authenticates (or exits), the browser redirects to your callback URL with query parameters appended:

ParameterAlways present?Meaning
statusYessuccess / error / exit
codeOnly with auth code exchangeThe authorization code (see below)
stateOnly with auth code exchangeThe state value you provided

Don’t append your own ?state= to the callback URL - Agent Handler appends it for you. Doubled-up query parameters break the flow.

Same flow, custom URL scheme. Register myapp:// as an allowed origin, pass callback_url: "myapp:///integrations/done". After authentication the browser redirects to that scheme, your OS routes it to your app, your app reads the status parameter.

Authorization code exchange (for server-to-server flows)

By default, credentials are stored the moment the user finishes authenticating in the browser. For higher-security flows - most common with mobile and desktop apps where the browser session and your backend are different processes - you can require a server-to-server step before the credential is created.

The flow:

  1. Mint the link token with both callback_url and state. The presence of state triggers authorization code mode.

    1POST /api/registered-users/{id}/link-token
    2
    3{
    4 "connector_slug": "linear",
    5 "callback_url": "https://myapp.com/integrations/callback",
    6 "state": "random-csrf-token-123"
    7}
  2. The user authenticates. The callback URL is hit with status=success&code=ah_code_xyz&state=random-csrf-token-123. The credential is not yet stored.

  3. Validate state. It should match the value you minted with. If not, abort - this is a CSRF attempt.

  4. Exchange the code from your backend. Hit the confirmation endpoint with the code. Agent Handler creates the credential and returns the IDs.

    $curl -X POST https://ah-api.merge.dev/api/v1/link-token/confirm/ \
    > -H "Authorization: Bearer $MERGE_AGENT_HANDLER_API_KEY" \
    > -H "Content-Type: application/json" \
    > -d '{ "code": "ah_code_xyz" }'
    1{
    2 "credential_id": "134e0111-0f67-44f6-98f0-597000290bb3",
    3 "connector_slug": "linear",
    4 "registered_user_id": "550e8400-e29b-41d4-a716-446655440000"
    5}

Codes expire after 5 minutes. They’re single-use. They can only be exchanged by the same organization that minted them.

The state-and-code dance is OAuth’s standard CSRF protection: the redirect comes from the user’s browser, but the code exchange comes from your backend. Even if an attacker hijacks the redirect, they can’t redeem the code without the API key.

When to use code exchange

  • Mobile and desktop apps. The browser flow and the app are separate processes; code exchange lets the app verify the auth before trusting it.
  • Compliance. Some security reviews want server-to-server credential creation as a hard requirement.
  • Pre-credential validation. You want to check the user’s identity in your own system before the credential is created on Agent Handler’s side.

For simpler web flows where the user authenticates and closes the tab, omit state - credentials are created immediately, no exchange step needed.

Errors during code exchange

StatusErrorMeaning
400invalid_authorization_codeCode doesn’t exist or was already used
400authorization_code_expiredOlder than 5 minutes
403code_does_not_belong_to_organizationWrong API key for the org that minted the code

Treat all of these as terminal - re-mint a fresh link token and start over.

Next

Brand the Link experience with your logo and colors via Link customization.