CRM connections: HubSpot, Pipedrive and RD Station CRM
Keep Postal contacts and your CRM in sync, both ways and without loops: HubSpot, Pipedrive and RD Station CRM, with field and stage mapping, a sync log and retries.
#Overview
A CRM connection keeps Postal contacts and the people in your CRM in sync, both ways and without loops. What a person does in the CRM (a new contact, a deal that changes stage) reaches Postal as a contact, tag, property and event, and can start an automation. What happens in Postal (a contact created or changed, an email opened, clicked, replied to or bounced) goes back to the CRM as the person's data and as a note on their timeline.
| Connection | Credentials | From the CRM to Postal | From Postal to the CRM |
|---|---|---|---|
| HubSpot | Private app token (and, for webhooks, the client secret) | Contact and deal webhooks, plus the manual sync | Contacts created or updated; notes on the timeline |
| Pipedrive | API token and account address | Person and deal webhooks, plus the manual sync | Persons created or updated; notes on the person |
| RD Station CRM | Instance token | Contact and deal webhooks, plus the manual sync | Contacts created or updated; activities on the deal or a note on the contact |
| Webhook (other CRM) | None: a signing secret generated by Postal | Any system that sends a signed JSON POST | Signed POST to your system's HTTPS URL |
The three native connections use the token you paste (no OAuth) and read the CRM's API for you. The webhook connection is for any CRM or system that sends and receives webhooks; it has its own page.
App integrations (Stripe, Supabase, Clerk, Auth0 and Firebase) use the same connections, but count separately, in app_integrations, and do not sync back.
Depends on your plan
The number of CRM connections comes from the marketing plan (crm_connections; 0 means not included). Going over the limit answers 403 plan_limit_reached. See Limits; GET /crm/providers shows the limit and how many you have already used. Connections, and the contacts they create, count toward plan limits like any others.
#Create a connection
In the dashboard, open Integrations › CRM, pick the CRM and paste the credentials. They are stored encrypted and never shown again: the API returns only the names of the stored fields. Use Test connection to check the access. Through the API, it is POST /crm-connections, with a full-access key.
cURL
curl -X POST https://api.postal.so/crm-connections \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "hubspot",
"name": "HubSpot",
"credentials": {
"access_token": "pat-na1-…",
"client_secret": "…"
},
"settings": {
"direction": "both"
}
}'
Node.js
const response = await fetch('https://api.postal.so/crm-connections', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: 'hubspot',
name: 'HubSpot',
credentials: {
access_token: 'pat-na1-…',
client_secret: '…',
},
settings: {
direction: 'both',
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/crm-connections",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"provider": "hubspot",
"name": "HubSpot",
"credentials": {
"access_token": "pat-na1-…",
"client_secret": "…",
},
"settings": {
"direction": "both",
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('crm-connections', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'provider' => 'hubspot',
'name' => 'HubSpot',
'credentials' => [
'access_token' => 'pat-na1-…',
'client_secret' => '…',
],
'settings' => [
'direction' => 'both',
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "crm_connection",
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"provider": "hubspot",
"name": "HubSpot",
"status": "active",
"settings": {
"direction": "both",
"field_mappings": [],
"stage": null,
"inbound": {
"items_path": null,
"event_field": null,
"allow_token": false
},
"outbound": {
"url": null,
"events": [
"contact.created",
"contact.updated",
"contact.deleted",
"email.opened",
"email.clicked",
"email.replied",
"email.bounced",
"email.complained"
]
},
"skip_sources": []
},
"credentials": ["access_token", "client_secret"],
"source": "crm:019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"webhook_url": "https://api.postal.so/integrations/crm/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/webhook",
"webhook_secret": "whsec_c2VncmVkby1kZS1leGVtcGxvLTEyMzQ1Ng==",
"last_sync_at": null,
"last_error": null,
"last_error_at": null,
"created_at": "2026-10-26T14:00:00.000000Z",
"updated_at": "2026-10-26T14:00:00.000000Z"
}
provider:hubspot,pipedrive,rdstation_crmorgeneric(the webhook). The credentials for each are below.statusstarts asactive.paused(PATCH) stops receiving and sending without deleting anything;erroris only a warning that the last push failed for good, and goes back toactiveon the first push that works.webhook_urlis where the CRM delivers changes;webhook_secret(whsec_…) is the secret that authenticates the delivery. They appear on creation, when you get one connection and when you generate a new secret, not in the list.source(crm:<id>) is the source of everything the connection changes on contacts.
#What syncs, and in which direction
settings.direction picks the direction: both (default), inbound (only from the CRM to Postal) or outbound (only from Postal to the CRM). On a send-only connection, the CRM's webhook answers 409 connection_paused; on a receive-only one, Postal sends nothing.
From the CRM to Postal
- Each person becomes a contact, created or updated. Postal recognizes the contact by its link to the CRM record, then by
external_idand finally by email; so an email change in the CRM updates the same contact. - Mapped fields become name, properties and tags (see Map fields and stages). People on the suppression list do not become contacts (the entry is logged as
skipped). - Someone who unsubscribed is never resubscribed by a CRM:
unsubscribed: falsedoes not undo an unsubscribe (only the contacts API does, with proof of the new consent). - An event from the CRM, when the mapping names one (
inbound.event_field), is recorded on the contact and can start an automation with the event trigger. - Deletions in the CRM do not delete the contact in Postal.
From Postal to the CRM
Postal sends the changes the CRM subscribed to in settings.outbound.events. The default is:
| Event | In the CRM | Default |
|---|---|---|
contact.created, contact.updated | The person is created or updated (name, email, mapped fields). | Yes |
contact.deleted | A note saying the contact was deleted in Postal. The CRM record is not deleted. | Yes |
email.opened, email.clicked, email.replied, email.bounced, email.complained | A note on the person's timeline, with the subject (and the link, the bounce reason…). | Yes |
email.sent, email.delivered, email.failed, email.suppressed | The same, for those who want the full history. | No |
The events are the same as Postal's webhooks. Any other name in outbound.events gives 422.
The text of the notes is always in Brazilian Portuguese, as the CRM's users read it (for example, “Postal: E-mail aberto”, “Assunto: …”). The names of the fields Postal writes to the CRM do not change with the language.
Sync now
The Sync now button (POST /crm-connections/{id}/sync) runs in the background: it reads the CRM's people, page by page (and, if there are stage mappings, the open deals), and then sends every Postal contact, as the direction allows. Each person appears in the log, and a sync entry summarizes how many people were imported and sent and how many failed. Use it for the first load, to recover what a webhook missed or when the CRM cannot send webhooks.
#Map fields and stages
With no mapping, Postal reads and writes its own format. On the native connections, email, first and last name and the record ID are already linked, and what you map is added to them. Each mapping links a CRM field to a Postal field, with its own direction (both, inbound or outbound):
| Postal field | What it is |
|---|---|
email | The contact's email. |
external_id | The person's ID in your app (the same external_id as in contacts). |
crm_id | The record ID in the CRM. Postal keeps it in the connection's link, never in external_id. |
first_name, last_name | First and last name. An empty value in the CRM clears the field. |
unsubscribed | True or false. See the unsubscribe rule above. |
tags | Tags: a list, or text separated by commas or semicolons. |
properties.<key> | An existing contact property (properties.company). A key that does not exist gives 422. |
The CRM field is a JSONPath-style path, up to 255 characters, with a small subset: $.person.email (or person.email), items[0].name, $.data.items[*] (every element of a list) and $ (the whole document). A path that does not exist reads null, and the field is ignored (except first and last name, which are left empty). On the native connections, paths point at the record Postal builds from the CRM (for example, $.properties.company on HubSpot and $.custom.<key> on Pipedrive and RD Station CRM); GET /crm-connections/{id}/fields lists the CRM fields you can choose from, with their type.
Pipeline stages
When a deal changes stage, its person gets a Postal property and/or tag, and you can start an automation on that change. In settings.stage, crm_field and pipeline_field say where the stage and the pipeline are (on the native connections, $.deal.stage and $.deal.pipeline are already the default), and each item in mappings links a stage (and, optionally, a pipeline) to a property_key with property_value, to a tag or to both. Names are compared case-insensitively. Without property_value, the property gets the stage name. When sending, Postal does not move deals in the CRM. GET /crm-connections/{id}/stages lists the stages of each pipeline.
{
"settings": {
"direction": "both",
"field_mappings": [
{
"crm_field": "$.properties.company",
"postal_field": "properties.company",
"direction": "both"
},
{
"crm_field": "$.properties.hs_lead_status",
"postal_field": "tags",
"direction": "inbound"
}
],
"stage": {
"crm_field": "$.deal.stage",
"pipeline_field": "$.deal.pipeline",
"mappings": [
{
"stage": "Closed won",
"pipeline": "Sales",
"property_key": "lifecycle",
"property_value": "customer",
"tag": "customer"
}
]
},
"outbound": {
"events": [
"contact.created",
"contact.updated",
"email.opened",
"email.clicked",
"email.replied"
]
},
"skip_sources": [
"my-app"
]
}
}
In this example, HubSpot's company field goes back and forth to Postal's company property, the lead status only comes in as a tag, and a deal in “Closed won” of the “Sales” pipeline marks the person with lifecycle = customer and the customer tag. The company and lifecycle properties must exist beforehand. Only the listed events go out to the CRM, and changes coming from my-app do not go back to it.
#No loops
Two connections that feed each other could swap the same change forever. Postal prevents that in two ways:
- Everything a connection changes on a contact carries the source
crm:<id>(last_change_source), and that connection does not send back the change it made itself. The team's other connections receive the change as usual. settings.skip_sourceslists sources whose changes the connection does not send (up to 20; lowercase letters, numbers,.,_,:and-; a trailing*works as a prefix). Use it for the system that writes to both Postal and the CRM, for example your app, which sends contacts through the API with the source labelmy-app, andcrm:*to ignore every CRM connection.
#Native connections
The three work the same way: Postal calls the CRM's API with your token (with a 10-second timeout, not following redirects), looks for the person by the stored link and then by email, and creates or updates the record. A CRM that answers 429 is waited for as long as it asks, and a 5xx is tried again. People without an email are ignored. The CRM's error message appears in the log and never carries the token.
To receive changes from the CRM you have to set up the webhook in the CRM itself, with the connection URL (webhook_url); without a webhook, the connection still sends data and reads the CRM when you ask for Sync now. Each CRM authenticates the webhook in its own way:
| CRM | How Postal checks the webhook |
|---|---|
| HubSpot | The X-HubSpot-Signature-v3 signature, computed with the app's client secret. Without the client secret in the credentials, HubSpot does not send changes to Postal. |
| Pipedrive | HTTP basic authentication on the webhook: user postal and a password equal to the connection's webhook_secret. |
| RD Station CRM | RD Station CRM does not sign webhooks: the URL carries ?token=<webhook_secret> (or the X-Postal-Token header). The token in the URL is the protection; do not share it. |
A wrong signature or credential answers 401 invalid_signature. If the secret is compromised, generate another with POST /crm-connections/{id}/webhook-secret/rotate and update the webhook in the CRM: the old one stops working right away.
HubSpot
- Credentials:
access_token(required) andclient_secret(optional, only for webhooks). In HubSpot, go to Settings › Integrations › Private Apps and create an app with permission for contacts (read and write), deals (read) and contact and deal schemas (read); copy the access token. The client secret is the one of the app that signs the notifications. - Webhook: in the app, subscribe to contacts (creation and property change) and deals (creation and stage change) with the connection URL. Deletions and merges are ignored.
- Fields: the record is HubSpot's own,
{"id", "properties": {…}}, so paths are$.properties.<internal name>. Postal reads the properties you map, plus email, first name, last name and phone. For deals,$.deal.stage,$.deal.pipeline,$.deal.status,$.deal.titleand$.deal.value. - Sending: the contact is updated through the link, or looked up by email and created if it does not exist. Email activity becomes a note associated with the contact.
Pipedrive
- Credentials:
api_tokenandcompany_domain. In Pipedrive, open the account menu › Personal preferences › API and copy the personal token. The account address is only the first part of the address you use in the browser:mycompanyinmycompany.pipedrive.com(lowercase letters, numbers and hyphens). - Webhook: under Tools and apps › Webhooks, create one webhook for persons and another for deals (all events), with the connection URL and the HTTP basic authentication above. Deletions are ignored. A change to a deal reads its person and the names of the stage and the pipeline.
- Fields:
$.email,$.first_name,$.last_name,$.name,$.phone,$.organization,$.id, the person's custom fields at$.custom.<key>and, for deals,$.deal.stage,$.deal.pipeline,$.deal.status,$.deal.titleand$.deal.value. - Sending: the person is updated through the link or looked up by email and created if it does not exist; the new email and phone enter as the primary ones without deleting the others. Email activity becomes a note on the person.
RD Station CRM
- Credentials:
token, the instance token. In RD Station CRM, open the account menu › Settings › Integrations › API token. - Webhook: create one webhook for contacts and another for deals with the connection URL including the
?token=(the connection screen shows the ready URL). Deletions are ignored. - Fields:
$.email,$.first_name,$.last_name,$.name,$.phone,$.title,$.organization,$.id, the contact's custom fields at$.custom.<id>and, for deals,$.deal.stage,$.deal.pipeline,$.deal.status,$.deal.titleand$.deal.value. - Sending: the contact is updated through the link or looked up by email and created if it does not exist. Email activity becomes an activity on the contact's open deal; with no deal, a line in the contact's notes (the oldest drop out beyond 4,000 characters).
Token permissions
If the CRM refuses the token or a permission (HTTP 401 or 403), the log shows the message and the connection keeps its error warning until a push works. Check the token and the permissions and use Test connection.
#Sync log
Each webhook received, person synced, push to the CRM, test and full sync becomes a log entry, under Integrations › CRM (on the connection) and in GET /crm-connections/{id}/logs. An entry has:
| Field | Values |
|---|---|
direction | inbound (from the CRM) or outbound (to the CRM). Filter: ?direction=. |
object_type | webhook (the delivery received), person, activity (email activity), test or sync. |
status | pending (queued or waiting for a retry), success, failed or skipped (nothing to do: suppressed contact, no match, paused connection). Filter: ?status=. |
action | What happened: created, updated, unchanged, upserted, activity, deleted or a summary (“2 people”). |
error, attempts, next_attempt_at | The message, how many attempts have happened and when the next one will be. |
external_id | The record ID in the CRM, when known. |
retryable, payload | Whether it can be sent again, and what was received or sent (cut at 16 KB; a cut payload cannot be retried). |
The log messages are written in Brazilian Portuguese.
{
"object": "list",
"has_more": false,
"data": [
{
"object": "crm_sync_log",
"id": "019a2c10-5e6f-7a81-9b2c-3d4e5f6a7b8c",
"direction": "outbound",
"object_type": "person",
"event": "contact.updated",
"external_id": "51",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"action": null,
"status": "failed",
"error": "HubSpot recusou as credenciais ou a permissão (HTTP 403). Confira o token e as permissões dele.",
"attempts": 6,
"retryable": true,
"payload": {
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"first_name": "Jane"
},
"next_attempt_at": null,
"created_at": "2026-10-26T14:05:10.000000Z",
"updated_at": "2026-10-26T14:05:11.000000Z"
}
]
}
Retries
- Pushes to the CRM that fail are retried on their own, after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours (6 attempts in all). Once they run out, the entry becomes
failedand the connection shows theerrorwarning (last_error); it keeps working and goes back toactiveon the first push that works. If the CRM answered429, Postal waits at least as long as it asked. - Webhooks received are answered right away with
202and processed in the background. If the CRM could not be queried (rate limit, down), Postal tries again, up to 3 times. - A
failedentry with its full payload can be sent again by hand, in the dashboard or throughPOST /crm-connections/{id}/logs/{log_id}/retry, after fixing the cause (a missing email, a property that did not exist, the token).
#Through the API
Everything the dashboard does is in the API, with a full-access key (a sending-only key gets 401 restricted_api_key). IDs are UUIDs, and lists follow cursor pagination.
| Endpoint | What it does |
|---|---|
GET /crm/providers | The connectors (generic, pipedrive, rdstation_crm, hubspot and the app sources), the plan limit and the usage of each counter, and the partner platform that manages the team, if any. |
GET /crm-connections | Lists the connections. ?limit_key=crm_connections (or app_integrations) filters by counter. |
POST /crm-connections | Creates one. Body: provider, name, credentials and settings. |
GET /crm-connections/{id} | One connection, with the webhook_secret. |
PATCH /crm-connections/{id} | Changes name, status (active or paused), credentials (they replace all: send every required one) and settings (they replace the previous ones: send the full set). |
DELETE /crm-connections/{id} | Deletes the connection and its log. The CRM stops syncing; the contacts stay. |
POST /crm-connections/{id}/test | Checks the access to the CRM (on native ones, a light call; on the webhook, a postal.test). Writes a test entry to the log. |
POST /crm-connections/{id}/preview | Shows what a sample payload would change (payload, plus optional settings to try without saving), without saving anything. Built for the webhook connection. |
GET /crm-connections/{id}/fields | The CRM fields for mapping (key, label, type). On native ones, they come from the CRM. |
GET /crm-connections/{id}/stages | The stages of each pipeline (pipeline, stage, and status when the CRM says). Empty on connectors without pipelines. |
POST /crm-connections/{id}/sync | Queues the sync (202). 422 if the connection is paused or the direction does not allow it. |
POST /crm-connections/{id}/webhook-secret/rotate | Generates a new secret; the previous one stops working right away. |
GET /crm-connections/{id}/logs | The log. Filters: status and direction. |
POST /crm-connections/{id}/logs/{log_id}/retry | Sends a failed entry with its full payload again. |
cURL
curl -X POST https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/test \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/test",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/test', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "crm_connection_test",
"ok": true,
"message": "Connected to HubSpot (12 contacts).",
"status_code": 200
}
When the CRM refuses, ok is false and message carries the reason. fields, stages and a sync that cannot reach the CRM answer 502 crm_request_failed with a readable message.
cURL
curl -X POST https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/sync \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/sync', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/sync",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/sync', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "crm_connection",
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"sync": "queued"
}
cURL
curl 'https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/logs?status=failed' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/logs?status=failed', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/logs",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"status": "failed",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/logs', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'status' => 'failed',
],
]);
$data = json_decode($response->getBody(), true);
#Limits
| What | Limit |
|---|---|
| Connections | the plan's crm_connections (and app_integrations for app ones) |
| Body of a received webhook | 256 KB (413 payload_too_large) |
| People per received webhook | 500 (any beyond that are ignored) |
| Webhooks received | 600 per minute, per source IP |
| Field / stage mappings | 100 / 100 |
Sources to skip (skip_sources) | 20 |
Stored sample payload (sample_payload) | 32 KB |
| Timeout of calls to the CRM | 10 seconds, without following redirects |
| Payload stored in the log | 16 KB (received webhooks are kept whole, up to 256 KB) |
| Pages read from the CRM in a sync | 1,000 (100 to 200 people each) |
#Errors
| Status | Name | When it happens |
|---|---|---|
401 | invalid_signature | The webhook signature or token is missing or wrong, or the signature timestamp is more than 5 minutes old. |
401 | restricted_api_key | A sending-only key tried to use /crm-connections. |
403 | plan_limit_reached | The plan does not include CRM connections or has used them all. |
404 | not_found | The connection does not exist in this team, or the webhook ID belongs to another connection. |
409 | connection_paused | The connection is paused, or only sends (does not receive from the CRM). |
413 | payload_too_large | The webhook body is over 256 KB. |
422 | validation_error | Invalid credentials or settings: unknown direction, invalid Postal field or path, a property that does not exist, an unknown event, a URL that is not HTTPS, a webhook body that is not JSON; or a retry of an entry that is not eligible. |
502 | crm_request_failed | The CRM refused Postal's call or did not respond (fields, stages and sync). |
The error format and the other names are in Errors.
#See also
- Webhook connection: for any other CRM or system.
- Integrate your app: Stripe, Supabase, Clerk, Auth0 and Firebase, which use the same connections.
- Webhooks: the events and signature the connection reuses, and the source of changes.
- Contacts, properties and automations.
- MCP server: an AI assistant can also read and adjust the contacts the CRM syncs.