Introduction
Myuser finds leads, emails them, books meetings and shows who visits your website. If your company runs on a CRM your own team built, this page shows how to connect it, so Myuser works with it the way it works with HubSpot or Salesforce.
The connection has two parts. Set up one of them or both:
- Your endpoints. Your developers add a few small HTTPS endpoints to your CRM and give Myuser their address and an API key. Myuser calls them to look people up, add leads, log emails, replies, meetings and calls, and read and write deals.
- Webhooks. Myuser sends a signed message to your server the moment something happens: an email went out, a lead replied, a meeting was booked, a deal moved, a website visitor was identified.
Everything is JSON over HTTPS. Lists are sent whole, in one answer. There is no paging.
--{Example addresses}--
Your endpoints https://crm.yourco.com/api/myuser
Your webhook URL https://crm.yourco.com/webhooks/myuser
Both addresses are examples. You choose them and paste them into Myuser.
Quickstart
- Build two endpoints:
GET /pingandGET /contacts. That is enough to pass the connection test. AddPOST /contactsandPOST /activitiesso Myuser can add leads and log emails, then the others for the features you want. - Open the card. In the Myuser dashboard go to Integrations and press Set up on the Custom CRM card.
- Paste and test. Fill in Address of your endpoints and API key, then press Save and test. Myuser calls each endpoint once and shows which features work.
- Add webhooks (optional). Paste your Webhook URL, press Generate to make a signing secret, save the same secret on your server, tick the events you want and press Save and send test event.
- Mark as done. Your CRM becomes the default CRM of that product. The On switch pauses it at any time and keeps the settings.
--{Call your endpoint the way Myuser does}--
curl https://crm.yourco.com/api/myuser/ping \
-H "Authorization: Bearer YOUR_CRM_KEY" \
-H "Accept: application/json" \
-H "User-Agent: Myuser-CRM/1.0"
Authentication
Two secrets are involved. Each one works in one direction.
| Secret | Who makes it | What it is for |
|---|---|---|
| API key | You | Myuser sends it on every call to your endpoints, so your CRM knows the call came from Myuser. |
| Signing secret | You, or Generate in Myuser | Myuser signs every webhook with it, so your server can prove the message came from Myuser. |
Your API key
Myuser sends the API key you pasted on every call, in the Authorization header:
Authorization: Bearer YOUR_CRM_KEY
If your API expects the key in another header, like X-API-Key, type that name in Header for the key under More options. Myuser then sends X-API-Key: YOUR_CRM_KEY instead. Header names use letters, numbers and dashes.
Myuser sends the key exactly as you typed it. It is never hashed or changed, so your CRM simply compares it with the key it stores. Keys can be up to 500 characters.
Check the key on every call and answer 401 when it is wrong. Compare it in constant time, as the examples do, so nobody can guess it one character at a time. Make a key just for Myuser, so you can replace it without touching your other systems.
--{A wrong key must get a 401}--
curl -i https://crm.yourco.com/api/myuser/ping \
-H "Authorization: Bearer wrong-key"
# HTTP/1.1 401 Unauthorized
--{Check the key (Express)}--
const crypto = require('crypto');
function requireMyuserKey(req, res, next) {
const sent = Buffer.from((req.get('Authorization') || '').replace(/^Bearer /, ''));
const key = Buffer.from(process.env.MYUSER_CRM_KEY);
if (sent.length !== key.length || !crypto.timingSafeEqual(sent, key)) {
return res.status(401).json({ error: { message: 'Invalid API key' } });
}
next();
}
app.use('/api/myuser', requireMyuserKey);
--{Check the key}--
<?php
$sent = preg_replace('/^Bearer\s+/', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (!hash_equals(getenv('MYUSER_CRM_KEY'), $sent)) {
http_response_code(401);
header('Content-Type: application/json');
exit(json_encode(['error' => ['message' => 'Invalid API key']]));
}
--{Check the key (Flask)}--
import hmac, os
from flask import request, abort
@app.before_request
def require_myuser_key():
sent = request.headers.get('Authorization', '').removeprefix('Bearer ')
if not hmac.compare_digest(sent, os.environ['MYUSER_CRM_KEY']):
abort(401)
Webhook signatures
Every webhook carries a Myuser-Signature header with the time it was signed and the signature:
Myuser-Signature: t=1760968991,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
v1 is an HMAC-SHA256 (a fingerprint only someone with the secret can make) of the time, a dot and the raw request body, written as hex. To check a webhook:
- Read
tandv1from the header. - Compute the HMAC-SHA256 of
t + "." + raw bodywith your signing secret, as hex. - Compare it with
v1in constant time. - Reject the webhook if
tis more than 5 minutes away from your clock. This stops someone from sending an old webhook again.
Use the raw body exactly as it arrived. Parsing the JSON and encoding it again changes the bytes and breaks the check.
The signing secret is used whole, as plain text. A secret made with Generate starts with whsec_, and that part belongs to the secret too. Secrets you type yourself need 16 to 200 characters and no spaces.
--{Check a signature by hand}--
# T = the t= value of the Myuser-Signature header, body.json = the raw body
printf '%s.' "$T" | cat - body.json | openssl dgst -sha256 -hmac "$MYUSER_WEBHOOK_SECRET"
# the hex at the end must equal the v1= value
--{Verify a webhook (Express)}--
const crypto = require('crypto');
app.post('/webhooks/myuser', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(
(req.get('Myuser-Signature') || '').split(',').map((p) => p.split('='))
);
const expected = crypto
.createHmac('sha256', process.env.MYUSER_WEBHOOK_SECRET)
.update(parts.t + '.' + req.body)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const valid = typeof parts.v1 === 'string' && parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!fresh || !valid) return res.status(400).send('Bad signature');
const event = JSON.parse(req.body);
// handle event.type here
res.sendStatus(200);
});
--{Verify a webhook}--
<?php
$payload = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_MYUSER_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $payload, getenv('MYUSER_WEBHOOK_SECRET'));
if (abs(time() - (int)($sig['t'] ?? 0)) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400);
exit('Bad signature');
}
$event = json_decode($payload, true);
// handle $event['type'] here
http_response_code(200);
--{Verify a webhook (Flask)}--
import hashlib, hmac, json, os, time
from flask import request, abort
@app.post('/webhooks/myuser')
def myuser_webhook():
payload = request.get_data()
parts = dict(p.split('=', 1) for p in request.headers.get('Myuser-Signature', '').split(',') if '=' in p)
expected = hmac.new(os.environ['MYUSER_WEBHOOK_SECRET'].encode(),
parts.get('t', '').encode() + b'.' + payload, hashlib.sha256).hexdigest()
if abs(time.time() - int(parts.get('t') or 0)) > 300 or not hmac.compare_digest(expected, parts.get('v1', '')):
abort(400)
event = json.loads(payload)
# handle event['type'] here
return '', 200
Errors
This is how Myuser reads the answers from your endpoints.
| Your answer | What Myuser does |
|---|---|
200 to 299 | Success. Myuser reads the JSON body. |
401 or 403 | The call failed. Recent activity on the card shows your message and "Your CRM refused the API key." |
404, 405 or 501 | Myuser treats the endpoint as not built. Features that need it stay off and the test shows "Not built yet". This is not logged as a failure. On GET /contacts?email= a 404 means "nobody has that email". |
| Any other code | The call failed. Recent activity shows the code and your message. |
A redirect (3xx) | Not followed. The call fails and asks you to paste the final address. |
| No answer within 15 seconds | The call failed. Connecting may take up to 5 seconds of that. |
Myuser shows your message from the first of these it finds in your answer: error.message, error (when it is text) or message. Without one it shows the start of the body.
Myuser does not repeat a failed call right away. Deal changes from the Deals tab are tried again on the next pass until they go through, and every failure shows in Recent activity so you can fix the cause. Webhooks have their own retries for about 2 days (see Webhooks).
--{An error answer Myuser can read}--
{
"error": { "message": "Email is not valid" }
}
Address rules
Both the endpoint address and the webhook URL must:
- start with
https://and have a valid certificate; - point to a public server.
localhost, private networks and IPv6 addresses are refused; - use port 443, or a port above 1024;
- have no
user:password@part. Put the key in the API key field instead.
If the endpoint address has a query string, like ?tenant=42, Myuser keeps it on every call. Answers can be up to 20 MB.
Connect your CRM
Myuser has built-in integrations for these CRMs. Each one connects from its own card in Integrations, with no code:
- HubSpot
- Salesforce
- GoHighLevel
- Attio
- Pipedrive
- ActiveCampaign
- Close
- Zoho CRM
- Microsoft Dynamics 365
The Custom CRM integration is for every other CRM, usually one your own team built. Your developers build the endpoints on this page, and you connect them in the dashboard. Myuser then works with your CRM like it works with the built-in ones.
Set it up
- In the Myuser dashboard open Integrations and press Set up on the Custom CRM card. Each product has its own settings.
- Under 1. Your endpoints, paste the Address of your endpoints and the API key. More options holds the Name of your CRM, the Header for the key, and the Link to a contact and Link to a deal (see Record links).
- Press Save and test. Leave Also create test records ticked so the endpoints that write get tested too.
- Under 2. Webhooks, paste your Webhook URL, press Generate, copy the secret into your server, tick the events you want and press Save and send test event.
- Under 3. What Myuser adds to your CRM, pick what reaches your CRM.
- Press Mark as done, or Save for later to finish another time.
Mark as done needs a passing connection test when the endpoints are filled in, and one test event that reached your server when the webhooks are filled in. You can use only the endpoints, only the webhooks, or both. Changing the address, the key or the header asks for a new test. So does changing the webhook URL or the secret.
--{The Custom CRM card, filled in}--
Address of your endpoints https://crm.yourco.com/api/myuser
API key ****************4f2a
Header for the key (empty, so Authorization: Bearer is used)
Link to a contact https://crm.yourco.com/contacts/{id}
Link to a deal https://crm.yourco.com/deals/{id}
Webhook URL https://crm.yourco.com/webhooks/myuser
Signing secret whsec_************91c0
Status On, default CRM
The connection test
Save and test calls each endpoint once, in this order, and shows the result of each step:
| Step | What Myuser calls |
|---|---|
| Connection | GET /ping. When this fails, the test stops here. |
| Find a contact | GET /contacts?email= with a made-up email. It should find nobody. |
| Add a lead | POST /contacts with a test contact. Only with test records ticked. |
| Update a contact | PATCH /contacts/{id} changes the test contact's job title. |
| Log an activity | POST /activities logs a note on the test contact. |
| List companies | GET /companies |
| Deal stages | GET /pipelines |
| List deals | GET /deals |
| Create and update a deal | POST /deals, then PATCH /deals/{id} changes its amount. Only with test records ticked. |
| Meeting sync | GET /meetings for the last 30 days. |
The test passes when GET /ping and GET /contacts work. Endpoints that answer 404, 405 or 501 show as "Not built yet", and their features stay off. After the test the card shows which features are ready: Find contacts, Add and update leads, Log emails, replies, meetings and calls, Read companies, Deal stages, Read deals, Create and move deals, and Meeting sync.
The test records are easy to spot:
- the contact's email starts with
integration.test@myuser-test-, it is named "Myuser Test" and its company is "Myuser Test Company" plus a short code; - the deal is named "Myuser Test Deal" plus the same code;
- every test write carries
"test": truein the body.
Myuser keeps them out of the Deals tab, the do-not-contact list and Master AI answers. Delete them from your CRM whenever you like.
What to expect
- Default CRM. After Mark as done, with the endpoints filled in, your CRM becomes the product's default CRM. New leads, emails and meetings go there instead of to another connected CRM.
- On and off. The On switch at the top of the card pauses every call and every webhook and keeps the settings. Turning it back on makes your CRM the default again.
- Disconnect removes the settings. Another connected CRM becomes the default again.
- What reaches your CRM follows 3. What Myuser adds to your CRM: leads with a positive reply (on by default), leads with any reply, every lead Myuser contacts, every email, booked meetings (on by default) and the lead's company (on by default). All CRM cards on a product share these choices.
- Webhooks go out within seconds of each event.
- Meetings your team books. About every 6 hours, while a campaign ran in the last 14 days, Myuser reads
GET /meetings. When a guest is a lead Myuser contacted, the meeting counts as booked for that lead. Myuser never sends these meetings back to your CRM. - Deals. The Deals tab can sync with your CRM in both directions. See Sync deals.
- Recent activity at the bottom of the card lists the last 40 failed calls and webhook deliveries, newest first, with your server's answer.
- Block my CRM from outreach. On the card, tick the companies, the people or both, and press Block the ticked ones. Myuser reads
GET /companiesandGET /contactsand copies them into the product's do-not-contact list, so campaigns never reach your customers. Nothing is blocked without a tick, people and companies Myuser added itself are skipped, and Unblock everything this sync added undoes it. - Master AI, the assistant in Myuser, can look people, deals and meetings up in your CRM, add or update contacts, log notes, and create or move deals, through the same endpoints.
Endpoints
These are the endpoints you build. Myuser adds each path to the address you pasted, so with https://crm.yourco.com/api/myuser the contact lookup goes to https://crm.yourco.com/api/myuser/contacts?email=....
| Endpoint | What it is for |
|---|---|
GET /ping | The connection test. Required. |
GET /contacts | Finding people by email, blocking your contacts, filling in missing job titles. Required. |
POST /contacts | Adding and updating leads. |
PATCH /contacts/{id} | Changing one contact. Optional: Myuser uses POST /contacts without it. |
GET /companies | Blocking your companies. |
POST /companies | Adding a company without a person. |
POST /activities | Logging emails, replies, meetings, calls and notes. |
GET /meetings | Meeting sync. |
GET /pipelines | Deal stages. See Sync deals. |
GET /deals | Reading deals. |
POST /deals | Creating deals. |
PATCH /deals/{id} | Moving and updating deals. |
--{Headers on every call from Myuser}--
Authorization: Bearer YOUR_CRM_KEY
Accept: application/json
Content-Type: application/json (calls with a body)
User-Agent: Myuser-CRM/1.0
Answer with JSON. A record or a list can come on its own or wrapped in data, like {"data": [...]} or {"data": {...}}. The examples use data.
Myuser sends times as ISO 8601 in UTC, like 2026-10-20T14:03:11Z. Your times can be ISO 8601, unix seconds or unix milliseconds.
Lists are sent whole: answer every matching record in one response. There is no paging.
The contact object
A person in your CRM. Myuser matches people by email, so every contact needs one.
| Attribute | Type | Description |
|---|---|---|
| id | string | Required. Your id for the contact. |
| string | Required. Main email address. | |
| first_name | string | First name. |
| last_name | string | Last name. |
| title | string | Job title. |
| phone | string | Phone number. |
| linkedin_url | string | LinkedIn profile link. |
| country | string | Country. |
| company | object | { "id", "name", "domain" }. The domain without www, like acme.com. A plain company name works too. |
| created_at | timestamp | When the contact was added. |
| url | string | Link to the contact in your CRM. Not needed when Link to a contact is set. |
| test | boolean | Keep the test flag Myuser sends with test records and send it back, so Myuser can skip them. |
--{The contact object}--
{
"id": "c_1042",
"email": "dana@acme.com",
"first_name": "Dana",
"last_name": "Reyes",
"title": "VP Sales",
"phone": "+1 415 555 0134",
"linkedin_url": "https://www.linkedin.com/in/example",
"country": "United States",
"company": { "id": "co_77", "name": "Acme Inc", "domain": "acme.com" },
"created_at": "2026-03-02T17:21:09Z",
"url": "https://crm.yourco.com/contacts/c_1042"
}
Test the connection
GET /ping
Called by the connection test and when Master AI checks the connection. Any 2xx answer passes.
| Attribute | Type | Description |
|---|---|---|
| ok | boolean | true. |
| name | string | Your CRM's name. Shown in the test result, and used as the card's name when you left Name of your CRM empty. |
--{GET /ping}--
GET /api/myuser/ping HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
--{Your answer}--
{
"ok": true,
"name": "YourCo CRM"
}
Find a contact
GET /contacts?email=
Myuser looks a person up by email, for example to link a lead to their record in your CRM, or when Master AI is asked about someone.
| Parameter | Type | Description |
|---|---|---|
| string | Required. The email to find, always in lowercase. |
Return the matching contacts, or an empty list when nobody has that email. Myuser also checks the emails in your answer itself, so a server that ignores email still works, only slower. A 404 also means "not found".
--{GET /contacts?email=dana@acme.com}--
GET /api/myuser/contacts?email=dana%40acme.com HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
--{Your answer}--
{
"data": [
{
"id": "c_1042",
"email": "dana@acme.com",
"first_name": "Dana",
"last_name": "Reyes",
"title": "VP Sales",
"company": { "id": "co_77", "name": "Acme Inc", "domain": "acme.com" }
}
]
}
List contacts
GET /contacts
Without email, return every contact. Myuser reads the full list when you block the people in your CRM with Block the ticked ones. It uses id, email, first_name, last_name, company, created_at and test.
| Parameter | Type | Description |
|---|---|---|
| missing | string | title: only contacts without a job title. Master AI uses it to fill in missing titles. If you ignore it, Myuser filters the list itself. |
--{GET /contacts}--
GET /api/myuser/contacts HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
--{Your answer}--
{
"data": [
{
"id": "c_1042",
"email": "dana@acme.com",
"first_name": "Dana",
"last_name": "Reyes",
"company": { "name": "Acme Inc", "domain": "acme.com" },
"created_at": "2026-03-02T17:21:09Z"
},
{
"id": "c_1043",
"email": "lee@globex.com",
"first_name": "Lee",
"last_name": "Park",
"company": { "name": "Globex", "domain": "globex.com" },
"created_at": "2026-05-14T09:02:40Z"
}
]
}
Create or update a contact
POST /contacts
Myuser sends a lead here when it should be in your CRM, as chosen under 3. What Myuser adds to your CRM: for example when the lead replies positively or books a meeting. Master AI uses it too.
Match by email. Update the contact when it exists and create it when it doesn't. Fields Myuser doesn't know are left out.
| Parameter | Type | Description |
|---|---|---|
| string | Required. The lead's email, in lowercase. | |
| first_name | string | First name. |
| last_name | string | Last name. |
| title | string | Job title. |
| phone | string | Phone number. |
| linkedin_url | string | LinkedIn profile link. |
| country | string | Country. |
| company | object | { "name", "domain", "linkedin_url", "employees", "annual_revenue", "custom" }, with the parts Myuser knows. |
| create_company | boolean | false when Create the company too is off. Then link the contact to a company you already have, but don't create one. |
| custom | object | Other details Myuser has for the lead, by name. Safe to ignore. |
| source | string | Always myuser. |
| test | boolean | true only for the test contact. |
Answer with the contact's id. Without it Myuser can't link the lead to the record.
| Attribute | Type | Description |
|---|---|---|
| created | boolean | true when the contact is new, false when it already existed. |
| data.id | string | Required. Your contact id. |
| data.url | string | Link to the contact. Not needed when Link to a contact is set. |
| data.company.id | string | The id of the contact's company in your CRM. |
--{POST /contacts}--
POST /api/myuser/contacts HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
Content-Type: application/json
{
"email": "dana@acme.com",
"first_name": "Dana",
"last_name": "Reyes",
"title": "VP Sales",
"linkedin_url": "https://www.linkedin.com/in/example",
"country": "United States",
"company": { "name": "Acme Inc", "domain": "acme.com", "employees": 240 },
"create_company": true,
"source": "myuser"
}
--{Your answer}--
{
"created": true,
"data": {
"id": "c_1042",
"url": "https://crm.yourco.com/contacts/c_1042",
"company": { "id": "co_77" }
}
}
Update a contact
PATCH /contacts/{id}
Master AI uses this to change a contact it found, like a missing job title. The connection test uses it to change the test contact's title. Without this endpoint Myuser uses POST /contacts instead.
Myuser sends email, source and the fields that change: first_name, last_name, title, phone, linkedin_url, country or company ({ "name" }).
--{PATCH /contacts/c_1042}--
PATCH /api/myuser/contacts/c_1042 HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
Content-Type: application/json
{ "email": "dana@acme.com", "title": "VP Sales", "source": "myuser" }
--{Your answer}--
{
"data": { "id": "c_1042" }
}
List companies
GET /companies
Return every company. Myuser reads the list when you block the companies in your CRM with Block the ticked ones, so a whole company is skipped and not only the people you have. It uses id, name, domain, created_at and test.
--{GET /companies}--
GET /api/myuser/companies HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
--{Your answer}--
{
"data": [
{ "id": "co_77", "name": "Acme Inc", "domain": "acme.com", "created_at": "2026-03-02T17:21:09Z" },
{ "id": "co_78", "name": "Globex", "domain": "globex.com", "created_at": "2026-05-14T09:02:40Z" }
]
}
Add a company
POST /companies
Myuser adds a company without a person when a website visitor rule sends companies to your CRM. Match by domain, then by name, and create the company when it is new.
| Parameter | Type | Description |
|---|---|---|
| name | string | Company name. |
| domain | string | Website domain without www, like acme.com. |
| linkedin_url | string | LinkedIn company page. |
| employees | number or string | Number of employees, or a range like 51-200. |
| annual_revenue | number or string | Yearly revenue. |
| custom | object | Other details Myuser has, by name. Safe to ignore. |
| source | string | Always myuser. |
--{POST /companies}--
POST /api/myuser/companies HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
Content-Type: application/json
{ "name": "Acme Inc", "domain": "acme.com", "employees": "201-500", "source": "myuser" }
--{Your answer}--
{
"created": true,
"data": { "id": "co_77" }
}
Log an activity
POST /activities
Myuser logs what happened with a lead: emails, replies, meetings, calls and notes. Every activity carries the person, so find the contact by contact_email, create it from contact when it is missing, and attach the activity.
| type | When Myuser sends it |
|---|---|
email_sent | Every email Myuser sends, when Log every email is on. |
email_reply | A lead replied. |
meeting | A meeting was booked with a lead. |
call | A call with a lead was logged. |
note | A note from Master AI, or the connection test. |
| Parameter | Type | Description |
|---|---|---|
| type | string | Required. email_sent, email_reply, meeting, call or note. |
| subject | string | Email subject, meeting title or call title. |
| body | string | The text. Up to 30,000 characters. |
| direction | string | outbound, or inbound for replies and incoming calls. |
| occurred_at | timestamp | When it happened. |
| start_at | timestamp | Meetings only: when the meeting starts. |
| end_at | timestamp | Meetings only: when it ends. |
| campaign | object | Meetings only: { "name" } of the Myuser campaign. |
| duration_seconds | integer | Calls only. |
| phone | string | Calls only, when known. |
| contact_email | string | Required. The lead's email, in lowercase. |
| contact | object | { "email", "first_name", "last_name", "title", "linkedin_url", "country", "company": { "name" } }, with the parts Myuser knows. |
| source | string | Always myuser. |
| external_id | string | The same activity always gets the same id. Use it to skip duplicates. |
| test | boolean | true only for the test note. |
| Attribute | Type | Description |
|---|---|---|
| data.id | string | Your activity id. |
| data.contact_id | string | The contact's id. Myuser adds it as crm_id to later webhooks about this lead. |
--{POST /activities}--
POST /api/myuser/activities HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
Content-Type: application/json
{
"type": "email_sent",
"subject": "Quick question about Acme's outbound",
"body": "Hi Dana,\n\nSaw that Acme is hiring two SDRs...",
"direction": "outbound",
"occurred_at": "2026-10-20T14:03:11Z",
"contact_email": "dana@acme.com",
"contact": {
"email": "dana@acme.com",
"first_name": "Dana",
"last_name": "Reyes",
"title": "VP Sales",
"company": { "name": "Acme Inc" }
},
"source": "myuser",
"external_id": "act_3f9c1e0a4b7d2e6f8a1c5b9d"
}
--{Your answer}--
{
"data": { "id": "act_88213", "contact_id": "c_1042" }
}
List meetings
GET /meetings?since=
Meeting sync. About every 6 hours Myuser asks for the meetings since its last look, so meetings your team booked outside Myuser are counted too.
| Parameter | Type | Description |
|---|---|---|
| since | timestamp | Required. ISO 8601 in UTC. Return the meetings booked or changed since then. Returning more is fine: Myuser skips meetings it already has. |
| Attribute | Type | Description |
|---|---|---|
| id | string | Required. Your id for the meeting. |
| title | string | Meeting title. |
| start_at | timestamp | When it starts. |
| end_at | timestamp | When it ends. |
| status | string | cancelled, canceled or declined mark a cancelled meeting. Anything else counts as booked. |
| host_email | string | Email of your team member who owns the meeting. Guests with the same email domain count as your team. |
| attendees | array | [{ "email", "name" }], or plain email addresses. |
| contact_id | string | Your contact id, used for the link to the record. |
When a guest is a lead Myuser contacted, matched by email and then by company domain, the meeting counts as booked for that lead.
--{GET /meetings?since=...}--
GET /api/myuser/meetings?since=2026-10-18T00%3A00%3A00Z HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
--{Your answer}--
{
"data": [
{
"id": "m_5512",
"title": "Acme x YourCo intro",
"start_at": "2026-10-22T16:00:00Z",
"end_at": "2026-10-22T16:30:00Z",
"status": "confirmed",
"host_email": "sam@yourco.com",
"attendees": [
{ "email": "dana@acme.com", "name": "Dana Reyes" },
{ "email": "sam@yourco.com", "name": "Sam Lee" }
],
"contact_id": "c_1042"
}
]
}
Record links
Myuser links leads and deals to their record in your CRM. Set the link patterns on the card under More options:
--{Record links}--
Link to a contact https://crm.yourco.com/contacts/{id}
Link to a deal https://crm.yourco.com/deals/{id}
Myuser puts the record's id where {id} is. Without a pattern it uses the url field from your answers, when there is one.
Webhooks
Myuser sends a POST to your Webhook URL within seconds of each event you ticked on the card. Every event is signed with your signing secret (see Webhook signatures).
Answer with any 2xx code within 10 seconds. The body of your answer is ignored, so do slow work after you answer.
When a delivery fails (no answer in time, a redirect, or any other code), Myuser tries again 7 times: after 1 minute, then 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. That is about 2 days in total. Every try carries the same event id in Myuser-Event-Id, and Myuser-Attempt counts the tries. Use the id to skip events you already handled. Events can arrive out of order.
Every delivery, and every failed try, shows in Recent activity on the card. The test event from the card is never retried. While the On switch is off, no events are sent.
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | Myuser-Webhooks/1.0 |
| Myuser-Event | The event type, like reply.received. |
| Myuser-Event-Id | The event id, the same on every try. |
| Myuser-Attempt | 1 on the first try, 2 on the first retry, and so on. |
| Myuser-Signature | t=<unix time>,v1=<signature>. See Webhook signatures. |
The event object
| Attribute | Type | Description |
|---|---|---|
| id | string | Unique id of the event, like evt_9b1f0c7e2a4d6f8b0c1e3a5d. |
| type | string | The event type, like meeting.booked. |
| created_at | timestamp | When the event happened. |
| test | boolean | true for the test event from the card. |
| data | object | What happened. It differs per type. |
Most events carry a lead, the person Myuser contacted, and the campaign that reached them:
| Attribute | Type | Description |
|---|---|---|
| lead.id | string | Myuser's id for the lead. |
| lead.email | string | Email, in lowercase. |
| lead.first_name, lead.last_name | string | Name. |
| lead.title | string | Job title. |
| lead.phone | string | Phone number. |
| lead.linkedin_url | string | LinkedIn profile link. |
| lead.country | string | Country. |
| lead.company | object | { "name", "domain" }. |
| lead.crm_id | string | Your contact id when Myuser knows it, otherwise null. |
| campaign | object | { "id", "name" } of the Myuser campaign, or null. |
--{The test event}--
{
"id": "evt_9b1f0c7e2a4d6f8b0c1e3a5d",
"type": "ping",
"created_at": "2026-10-20T14:03:11Z",
"test": true,
"data": {
"message": "Test event from Myuser. Your webhook works when this arrives with a valid signature.",
"product": { "id": "dprod_8Hc2kq0LmZ4", "name": "YourCo outbound" }
}
}
Event types
Tick the events you want on the card. All are ticked when you start.
| type | When Myuser sends it |
|---|---|
email.sent | Myuser sent an email to a lead. |
linkedin.sent | Myuser sent a LinkedIn invite, InMail or message. |
reply.received | A lead replied by email or on LinkedIn, with the text. |
reply.positive | Myuser read a reply as positive. |
reply.negative | Myuser read a reply as negative. |
meeting.booked | A meeting was booked with a lead. |
call.logged | A call with a lead was logged. |
deal.created | A deal was added to the Deals tab. See Deal events. |
deal.stage_changed | A deal moved to another stage. See Deal events. |
visitor.identified | A website visitor was identified as a person. See Visitor events. |
visitor.company_identified | A company was identified visiting your website. |
visitor.lead_visited | A lead Myuser contacted came back to your website. |
visitor.added_to_campaign | A website visitor was added to a campaign. |
ping | The test event from the card. |
Email sent
email.sent comes once for every email Myuser sends. email.is_first is true on the first email to the lead, and email.step counts the emails in the thread. A follow-up without its own subject gets "Re:" and the first subject.
--{email.sent}--
{
"id": "evt_4c2e8a1f9b3d5e7a0c6b2d4f",
"type": "email.sent",
"created_at": "2026-10-20T14:03:12Z",
"test": false,
"data": {
"lead": {
"id": "2015190",
"email": "dana@acme.com",
"first_name": "Dana",
"last_name": "Reyes",
"title": "VP Sales",
"phone": "",
"linkedin_url": "https://www.linkedin.com/in/example",
"country": "United States",
"company": { "name": "Acme Inc", "domain": "acme.com" },
"crm_id": "c_1042"
},
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"email": {
"id": "em88412",
"is_first": true,
"step": 1,
"from": "sam@yourco.com",
"to": "dana@acme.com",
"subject": "Quick question about Acme's outbound",
"text": "Hi Dana,\n\nSaw that Acme is hiring two SDRs...",
"sent_at": "2026-10-20T14:03:11Z"
}
}
}
LinkedIn message sent
linkedin.sent carries lead, campaign and a message: kind is invite, inmail or message, plus text and sent_at.
--{linkedin.sent (data)}--
{
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"message": {
"kind": "invite",
"text": "Hi Dana, I work with sales teams like yours...",
"sent_at": "2026-10-20T15:10:00Z"
}
}
Reply received
reply.received comes when a lead replies. reply.channel is email or linkedin, and reply.text is the reply itself.
--{reply.received (data)}--
{
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"reply": {
"channel": "email",
"subject": "Re: Quick question about Acme's outbound",
"text": "Sounds interesting. Can you do Thursday at 10?",
"received_at": "2026-10-21T09:12:40Z"
}
}
Positive or negative reply
reply.positive and reply.negative come after Myuser reads a reply's meaning. They carry the same reply with a sentiment of positive or negative. A reply can come as reply.received first and as reply.positive a little later.
--{reply.positive (data)}--
{
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"reply": {
"channel": "email",
"subject": "Re: Quick question about Acme's outbound",
"text": "Sounds interesting. Can you do Thursday at 10?",
"sentiment": "positive"
}
}
Meeting booked
meeting.booked comes once per meeting. meeting.source says where it was booked: myuser, calendly, cal_com, or the CRM that reported it, like hubspot. Meetings Myuser read from your own CRM through GET /meetings are not sent back to you.
--{meeting.booked (data)}--
{
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"meeting": {
"title": "Acme x YourCo intro",
"description": "Booked from the reply on Oct 21.",
"start_at": "2026-10-23T10:00:00Z",
"end_at": "2026-10-23T10:30:00Z",
"source": "myuser"
}
}
Call logged
call.logged comes when a call with a lead is logged in Myuser.
--{call.logged (data)}--
{
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"call": {
"direction": "outbound",
"duration_seconds": 412,
"phone": "+1 415 555 0134",
"title": "Discovery call",
"notes": "Wants pricing for 5 seats.",
"at": "2026-10-24T17:00:00Z"
}
}
Sync deals
The Deals tab in Myuser is a board of your deals by stage. It can sync with your CRM, the same way it syncs with HubSpot or Attio.
To turn it on, connect your CRM first (Mark as done with the endpoints filled in). Then open the settings of the Deals tab and pick a direction under CRM sync for deals:
- 2-way: changes on the board go to your CRM, and changes in your CRM come back.
- One-way: CRM to Myuser: your CRM leads and Myuser follows. Changes come in, and Myuser never writes to your CRM.
- Off: the board lives only in Myuser.
Tick Listen to deals created outside Myuser to bring deals that start in your CRM onto the board.
The sync uses GET /pipelines, GET /deals, POST /deals and PATCH /deals/{id}. One-way sync only needs the two reads.
How it works:
- Stages go by name. Myuser matches your stage names to the board's stages, and you can set the exact names in the Deals settings. A stage name with "won" or "lost" in it counts as won or lost. Myuser never creates or changes stages in your CRM. When your CRM has no stage with the name Myuser wants, the deal is written without a stage.
- Board to your CRM. A few minutes after a change on the board, Myuser writes it with
POST /dealsorPATCH /deals/{id}. Before it creates a deal, it looks for one with the same name (GET /deals?name=) and updates that one instead of making a copy. - Your CRM to the board. About every 30 minutes Myuser reads
GET /dealsand looks at the 200 most recently changed deals. A stage change in your CRM moves the deal on the board, and new names and amounts come across too. - The first look is only a starting point. The first time Myuser sees a deal's stage, it just remembers it. Only a later change counts as a move, so an old value in your CRM never drags a deal back.
- No echo. A change that came from your CRM is never written back to it, and never sent back as a webhook.
- The test deal ("Myuser Test Deal", with
"test": true) never shows on the board.
The deal object
What your GET /deals returns for each deal.
| Attribute | Type | Description |
|---|---|---|
| id | string | Required. Your id for the deal. |
| name | string | Deal name. |
| amount | number | Value in your currency, like 12000 or 12000.50. |
| stage | string or object | The stage name, like "Proposal", or { "id", "name" }. "Sales pipeline / Proposal" works too. |
| status | string | open, won or lost. When it is missing, Myuser reads it from the stage name, or from closed_at. |
| contact_email | string | The main contact's email. Or contact_emails, a list of emails. |
| company | object | { "name", "domain" }. |
| created_at | timestamp | When the deal was created. |
| updated_at | timestamp | When it last changed. Myuser uses it to find the newest changes first. |
| closed_at | timestamp | When it was won or lost. |
| url | string | Link to the deal. Not needed when Link to a deal is set. |
| test | boolean | Keep the test flag Myuser sends and send it back, so the test deal is skipped. |
--{The deal object}--
{
"id": "d_311",
"name": "Acme Inc - 5 seats",
"amount": 12000,
"stage": { "id": "st_proposal", "name": "Proposal" },
"status": "open",
"contact_email": "dana@acme.com",
"company": { "name": "Acme Inc", "domain": "acme.com" },
"created_at": "2026-10-23T11:00:00Z",
"updated_at": "2026-10-25T08:30:00Z",
"closed_at": null,
"url": "https://crm.yourco.com/deals/d_311"
}
List pipelines
GET /pipelines
Return your pipelines and their stages. Myuser maps stages by name and checks that a stage exists before it writes it.
| Attribute | Type | Description |
|---|---|---|
| id | string | Your id for the pipeline. |
| name | string | Pipeline name. |
| stages | array | [{ "id", "name", "probability", "is_closed" }]. probability is the chance to win from 0 to 100, and is_closed marks won and lost stages. A stage can also be just its name. |
--{Your answer}--
{
"data": [
{
"id": "p_main",
"name": "Sales pipeline",
"stages": [
{ "id": "st_new", "name": "New", "probability": 10, "is_closed": false },
{ "id": "st_proposal", "name": "Proposal", "probability": 50, "is_closed": false },
{ "id": "st_won", "name": "Won", "probability": 100, "is_closed": true },
{ "id": "st_lost", "name": "Lost", "probability": 0, "is_closed": true }
]
}
]
}
List deals
GET /deals
Return every deal, using the deal object above. Myuser may add these filters. It also filters your answer itself, so ignoring them is fine.
| Parameter | Type | Description |
|---|---|---|
| status | string | open, won or lost. |
| contact_email | string | Only deals with this contact. |
| name | string | Only deals with exactly this name. Used before creating a deal, to avoid copies. |
--{Your answer}--
{
"data": [
{
"id": "d_311",
"name": "Acme Inc - 5 seats",
"amount": 12000,
"stage": "Proposal",
"contact_email": "dana@acme.com",
"company": { "name": "Acme Inc", "domain": "acme.com" },
"updated_at": "2026-10-25T08:30:00Z"
}
]
}
Create a deal
POST /deals
Myuser creates a deal when one is added on the board, or when Master AI creates one. Link it to the contact with contact_email.
| Parameter | Type | Description |
|---|---|---|
| name | string | Required. Deal name. |
| amount | number | Value, when known. |
| stage | string | A stage name from GET /pipelines. Left out when your CRM has no matching stage. |
| contact_email | string | The contact's email, in lowercase. |
| company | object | { "name", "domain" }. Sent by Master AI when it knows the company. |
| source | string | Always myuser. |
| test | boolean | true only for the test deal. |
| Attribute | Type | Description |
|---|---|---|
| data.id | string | Required. Your deal id. Myuser uses it for every later change. |
| data.url | string | Link to the deal. Not needed when Link to a deal is set. |
--{POST /deals}--
POST /api/myuser/deals HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
Content-Type: application/json
{
"name": "Acme Inc - 5 seats",
"amount": 12000,
"stage": "Proposal",
"contact_email": "dana@acme.com",
"source": "myuser"
}
--{Your answer}--
{
"data": { "id": "d_311", "url": "https://crm.yourco.com/deals/d_311" }
}
Update a deal
PATCH /deals/{id}
Myuser moves and updates deals here. It sends the deal's name, and its amount and stage when it has them. Answer with any 2xx code.
--{PATCH /deals/d_311}--
PATCH /api/myuser/deals/d_311 HTTP/1.1
Host: crm.yourco.com
Authorization: Bearer YOUR_CRM_KEY
Content-Type: application/json
{ "name": "Acme Inc - 5 seats", "amount": 12000, "stage": "Won" }
--{Your answer}--
{
"data": { "id": "d_311" }
}
Deal events
Two webhooks follow the Deals tab, whether the deal sync is on or not:
deal.createdcomes when a deal is added to the board. Deals that came from your CRM don't send it.deal.stage_changedcomes when a deal moves to another stage. Moves that came from your CRM don't send it. It addsprevious_stage,changed_byandreason.
changed_by is user (someone on your team), master_ai, crm (another connected CRM) or myuser (an automatic rule). stage.key is the board's id for the stage and stage.label its name. deal.source says how the deal started, like manual or booking_sync (from a booked meeting). deal.crm_deal_id is your deal id when the deal is linked to your CRM.
--{deal.stage_changed}--
{
"id": "evt_7d1a3c5e9f0b2d4a6c8e1f3b",
"type": "deal.stage_changed",
"created_at": "2026-10-28T10:15:00Z",
"test": false,
"data": {
"deal": {
"id": "8812",
"title": "Acme Inc - 5 seats",
"amount": 12000,
"stage": { "key": "closed_won", "label": "Won" },
"status": "won",
"pipeline": "main",
"company": { "name": "Acme Inc", "domain": "acme.com" },
"source": "booking_sync",
"crm_deal_id": "d_311",
"created_at": "2026-10-23T11:00:00Z"
},
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"previous_stage": { "key": "proposal", "label": "Proposal" },
"changed_by": "user",
"reason": ""
}
}
Visitor events
With the Myuser tracking script on your website, Myuser shows who visits: leads you already emailed the moment they come back, and new visitors as people (in the US) and companies (worldwide). Set it up on the Website visitors page of the dashboard.
Four webhooks tell your server about visits as they happen. Tick them under Website visitors in the 2. Webhooks section of the Custom CRM card. visitor.identified and visitor.company_identified come at most once per visit for the same person or company, and visitor.added_to_campaign once per lead.
Most of them carry a visit:
| Attribute | Type | Description |
|---|---|---|
| visit.country | string | The visitor's country. |
| visit.pages | array | Pages seen in this visit, up to 30. |
| visit.clicks | array | Buttons and links clicked, up to 10. |
| visit.intents | array | Ids of the intent tags this visit matched. You set the tags up on the Website visitors page. |
| visit.key_page_seen | boolean | true when the visitor saw one of your key pages, like pricing. |
| visit.active_seconds | integer | Seconds the visitor was active on the site. |
Visitor identified
visitor.identified comes when a visitor is identified as a person. score says how sure Myuser is of the match, or is null.
--{visitor.identified (data)}--
{
"person": {
"name": "Dana Reyes",
"email": "dana@acme.com",
"title": "VP Sales",
"linkedin_url": "https://www.linkedin.com/in/example"
},
"company": { "name": "Acme Inc", "domain": "acme.com", "industry": "Software", "employees": "201-500" },
"visit": {
"country": "US",
"pages": ["/", "/pricing", "/customers"],
"clicks": ["Book a demo"],
"intents": ["12"],
"key_page_seen": true,
"active_seconds": 184
},
"score": 0.92
}
Company identified
visitor.company_identified comes when the company behind a visit is identified, even when the person isn't.
--{visitor.company_identified (data)}--
{
"company": {
"name": "Globex",
"domain": "globex.com",
"industry": "Manufacturing",
"employees": "1001-5000",
"linkedin_url": "https://www.linkedin.com/company/example"
},
"visit": {
"country": "DE",
"pages": ["/", "/integrations"],
"clicks": [],
"intents": [],
"key_page_seen": false,
"active_seconds": 47
}
}
Lead visited
visitor.lead_visited comes when a lead Myuser contacted comes back to your website. visit.matched_by says how Myuser knew: click_token (they clicked a link in a Myuser email), identify (they were identified as that person) or company_domain (someone from their company visited).
--{visitor.lead_visited (data)}--
{
"lead": { "id": "2015190", "email": "dana@acme.com", "first_name": "Dana", "last_name": "Reyes", "crm_id": "c_1042" },
"campaign": { "id": "dcamp_Q4outbound7", "name": "Q4 outbound" },
"visit": { "page": "/pricing", "matched_by": "click_token" }
}
Added to a campaign
visitor.added_to_campaign comes when a website visitor is added to one of your Myuser campaigns. lead_id is the new lead's id in Myuser.
--{visitor.added_to_campaign (data)}--
{
"person": { "name": "Lee Park", "email": "lee@globex.com", "title": "Head of Operations", "linkedin_url": "" },
"company": { "name": "Globex", "domain": "globex.com" },
"campaign": { "id": "dcamp_SiteVisitors2", "name": "Website visitors" },
"lead_id": "2016044",
"score": 0.88
}