Connect with MatchaiComing soon
Let a professional connect their Matchai account to your app with OAuth 2.1 and PKCE. Endpoints, the Allow page, tokens and a full TypeScript example.
Connect with Matchai is how an app gets access to a professional's account, with their permission. It is OAuth 2.1 with the authorization code flow and PKCE. The professional sees what your app may do and who pays, presses Allow, and your app appears on their Connectors page, where they can disconnect it at any time.
Use it for anything other professionals will use: a website builder, a CRM, a partner's app, an AI assistant. For your own account, an API key is simpler.
Each connection is one Matchai account connected to one app, with its own tokens. A website builder with three broker customers holds three connections.
Before you start
- Create a Matchai app to get a client ID and a client secret, and to register your redirect URLs.
- Decide which scopes your app needs. Ask for the shortest list that works.
- Have a server that can keep the client secret private. The token exchange happens there, never in the browser.
Endpoints
| Purpose | URL |
|---|---|
| Authorization (the Allow page) | https://matchai.co/oauth/authorize |
| Token and refresh | https://api.matchai.co/oauth/token |
| Dynamic client registration | https://api.matchai.co/oauth/register |
| Authorization server metadata | https://api.matchai.co/.well-known/oauth-authorization-server |
| Protected resource metadata | https://api.matchai.co/.well-known/oauth-protected-resource for the REST API, and https://matchai.co/.well-known/oauth-protected-resource for the MCP server |
The last three are for MCP clients and for libraries that discover endpoints on their own. If you write the flow by hand, you only need the first two.
PKCE with S256 is required. Authorization codes are single-use and short-lived, and access tokens are short-lived: use the refresh token to get new ones.
The flow
Prepare PKCE and a state value. Generate a random code_verifier and keep it. Send its SHA-256 hash, base64url-encoded, as the code_challenge. Generate a random state too. Keep both where only this browser can send them back, such as an HttpOnly cookie.
Redirect the professional to Matchai. Send their browser to the authorization URL with these parameters:
Field
Type
https://matchai.co/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.your-website-company.example%2Fmatchai%2Fcallback
&scope=profile%3Aread%20listings%3Aread%20contacts%3Awrite
&state=3b1f0c9e7a2d4e68
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256The professional answers. If they are not signed in to Matchai, they sign in first. They then see the Allow page and press Allow, or decline.
Matchai sends them back. They return to your redirect_uri with a code and your state:
https://app.your-website-company.example/matchai/callback?code=…&state=3b1f0c9e7a2d4e68If they decline, there is an error=access_denied and your state instead of a code. Check that state matches the value you kept before you do anything else.
Exchange the code for tokens. From your server, POST to the token endpoint with a form-encoded body:
curl https://api.matchai.co/oauth/token \
-d grant_type=authorization_code \
-d code="$MATCHAI_AUTH_CODE" \
-d redirect_uri="https://app.your-website-company.example/matchai/callback" \
-d client_id="$MATCHAI_CLIENT_ID" \
-d client_secret="$MATCHAI_CLIENT_SECRET" \
-d code_verifier="$MATCHAI_CODE_VERIFIER"{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…",
"scope": "profile:read listings:read contacts:write"
}expires_in is the lifetime of the access token in seconds. Read it from each response; do not hard-code a lifetime. Store both tokens against this one professional, encrypted, and never log them.
Call the API. Send the access token as a bearer token. Start with GET /v1/me: it tells you which account just connected. GET /v1/me/connection tells you whether final actions ask first.
curl https://api.matchai.co/v1/me \
-H "Authorization: Bearer $MATCHAI_ACCESS_TOKEN"Refresh when the token expires. An expired access token answers 401 with the code token_expired. Exchange the refresh token for a new access token, then repeat the request once:
curl https://api.matchai.co/oauth/token \
-d grant_type=refresh_token \
-d refresh_token="$MATCHAI_REFRESH_TOKEN" \
-d client_id="$MATCHAI_CLIENT_ID" \
-d client_secret="$MATCHAI_CLIENT_SECRET"Always keep the newest refresh token a response gives you. If the refresh fails, the connection has ended: ask the professional to connect again.
The Allow page
The Allow page is the moment the professional decides. It is designed so they never have to guess:
| What it shows | Where it comes from |
|---|---|
| Your app's name, logo and website, with a mark when Matchai has verified the publisher | Your Matchai app |
| What your app may do, each scope in plain words, such as "See your listings" or "Add and edit your contacts" | The scope you asked for |
| Whether final actions ask first | Your connection starts on Ask me. Sending, publishing, paying and deleting wait for their approval. |
| Who pays | "Included with" your product when you are a partner-paid app, a line saying the connection is added to their Matchai bill when you are user-paid, and nothing at all in test mode |
Pressing Allow creates the connection. From then on it appears on their Connectors page with the same details, and they can change how final actions are handled, or press Disconnect.
Why scopes are read as sentences
The professional is deciding whether to let a stranger's software act in their name. A list such as contacts:write messages:send asks them to translate; a sentence lets them decide. Ask for the scopes you can explain in one line each.
Example: a website builder connects a broker
Say you run a company that builds websites for brokers. Your product is called "Your website company" in Matchai, and Camille Tremblay, a broker, is one of your customers.
Camille opens her website settings in your product and presses Connect Matchai. Your server runs step 1 and 2 above and redirects her.
She is already signed in to Matchai. The Allow page names Your website company, lists what it may do (see her listings, add contacts, hear about new leads) and says who pays. She presses Allow.
She lands back in your product. Your server exchanges the code, calls GET /v1/me, and stores her tokens against her customer record. It took her a few clicks and no password was shared.
Your product reads her listings with GET /v1/listings and shows them on her new website. It subscribes to listing.published, listing.updated and lead.created with POST /v1/webhook-subscriptions, so the site stays current without polling.
A visitor fills in the contact form on her site. Your server sends it with POST /v1/enquiries. A lead and its conversation appear in Camille's Matchai contacts, and lead.created reaches your endpoint.
Months later, Camille disconnects your product from her Connectors page. Your next call answers 401, her webhook subscriptions are turned off, and your product shows "Reconnect Matchai". Nothing in her Matchai account was deleted.
Your next customer, a second broker, repeats the same flow and gets a separate set of tokens. Neither connection can see the other broker's data.
A complete example
This server does steps 1 to 5 for a signed-in customer. It uses Express. currentCustomerId and saveConnection stand for your own login and storage.
import { createHash, randomBytes } from 'node:crypto';
import cookieParser from 'cookie-parser';
import express from 'express';
// Your own code: who is signed in to your product, and where tokens are kept.
declare function currentCustomerId(request: express.Request): string;
declare function saveConnection(connection: {
customerId: string;
matchaiUserId: string;
accessToken: string;
refreshToken: string;
expiresAt: Date;
}): Promise<void>;
const clientId = process.env.MATCHAI_CLIENT_ID!;
const clientSecret = process.env.MATCHAI_CLIENT_SECRET!;
const redirectUri = 'https://app.your-website-company.example/matchai/callback';
const scopes = ['profile:read', 'listings:read', 'contacts:write', 'webhooks:manage'];
const base64url = (bytes: Buffer) => bytes.toString('base64url');
const app = express();
app.use(cookieParser());
// Steps 1 and 2: send the professional to Matchai.
app.get('/matchai/connect', (request, response) => {
currentCustomerId(request); // Only a signed-in customer can start a connection.
const state = base64url(randomBytes(24));
const verifier = base64url(randomBytes(48));
const challenge = base64url(createHash('sha256').update(verifier).digest());
// Keep both values in a cookie that only this browser sends back.
response.cookie('matchai_oauth', JSON.stringify({ state, verifier }), {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 10 * 60 * 1000,
});
const authorize = new URL('https://matchai.co/oauth/authorize');
authorize.search = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
state,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();
response.redirect(authorize.toString());
});
// Steps 4 and 5: Matchai sends the professional back here.
app.get('/matchai/callback', async (request, response) => {
const customerId = currentCustomerId(request);
const saved = JSON.parse(request.cookies.matchai_oauth ?? 'null') as
| { state: string; verifier: string }
| null;
response.clearCookie('matchai_oauth');
if (request.query.error === 'access_denied') {
return response.redirect('/settings/website?matchai=declined');
}
const { code, state } = request.query;
if (!saved || typeof code !== 'string' || state !== saved.state) {
return response.status(400).send('This connection request is not valid. Please start again.');
}
const tokenResponse = await fetch('https://api.matchai.co/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
code_verifier: saved.verifier,
}),
});
if (!tokenResponse.ok) {
return response.status(502).send('Matchai did not accept the connection. Please try again.');
}
const tokens = await tokenResponse.json();
// Learn which Matchai account just connected.
const me = await fetch('https://api.matchai.co/v1/me', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
}).then((result) => result.json());
await saveConnection({
customerId,
matchaiUserId: me.user_id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
});
response.redirect('/settings/website?matchai=connected');
});
app.listen(3000);Then every call to the API goes through one function that refreshes an expired token and notices a connection that has ended:
type Tokens = { accessToken: string; refreshToken: string; expiresAt: Date };
// Your own code: tokens are stored per customer.
declare function loadTokens(customerId: string): Promise<Tokens>;
declare function saveTokens(customerId: string, tokens: Tokens): Promise<void>;
declare function markDisconnected(customerId: string): Promise<void>;
async function refresh(refreshToken: string): Promise<Tokens | null> {
const response = await fetch('https://api.matchai.co/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.MATCHAI_CLIENT_ID!,
client_secret: process.env.MATCHAI_CLIENT_SECRET!,
}),
});
if (!response.ok) return null;
const tokens = await response.json();
return {
accessToken: tokens.access_token,
// Keep the newest refresh token you were given.
refreshToken: tokens.refresh_token ?? refreshToken,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
};
}
export async function matchaiFetch(
customerId: string,
path: string,
init: RequestInit & { headers?: Record<string, string> } = {},
): Promise<Response> {
let tokens = await loadTokens(customerId);
const call = () =>
fetch(`https://api.matchai.co/v1${path}`, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${tokens.accessToken}` },
});
const response = await call();
if (response.status !== 401) return response;
// The access token expired, or the professional disconnected. Refresh once.
const refreshed = await refresh(tokens.refreshToken);
if (!refreshed) {
await markDisconnected(customerId); // Show "Reconnect Matchai" in your product.
return response;
}
await saveTokens(customerId, refreshed);
tokens = refreshed;
return call();
}Refresh one customer's tokens at a time. If two requests refresh at once, only the first can use the refresh token it holds.
MCP clients and dynamic client registration
AI assistants that speak MCP, such as Claude and ChatGPT, do not ask you to register an app. They find the sign-in on their own and register themselves with dynamic client registration:
- The client calls a user tool on
https://matchai.co/mcpwithout a token. The server answers401with aWWW-Authenticate: Bearer resource_metadata=…header that points to the protected resource metadata. - The client reads the metadata, finds the authorization server, and reads
https://api.matchai.co/.well-known/oauth-authorization-server. - It registers itself at
https://api.matchai.co/oauth/registerand gets a client ID. - It runs the same flow as above. The professional sees the same Allow page, naming the assistant.
To add Matchai to an assistant, you only enter the server address. The MCP server guide covers the public tools, which keep working with no sign-in.
Ending a connection
- The professional disconnects. They press Disconnect on the Connectors page. Every token of the connection stops working, its webhook subscriptions are turned off (
disabled_reason: connection_ended), and nothing in their account is deleted. - Your app disconnects. Call
DELETE /v1/me/connection(disconnect) for the same effect, for example when a customer leaves your product.
Either way, delete the stored tokens and treat later 401 answers as "connect again".
Security checklist
- Check
stateon every callback, and reject a callback you did not start. - Use PKCE with
S256on every flow. Matchai refuses a flow without it. - Keep the client secret and every token on your server, encrypted at rest and out of your logs.
- Register exact redirect URLs, and never build one from user input.
- Ask for the fewest scopes that work. Needing more later means asking the professional again.
- Keep one set of tokens per professional, and never use one customer's token for another's request.