NAV Navbar
curl Node.js PHP Python

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:

  1. 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.
  2. 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

  1. Build two endpoints: GET /ping and GET /contacts. That is enough to pass the connection test. Add POST /contacts and POST /activities so Myuser can add leads and log emails, then the others for the features you want.
  2. Open the card. In the Myuser dashboard go to Integrations and press Set up on the Custom CRM card.
  3. 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.
  4. 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.
  5. 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.

SecretWho makes itWhat it is for
API keyYouMyuser sends it on every call to your endpoints, so your CRM knows the call came from Myuser.
Signing secretYou, or Generate in MyuserMyuser 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:

  1. Read t and v1 from the header.
  2. Compute the HMAC-SHA256 of t + "." + raw body with your signing secret, as hex.
  3. Compare it with v1 in constant time.
  4. Reject the webhook if t is 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 answerWhat Myuser does
200 to 299Success. Myuser reads the JSON body.
401 or 403The call failed. Recent activity on the card shows your message and "Your CRM refused the API key."
404, 405 or 501Myuser 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 codeThe 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 secondsThe 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:

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:

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

  1. In the Myuser dashboard open Integrations and press Set up on the Custom CRM card. Each product has its own settings.
  2. 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).
  3. Press Save and test. Leave Also create test records ticked so the endpoints that write get tested too.
  4. 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.
  5. Under 3. What Myuser adds to your CRM, pick what reaches your CRM.
  6. 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:

StepWhat Myuser calls
ConnectionGET /ping. When this fails, the test stops here.
Find a contactGET /contacts?email= with a made-up email. It should find nobody.
Add a leadPOST /contacts with a test contact. Only with test records ticked.
Update a contactPATCH /contacts/{id} changes the test contact's job title.
Log an activityPOST /activities logs a note on the test contact.
List companiesGET /companies
Deal stagesGET /pipelines
List dealsGET /deals
Create and update a dealPOST /deals, then PATCH /deals/{id} changes its amount. Only with test records ticked.
Meeting syncGET /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:

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

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=....

EndpointWhat it is for
GET /pingThe connection test. Required.
GET /contactsFinding people by email, blocking your contacts, filling in missing job titles. Required.
POST /contactsAdding and updating leads.
PATCH /contacts/{id}Changing one contact. Optional: Myuser uses POST /contacts without it.
GET /companiesBlocking your companies.
POST /companiesAdding a company without a person.
POST /activitiesLogging emails, replies, meetings, calls and notes.
GET /meetingsMeeting sync.
GET /pipelinesDeal stages. See Sync deals.
GET /dealsReading deals.
POST /dealsCreating 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.

AttributeTypeDescription
idstringRequired. Your id for the contact.
emailstringRequired. Main email address.
first_namestringFirst name.
last_namestringLast name.
titlestringJob title.
phonestringPhone number.
linkedin_urlstringLinkedIn profile link.
countrystringCountry.
companyobject{ "id", "name", "domain" }. The domain without www, like acme.com. A plain company name works too.
created_attimestampWhen the contact was added.
urlstringLink to the contact in your CRM. Not needed when Link to a contact is set.
testbooleanKeep 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.

AttributeTypeDescription
okbooleantrue.
namestringYour 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.

ParameterTypeDescription
emailstringRequired. 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.

ParameterTypeDescription
missingstringtitle: 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.

ParameterTypeDescription
emailstringRequired. The lead's email, in lowercase.
first_namestringFirst name.
last_namestringLast name.
titlestringJob title.
phonestringPhone number.
linkedin_urlstringLinkedIn profile link.
countrystringCountry.
companyobject{ "name", "domain", "linkedin_url", "employees", "annual_revenue", "custom" }, with the parts Myuser knows.
create_companybooleanfalse when Create the company too is off. Then link the contact to a company you already have, but don't create one.
customobjectOther details Myuser has for the lead, by name. Safe to ignore.
sourcestringAlways myuser.
testbooleantrue only for the test contact.

Answer with the contact's id. Without it Myuser can't link the lead to the record.

AttributeTypeDescription
createdbooleantrue when the contact is new, false when it already existed.
data.idstringRequired. Your contact id.
data.urlstringLink to the contact. Not needed when Link to a contact is set.
data.company.idstringThe 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.

ParameterTypeDescription
namestringCompany name.
domainstringWebsite domain without www, like acme.com.
linkedin_urlstringLinkedIn company page.
employeesnumber or stringNumber of employees, or a range like 51-200.
annual_revenuenumber or stringYearly revenue.
customobjectOther details Myuser has, by name. Safe to ignore.
sourcestringAlways 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.

typeWhen Myuser sends it
email_sentEvery email Myuser sends, when Log every email is on.
email_replyA lead replied.
meetingA meeting was booked with a lead.
callA call with a lead was logged.
noteA note from Master AI, or the connection test.
ParameterTypeDescription
typestringRequired. email_sent, email_reply, meeting, call or note.
subjectstringEmail subject, meeting title or call title.
bodystringThe text. Up to 30,000 characters.
directionstringoutbound, or inbound for replies and incoming calls.
occurred_attimestampWhen it happened.
start_attimestampMeetings only: when the meeting starts.
end_attimestampMeetings only: when it ends.
campaignobjectMeetings only: { "name" } of the Myuser campaign.
duration_secondsintegerCalls only.
phonestringCalls only, when known.
contact_emailstringRequired. The lead's email, in lowercase.
contactobject{ "email", "first_name", "last_name", "title", "linkedin_url", "country", "company": { "name" } }, with the parts Myuser knows.
sourcestringAlways myuser.
external_idstringThe same activity always gets the same id. Use it to skip duplicates.
testbooleantrue only for the test note.
AttributeTypeDescription
data.idstringYour activity id.
data.contact_idstringThe 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.

ParameterTypeDescription
sincetimestampRequired. ISO 8601 in UTC. Return the meetings booked or changed since then. Returning more is fine: Myuser skips meetings it already has.
AttributeTypeDescription
idstringRequired. Your id for the meeting.
titlestringMeeting title.
start_attimestampWhen it starts.
end_attimestampWhen it ends.
statusstringcancelled, canceled or declined mark a cancelled meeting. Anything else counts as booked.
host_emailstringEmail of your team member who owns the meeting. Guests with the same email domain count as your team.
attendeesarray[{ "email", "name" }], or plain email addresses.
contact_idstringYour 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"
    }
  ]
}

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.

HeaderValue
Content-Typeapplication/json
User-AgentMyuser-Webhooks/1.0
Myuser-EventThe event type, like reply.received.
Myuser-Event-IdThe event id, the same on every try.
Myuser-Attempt1 on the first try, 2 on the first retry, and so on.
Myuser-Signaturet=<unix time>,v1=<signature>. See Webhook signatures.

The event object

AttributeTypeDescription
idstringUnique id of the event, like evt_9b1f0c7e2a4d6f8b0c1e3a5d.
typestringThe event type, like meeting.booked.
created_attimestampWhen the event happened.
testbooleantrue for the test event from the card.
dataobjectWhat happened. It differs per type.

Most events carry a lead, the person Myuser contacted, and the campaign that reached them:

AttributeTypeDescription
lead.idstringMyuser's id for the lead.
lead.emailstringEmail, in lowercase.
lead.first_name, lead.last_namestringName.
lead.titlestringJob title.
lead.phonestringPhone number.
lead.linkedin_urlstringLinkedIn profile link.
lead.countrystringCountry.
lead.companyobject{ "name", "domain" }.
lead.crm_idstringYour contact id when Myuser knows it, otherwise null.
campaignobject{ "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.

typeWhen Myuser sends it
email.sentMyuser sent an email to a lead.
linkedin.sentMyuser sent a LinkedIn invite, InMail or message.
reply.receivedA lead replied by email or on LinkedIn, with the text.
reply.positiveMyuser read a reply as positive.
reply.negativeMyuser read a reply as negative.
meeting.bookedA meeting was booked with a lead.
call.loggedA call with a lead was logged.
deal.createdA deal was added to the Deals tab. See Deal events.
deal.stage_changedA deal moved to another stage. See Deal events.
visitor.identifiedA website visitor was identified as a person. See Visitor events.
visitor.company_identifiedA company was identified visiting your website.
visitor.lead_visitedA lead Myuser contacted came back to your website.
visitor.added_to_campaignA website visitor was added to a campaign.
pingThe 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:

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:

The deal object

What your GET /deals returns for each deal.

AttributeTypeDescription
idstringRequired. Your id for the deal.
namestringDeal name.
amountnumberValue in your currency, like 12000 or 12000.50.
stagestring or objectThe stage name, like "Proposal", or { "id", "name" }. "Sales pipeline / Proposal" works too.
statusstringopen, won or lost. When it is missing, Myuser reads it from the stage name, or from closed_at.
contact_emailstringThe main contact's email. Or contact_emails, a list of emails.
companyobject{ "name", "domain" }.
created_attimestampWhen the deal was created.
updated_attimestampWhen it last changed. Myuser uses it to find the newest changes first.
closed_attimestampWhen it was won or lost.
urlstringLink to the deal. Not needed when Link to a deal is set.
testbooleanKeep 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.

AttributeTypeDescription
idstringYour id for the pipeline.
namestringPipeline name.
stagesarray[{ "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.

ParameterTypeDescription
statusstringopen, won or lost.
contact_emailstringOnly deals with this contact.
namestringOnly 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.

ParameterTypeDescription
namestringRequired. Deal name.
amountnumberValue, when known.
stagestringA stage name from GET /pipelines. Left out when your CRM has no matching stage.
contact_emailstringThe contact's email, in lowercase.
companyobject{ "name", "domain" }. Sent by Master AI when it knows the company.
sourcestringAlways myuser.
testbooleantrue only for the test deal.
AttributeTypeDescription
data.idstringRequired. Your deal id. Myuser uses it for every later change.
data.urlstringLink 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:

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:

AttributeTypeDescription
visit.countrystringThe visitor's country.
visit.pagesarrayPages seen in this visit, up to 30.
visit.clicksarrayButtons and links clicked, up to 10.
visit.intentsarrayIds of the intent tags this visit matched. You set the tags up on the Website visitors page.
visit.key_page_seenbooleantrue when the visitor saw one of your key pages, like pricing.
visit.active_secondsintegerSeconds 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
}