The TechSales GTM API gives your own systems read access to one workspace: contacts, lists and their rows, campaigns, classified replies, account signals, the do-not-contact list and list preparation jobs. The one thing it writes is a contact's link to its id in your CRM. Outbound webhooks tell you when something changes, so you rarely need to poll.
Base URL: https://app.techsalestr.com/v1. JSON over HTTPS.
A list answers { object: "list", data, has_more, next_cursor, request_id }; a record answers its fields with object and request_id; an error answers { error: { code, message, request_id } }.
Timestamps are ISO 8601 in UTC. Every response carries an X-Request-Id header.
The machine-readable description is the OpenAPI 3.1 document (public, no key needed): import it into Postman, Insomnia or a client generator.
Quickstart
In the app, open Settings → Developer & Integrations (an agency managing a client: that client's Client settings → Developer & Integrations). The tab is for workspace admins, and the API is on every paid plan.
Under API keys, name the key (for example "CRM sync"), tick the scopes it needs, optionally pick an expiry date, and create it.
Copy the key from the dialog. It is shown once and can't be shown again; keep it in your secret store, never in browser code.
A 200 with a data array means the key works. A 401 or 403 is explained under Troubleshooting.
Authentication
Send the key as a bearer token on every request: Authorization: Bearer <key>. A key looks like tsk_live_<prefix>_<secret>. It belongs to one workspace and decides which workspace a request reads; nothing in the request can change that.
Shown once. The full key appears only in the dialog right after you create or rotate it. Afterwards the app shows its prefix and dots; we store only a hash of the secret.
Rotation. Rotate gives the key a new secret and keeps its prefix. The previous secret keeps working for 24 h, so you can switch a running integration without downtime.
Revocation. Revoke stops the key at once: the next request with it gets 401.
Expiry. A key can carry an expiry date; after it, the key answers 401.
Plan. The plan is checked on every request: a workspace without the API on its plan gets 403 feature_not_available.
A workspace can have 10 live keys. Use one per system.
Scopes
A key carries scopes, and each endpoint needs exactly one. A key without it gets 403 insufficient_scope. Give each key only what its system reads.
Scope
What it allows
Endpoints
contacts:read
Contacts in the pipeline, with stage, persona and do-not-contact flag
GET /v1/contacts, GET /v1/contacts/{id}, GET /v1/contacts/{id}/external-ids
lists:read
Lists and their rows
GET /v1/lists, GET /v1/lists/{id}, GET /v1/lists/{id}/rows
campaigns:read
Campaigns (sequences) and their launch state
GET /v1/campaigns, GET /v1/campaigns/{id}
replies:read
Classified replies, including the reply text
GET /v1/replies, GET /v1/replies/{id}
signals:read
Account signals
GET /v1/signals, GET /v1/signals/{id}
suppression:read
The do-not-contact list
GET /v1/suppression
jobs:read
List preparation jobs and their steps
GET /v1/jobs, GET /v1/jobs/{id}
contacts:write
Link contacts to their ids in your CRM (nothing else is written)
PUT /v1/contacts/{id}/external-ids/{integration}, DELETE /v1/contacts/{id}/external-ids/{integration}
Pagination and incremental sync
Lists page by cursor, never by offset. limit sets the page size (1 to 200, default 50). Each page returns has_more and next_cursor; pass next_cursor back as ?cursor= exactly as it came. A cursor is opaque: don't build or edit one. Send the same filters with every page.
next_cursor is present whenever a page has items, also on the last page. Store it: calling again with it later returns what changed since. Records are sorted by their sync column and then id, and a record updated during your sync moves to the end and comes again with its new version, so upsert by id. An empty page has no cursor; keep the one you had.
Use the _since filter for the first run (or to recover a window), then the cursor. The filters each list accepts:
Endpoint
Since filter
Other filters
GET /v1/contacts
updated_since, created_since
status, persona_id, integration, external_id
GET /v1/contacts/{id}/external-ids
–
–
GET /v1/lists
updated_since
status
GET /v1/lists/{id}/rows
–
–
GET /v1/campaigns
updated_since
status, list_id
GET /v1/replies
updated_since
contact_id
GET /v1/signals
detected_since
kind
GET /v1/suppression
created_since
type
GET /v1/jobs
created_since
list_id, status
An incremental sync loop
# First run: everything updated since a date you choose.
curl -s "https://app.techsalestr.com/v1/contacts?limit=200&updated_since=2026-11-01T00:00:00Z" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
# Every later call: the next_cursor you stored, passed back exactly as it came.
curl -s "https://app.techsalestr.com/v1/contacts?limit=200&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
Rate limits
Each key may make 120 requests a minute, and all of a workspace's keys together 600. Windows are fixed, 1 min long. Every authenticated response says where you stand:
Header
Meaning
X-RateLimit-Limit
The limit that applies to this request, per minute.
X-RateLimit-Remaining
Requests left in this window.
X-RateLimit-Reset
Unix time (seconds) the window resets.
Retry-After
On a 429 only: seconds to wait before the next request.
On 429 rate_limited, wait Retry-After seconds and try again. The public OpenAPI document has a separate, per-address limit.
Errors
An error answers with its HTTP status and a body with a stable code, a readable message and the request_id. Branch on the code, not the message; quote the request id when you contact us.
Error body
{
"error": {
"code": "insufficient_scope",
"message": "This key does not have the contacts:read scope.",
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
Code
HTTP
Meaning
invalid_parameter
400
A query parameter is unknown or invalid, a path id is not a UUID, the body is wrong, or the cursor is not one this API issued.
unauthorized
401
No key, a malformed key, or a key that is unknown, revoked or expired. Also when the key's workspace is no longer active.
insufficient_scope
403
The key is valid but does not carry the scope this endpoint needs.
feature_not_available
403
The workspace's plan does not include the API. The API is on every paid plan.
not_found
404
No record with this id in the key's workspace.
conflict
409
The write clashes with what is there, e.g. that external id is already linked to another contact in the same system.
rate_limited
429
Too many requests this minute. Wait the Retry-After seconds, then try again.
not_enabled
503
The API is not enabled on this server yet (setup pending). Nothing you can fix.
service_unavailable
503
A read failed on our side. Retry shortly with backoff.
Endpoint reference
Generated from the OpenAPI document. Parameters marked * are required.
Kept only in this page's memory: not saved anywhere, gone when you leave or reload, and sent only with the requests you fire, to /v1 on this same site. Prefer a key with only the scopes you are testing, and revoke it if it was shared by mistake.
Contacts in the workspace's pipeline. Sample data is left out. Sorted by updated_at, then id, ascending. Keep next_cursor and call again with it to receive what changed since: a record updated during a sync moves to the end and comes again with its new version, so upsert by id.
Scope: contacts:read
Parameter
In
Type
Description
limit
query
integer = 50
Page size, 1 to 200. Default 50.
cursor
query
string
next_cursor from the previous page. Opaque: pass it back as it came.
updated_since
query
string (date-time)
Only records updated at or after this time (ISO 8601, with Z or an offset).
created_since
query
string (date-time)
Only records created at or after this time (ISO 8601, with Z or an offset).
status
query
string
Only this pipeline stage key.
persona_id
query
string (uuid)
Only contacts matched to this persona.
integration
query
string
With external_id: find the contact linked to this id in this system.
external_id
query
string
With integration: the record's id in your system.
Response: a page of Contact (32 fields)
Field
Type
Description
object
"contact"
–
id
string (uuid)
–
full_name
string | null
–
first_name
string | null
Derived from full_name: the first word.
last_name
string | null
Derived from full_name: everything after the first word.
email
string | null
–
linkedin_url
string | null
–
company
string | null
–
company_domain
string | null
–
job_title
string | null
–
status
string
Pipeline stage key, e.g. interested, meeting_booked.
status_label
string
The stage's English name.
source
string | null
The channel the contact came in on (email, linkedin...).
icp_score
number | null
–
persona
object | null
–
persona.id
string (uuid)
–
persona.name
string | null
–
email_status
string | null
–
suppressed
boolean
True when the email, its domain or the LinkedIn profile is on Do Not Contact.
external_ids
object
–
external_ids.hubspot_contact_id
string | null
–
external_links
array of object
Ids of this contact in your systems, saved with PUT /v1/contacts/{id}/external-ids/{integration}.
external_links[].integration
string
Your name for the system, e.g. hubspot, salesforce, my_crm.
external_links[].external_id
string
The record's id in that system.
external_links[].sync_status
"synced" | "pending" | "failed"
What you last reported for this record's sync.
external_links[].last_synced_at
string (date-time) | null
Your last successful sync, as you reported it.
external_links[].last_error
string | null
Your last sync error, as you reported it.
external_links[].updated_at
string (date-time)
–
last_message_at
string (date-time) | null
–
meeting_at
string (date-time) | null
–
created_at
string (date-time)
–
updated_at
string (date-time)
–
Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean
PUT/v1/contacts/{id}/external-ids/{integration}Link a contact to its id in your system
Saves (or replaces) the contact's id in one of your systems and the sync state, so the next event or sync can update that record instead of creating another. Idempotent: the same call twice leaves one link. An id already linked to a different contact in the same system is refused (409 conflict): one CRM record, one contact. Match on these ids, not on email.
Scope: contacts:write
Parameter
In
Type
Description
id *
path
string (uuid)
–
integration *
path
string
Your name for the system, e.g. hubspot, salesforce, my_crm.
Request body (JSON)
Field
Type
Description
external_id *
string
The record's id in your system.
sync_status
"synced" | "pending" | "failed"
Default synced.
last_synced_at
string (date-time) | null
When you last synced it. Default: now, when sync_status is synced.
last_error
string | null
Your last sync error.
Response: ExternalLink (7 fields)
Field
Type
Description
integration
string
Your name for the system, e.g. hubspot, salesforce, my_crm.
external_id
string
The record's id in that system.
sync_status
"synced" | "pending" | "failed"
What you last reported for this record's sync.
last_synced_at
string (date-time) | null
Your last successful sync, as you reported it.
last_error
string | null
Your last sync error, as you reported it.
updated_at
string (date-time)
–
request_id
string
–
Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503), not_found (404), conflict (409). What they mean
The workspace's lead lists, as its own Lists page shows them: in a managed (Done-For-You) workspace a list appears once it was sent for review. Sorted by updated_at, then id, ascending. Keep next_cursor and call again with it to receive what changed since: a record updated during a sync moves to the end and comes again with its new version, so upsert by id.
Scope: lists:read
Parameter
In
Type
Description
limit
query
integer = 50
Page size, 1 to 200. Default 50.
cursor
query
string
next_cursor from the previous page. Opaque: pass it back as it came.
updated_since
query
string (date-time)
Only records updated at or after this time (ISO 8601, with Z or an offset).
GET/v1/lists/{id}/rowsList the rows of a lead list
The list's rows in list order (row_index, then id), with the standard columns found in the import and whether each row would go out on an email or a LinkedIn launch. Row ids are not stable across re-imports and merges.
Scope: lists:read
Parameter
In
Type
Description
id *
path
string (uuid)
–
limit
query
integer = 50
Page size, 1 to 200. Default 50.
cursor
query
string
next_cursor from the previous page. Opaque: pass it back as it came.
Response: a page of ListRow (18 fields)
Field
Type
Description
object
"list_row"
–
id
string (uuid)
Not stable: a row can be re-imported or merged. Match on email or linkedin_url.
list_id
string (uuid)
–
row_index
integer
–
full_name
string | null
–
first_name
string | null
–
last_name
string | null
–
email
string | null
–
linkedin_url
string | null
–
company
string | null
–
job_title
string | null
–
row_state
string | null
–
excluded
boolean
–
excluded_reason
string | null
–
email_eligible
boolean
Would go out on an email launch of this list.
linkedin_eligible
boolean
Would go out on a LinkedIn launch of this list.
duplicate_of
string (uuid) | null
–
updated_at
string (date-time)
–
Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503), not_found (404). What they mean
Campaigns (message sequences) and their launch state. Sorted by updated_at, then id, ascending. Keep next_cursor and call again with it to receive what changed since: a record updated during a sync moves to the end and comes again with its new version, so upsert by id.
Scope: campaigns:read
Parameter
In
Type
Description
limit
query
integer = 50
Page size, 1 to 200. Default 50.
cursor
query
string
next_cursor from the previous page. Opaque: pass it back as it came.
updated_since
query
string (date-time)
Only records updated at or after this time (ISO 8601, with Z or an offset).
Replies the workspace received and how they were classified, with the reply text (at most 5,000 characters; body_truncated says when it was cut). Sorted by updated_at, then id, ascending. Keep next_cursor and call again with it to receive what changed since: a record updated during a sync moves to the end and comes again with its new version, so upsert by id.
Scope: replies:read
Parameter
In
Type
Description
limit
query
integer = 50
Page size, 1 to 200. Default 50.
cursor
query
string
next_cursor from the previous page. Opaque: pass it back as it came.
updated_since
query
string (date-time)
Only records updated at or after this time (ISO 8601, with Z or an offset).
contact_id
query
string (uuid)
–
Response: a page of Reply (16 fields)
Field
Type
Description
object
"reply"
–
id
string (uuid)
–
contact_id
string (uuid)
–
channel
string
–
received_at
string (date-time)
–
intent
string
–
confidence
number
–
action
string
–
requires_review
boolean
–
approval_status
string
–
message
object | null
–
message.id
string (uuid)
–
message.body
string
The reply text, at most 5,000 characters.
message.body_truncated
boolean
–
created_at
string (date-time)
–
updated_at
string (date-time)
–
Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean
A webhook endpoint is a URL of yours that receives a signed POST when something happens in the workspace. Every event is written in the same database transaction as the change it describes, then delivered at least once. The payload is deliberately small: ids, the changed fields and the state. Read anything else from the API with the id.
Setup
Build a receiver: an https:// URL on a public host name, on the default port, without credentials in it. Private, loopback and internal addresses are refused, when you save and again before every attempt. Redirects are not followed.
In the app, open Settings → Developer & Integrations → Webhooks and add the endpoint: the URL, an optional description, and at least one event. A workspace can have 10 endpoints.
Copy the signing secret (whsec_…) from the dialog. Like an API key it is shown once; keep it with your receiver's secrets.
Press Send test event and check that your receiver verifies the signature and answers 2xx.
Watch the delivery log under Webhook deliveries: every try, its answer and what is next.
Every delivery is a POST with a JSON body and these headers:
Header
Value
Content-Type
application/json
User-Agent
TechSales-Webhooks/1
X-TechSales-Signature
t=<unix seconds>,v1=<hex HMAC-SHA256>
X-TechSales-Event-Id
The event id (evt_…). Same on every retry and replay.
X-TechSales-Event-Type
The event type, e.g. contact.created.
X-TechSales-Delivery-Id
This delivery to this endpoint.
X-TechSales-Delivery-Attempt
1 on the first try, then 2, 3…
The body is an envelope: id, type, version (the payload version, currently 2026-11-02), created_at, workspace_id, resource: { type, id } and data. A test event also has test: true; an endpoint with a field mapping also gets mapped on contact events (see Field mapping).
Event catalog
20 event types in 6 categories. Open one for exactly when it fires and an example body.
contact.createdA contact was added to the pipeline.
When: A lead row is inserted that is not a sample lead.
Category contacts · resource contact · personal data in name, email, linkedin_url
When: A lead row is updated and one of name, email, phone, company, company_domain, job_title, linkedin_url, icp_score, icp_persona_id, email_status, assigned_to (owner) or meeting_at really changed. Stage moves are contact.status_changed. At most one per contact per minute: data.current is the first change's state, so fetch the contact for the latest.
Category contacts · resource contact · personal data in current.name, current.email, current.phone, current.linkedin_url
When: A list (campaigns row, kind list) is inserted that you can see. In a workspace the agency manages, a list it is still drafting is announced when it leaves draft.
When: An inbound lead_messages row is inserted whose message time is within the last 72 hours (an old conversation synced for the first time is not announced). The text is not in the payload: read it from GET /v1/replies.
Every delivery is signed with your endpoint's secret. The header is X-TechSales-Signature: t=<timestamp>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the whole secret string (including whsec_) as UTF-8.
Read the raw request body as bytes, before any JSON parsing or re-serialising.
Split the header on commas; take t and every v1 (there can be two).
Reject a t more than 300 seconds from your clock (replay protection).
Compute the HMAC for each secret you hold and compare it with each v1 in constant time. Any match is valid.
After you rotate the secret, for 24 h each call carries two v1 values, the new secret's first. Deploy the new secret next to the old one, then drop the old one once the overlap is over.
Node.js (crypto, Express)
import crypto from "node:crypto";
import express from "express";
// During a secret rotation, list the new secret and the old one: either may sign.
const SECRETS = [process.env.TECHSALES_WEBHOOK_SECRET];
const TOLERANCE_SECONDS = 300;
export function verifySignature(header, rawBody, secrets, nowMs = Date.now()) {
if (!header) return false;
let t = null;
const v1 = [];
for (const part of header.split(",")) {
const [k, v = ""] = part.trim().split("=", 2);
if (k === "t" && /^\d{1,12}$/.test(v)) t = Number(v);
else if (k === "v1" && /^[0-9a-f]{64}$/.test(v)) v1.push(v);
}
if (t === null || v1.length === 0) return false;
if (Math.abs(Math.floor(nowMs / 1000) - t) > TOLERANCE_SECONDS) return false; // replay
for (const secret of secrets) {
const expected = crypto
.createHmac("sha256", secret) // the whole whsec_… string is the key
.update(`${t}.${rawBody}`, "utf8")
.digest();
for (const sig of v1) {
const given = Buffer.from(sig, "hex");
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) return true;
}
}
return false;
}
const app = express();
// The raw body: verify the exact bytes that were signed, before any JSON parsing.
app.post("/webhooks/techsales", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifySignature(req.get("X-TechSales-Signature"), raw, SECRETS)) return res.status(400).send("bad signature");
const event = JSON.parse(raw);
res.sendStatus(200); // answer fast, then work
queueForProcessing(event); // your code: dedupe on event.id, then handle
});
Python (hmac, Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
# During a secret rotation, list the new secret and the old one: either may sign.
SECRETS = [os.environ["TECHSALES_WEBHOOK_SECRET"]]
TOLERANCE_SECONDS = 300
def verify_signature(header, raw_body: bytes, secrets, now=None) -> bool:
if not header:
return False
t, v1 = None, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t" and v.isdigit() and len(v) <= 12:
t = int(v)
elif k == "v1" and len(v) == 64:
v1.append(v)
if t is None or not v1:
return False
if abs(int(now if now is not None else time.time()) - t) > TOLERANCE_SECONDS:
return False # replay
signed = str(t).encode() + b"." + raw_body
for secret in secrets:
# the whole whsec_... string is the key
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, sig) for sig in v1):
return True
return False
app = Flask(__name__)
@app.post("/webhooks/techsales")
def techsales_webhook():
raw = request.get_data() # the exact bytes that were signed
if not verify_signature(request.headers.get("X-TechSales-Signature"), raw, SECRETS):
return "bad signature", 400
event = json.loads(raw)
queue_for_processing(event) # your code: dedupe on event["id"], then handle
return "", 200
Retries and auto-disable
Answer with a 2xx within 10 s; do the work after answering. What happens with other answers:
Your answer
What we do
2xx
Delivered.
408, 429, 5xx
Retry. On 429 or 503 a Retry-After header is honoured, kept between 30 s and 6 h.
Timeout, refused connection, DNS or TLS failure
Retry.
3xx
Failed, no retry: redirects are not followed.
Your host resolves to a private or internal address
Failed, no retry: we only call public addresses.
Any other 4xx (400, 401, 404, 410, 422…)
Failed, no retry: you said the request is wrong.
A delivery gets at most 8 attempts. The waits between them are 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h (each ±20 %), about 22.4 hours in all. After the last failed attempt the delivery is dead. Every attempt is in the delivery log, with its status code and the start of your answer.
When 5 deliveries in a row end failed or dead, the endpoint is turned off and the workspace gets a notice. While an endpoint is off, new events are not queued for it. Fix the receiver, turn the endpoint back on, replay what failed, and fetch anything that happened in between from the API (see recovering missed events).
Duplicates and order
Delivery is at least once: the same event can arrive more than once (a retry after a timeout, a replay). It always has the same id, also in X-TechSales-Event-Id. Store processed ids with a unique key and skip repeats.
Order is not guaranteed: a retry can arrive after a later event. Use the body's created_at, or read the current record from the API, before overwriting newer data.
The body is the same on every retry and replay, except mapped, which is made when the attempt is sent (its mapping_version says which mapping made it).
Test events and replays
Send test event posts a webhook.test event to that one endpoint right away, signed like any other. It is never stored, never retried, not in the delivery log and does not count toward the endpoint's health; the app shows the answer. You can't subscribe to it.
Test event body
{
"id": "evt_0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e",
"type": "webhook.test",
"version": "2026-11-02",
"created_at": "2026-11-02T09:30:00.000Z",
"workspace_id": "00000000-0000-4000-8000-000000009001",
"resource": {
"type": "endpoint",
"id": "00000000-0000-4000-8000-000000000501"
},
"data": {
"message": "This is a test event from TechSales. It is safe to ignore."
},
"test": true
}
From the delivery log you can replay one delivery, or every failed or dead delivery of the last 24 hours. A replay is a new delivery of the same event with the same event id, so your dedupe still applies. The endpoint must be on.
Field mapping
Your CRM names its fields its own way (FirstName, EmailAddress, a custom field). A field mapping, made in Settings → Developer & Integrations → Field mapping and attached to a webhook endpoint, sends contact fields under those names: that endpoint's contact events carry a mapped object ready to write into the CRM.
Only fields TechSales holds can be mapped; nothing is invented to fill a destination.
A value we don't have is left out of mapped.fields by default (on_missing: omit), so writing the object never blanks a value your CRM already holds. on_missing: null sends null instead, for a field you want cleared.
A required field that is missing is listed in missing_required; the event is still delivered and your receiver decides.
An event carries a partial contact (contact.updated holds what changed, contact.status_changed the stage), and the mapping applies to what is there, so a partial update stays partial.
mapped is on contact.created, contact.updated and contact.status_changed. Other events have no mapped.
Source field
Type
Description
id
string
TechSales contact id (UUID). Stable.
full_name
string
Full name as held.
first_name
string
First word of the full name.
last_name
string
Everything after the first word.
email
string
Work email, when known.
linkedin_url
string
LinkedIn profile URL.
company
string
Company name.
company_domain
string
Company website domain.
job_title
string
Job title.
status
string
Pipeline stage key, e.g. meeting_booked.
source
string
Channel the contact came in on.
icp_score
number
ICP fit score, 0 to 100.
persona_id
string
Matched persona id.
email_status
string
Email verification result.
meeting_at
datetime
Booked meeting time.
created_at
datetime
When the contact was added.
external_id
string
Your CRM's id for this contact, when one was saved for the mapping's integration.
Transforms: none, trim, lowercase, uppercase, date_only, to_text. Each is allowed only on a source type it can't break (for example date_only on a date).
Here LinkedIn_URL__c and crm_id are left out: the contact has no LinkedIn URL and is not linked to a CRM record yet.
External ids
An external id links a contact to its record in one of your systems (the integration, a short name you choose such as hubspot or my_crm). Save it with PUT /v1/contacts/{id}/external-ids/{integration} after you create the record, and every later event or sync can update that record instead of creating another.
The PUT is idempotent: the same call twice leaves one link.
One record, one contact: an id already linked to a different contact in the same system is refused with 409 conflict.
Find a contact by its CRM id with GET /v1/contacts?integration=…&external_id=…. The links are on every contact as external_links.
A mapping with an integration can send the saved id as the external_id source field, so the receiver knows which record to update without a lookup.
Match on these ids, never on email alone: people change addresses and share inboxes.
Writing needs a key with the contacts:write scope; it writes nothing else.
Link a contact, find it by CRM id
# Save (or replace) the contact's id in your CRM. Idempotent.
curl -s -X PUT "https://app.techsalestr.com/v1/contacts/00000000-0000-4000-8000-000000000101/external-ids/hubspot" \
-H "Authorization: Bearer $TECHSALES_API_KEY" \
-H "Content-Type: application/json" \
-d '{"external_id": "crm-12345", "sync_status": "synced"}'
# Find the contact behind a CRM id.
curl -s "https://app.techsalestr.com/v1/contacts?integration=hubspot&external_id=crm-12345" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
End-to-end integration: contacts into your CRM
The whole loop, from a new key to a CRM that stays in step, including what to do when your receiver was down.
Create a key with contacts:read and contacts:write (Quickstart).
Register a webhook pointing at your receiver and subscribe it to contact.created and contact.status_changed (Setup). Store the signing secret.
Attach a field mapping with your CRM's field names and an integration (for example hubspot), including external_id (Field mapping).
Receive and verify: read the raw body, check X-TechSales-Signature, answer 2xx within 10 s, then process (Verifying signatures).
Map: take mapped.fields, or map data yourself when no mapping is attached.
Upsert by external id: with a CRM id, update that record; without one, create the record and link it back with the PUT (External ids).
Handle retries: answer 5xx or time out only when you want the event again; a 4xx stops retries for that delivery (Retries).
Recover missed events with the API: run the sync loop over GET /v1/contacts with updated_since for the gap the first time, then with the stored cursor (Pagination). Upsert by the same external id, so a record seen twice stays one record.
The receiver's handler
# The REST calls the handler makes, one by one.
# 1. Is this contact already linked to a hubspot record?
curl -s "https://app.techsalestr.com/v1/contacts/$CONTACT_ID/external-ids" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
# 2. After creating the record in the CRM, link it back.
curl -s -X PUT "https://app.techsalestr.com/v1/contacts/$CONTACT_ID/external-ids/hubspot" \
-H "Authorization: Bearer $TECHSALES_API_KEY" \
-H "Content-Type: application/json" \
-d '{"external_id": "'"$CRM_ID"'", "sync_status": "synced"}'
# 3. Catch up on anything missed (downtime, a disabled endpoint): the sync loop.
curl -s "https://app.techsalestr.com/v1/contacts?limit=200&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
Run the sync loop on a schedule as a safety net, even when webhooks work: it covers an endpoint that was turned off (no events are queued for it while off) and anything that failed for good.
Catching up from the API
# First run: everything updated since a date you choose.
curl -s "https://app.techsalestr.com/v1/contacts?limit=200&updated_since=2026-11-01T00:00:00Z" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
# Every later call: the next_cursor you stored, passed back exactly as it came.
curl -s "https://app.techsalestr.com/v1/contacts?limit=200&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $TECHSALES_API_KEY"
CRM examples
Example — not an official connector. TechSales does not sync into HubSpot, Salesforce or Pipedrive by itself; these show how your own code can do it with the API and webhooks. Check field names against your own CRM, which may have renamed or added fields.
The pattern is the same everywhere: map the fields, then upsert by external id. Keep the CRM record's id as the contact's external id under one integration name per CRM (hubspot, salesforce, pipedrive), and in the CRM keep the TechSales contact id in a custom field, so either side can find the other.
TechSales field
HubSpot contact
Salesforce Lead / Contact
Pipedrive person
first_name
firstname
FirstName
first_name
last_name
lastname
LastName
last_name
email
email
Email
email
company
company
Company (Lead)
org_id (an organization)
job_title
jobtitle
Title
a custom field
linkedin_url
a custom property
a custom field
a custom field
status
a custom property
Status or a custom field
a custom field
HubSpot
Map to the contact property internal names above. Store the HubSpot record id with PUT …/external-ids/hubspot.
On an event, update the record by that id; with none, create the contact, then save its id.
Salesforce
Decide whether a TechSales contact becomes a Lead or a Contact; the field names above cover both, with Company on the Lead.
Use the 18-character record id as the external id (…/external-ids/salesforce), or an External ID field of yours that holds the TechSales contact id and upsert on it.
Pipedrive
A person's organization is its own record: find or create it by name, then link the person to it.
Store the person id with …/external-ids/pipedrive; job title and LinkedIn URL need custom fields.
Troubleshooting
Symptom
Likely cause
Fix
401 unauthorized
No Authorization header, a mistyped key, or a key that was revoked, expired or replaced by a rotation more than 24 h ago.
Send Authorization: Bearer tsk_live_…; check the key's state in Settings → Developer & Integrations; create a new key if needed.
403 insufficient_scope
The key lacks the scope the endpoint needs.
Create a key with that scope (the endpoint reference names it). Scopes can't be added to an existing key.
403 feature_not_available
The workspace's plan doesn't include the API.
Choose a paid plan.
429 rate_limited
Over 120 requests a minute for the key, or 600 for the workspace.
Wait Retry-After seconds; spread calls; use webhooks instead of polling.
400 invalid_parameter
An unknown query parameter, a bad value, or a cursor that was edited or built by hand.
Pass next_cursor back exactly as it came; the message names the parameter.
Signature mismatch
The body was parsed and re-serialised before verifying, the wrong secret, or the secret without its whsec_ prefix.
Verify the raw bytes, with the whole secret string. After a rotation, hold both secrets for the overlap.
Signature expired
Your server's clock is more than 300 seconds off, or an old request was replayed.
Sync the clock with NTP. Never accept old timestamps to work around it.
Endpoint turned off
5 deliveries in a row failed or died.
Fix the receiver, turn the endpoint on, replay failed deliveries, run the sync loop for the gap.
The same event twice
At-least-once delivery: a retry or a replay.
Dedupe on the event id; upsert by id or external id.
An older state overwrote a newer one
Events can arrive out of order.
Compare created_at, or read the record from the API before writing.
Still stuck? Quote the request_id (API) or the delivery id from the delivery log (webhooks) when you contact us.
Changelog
Current API version: 1.1.0. Webhook payload version: 2026-11-02.
2026-11-02 · External ids and field mappings 1.1.0
Contacts can be linked to their ids in your systems (contacts:write, the only write scope), contacts can be found by those ids, and webhook endpoints can carry a field mapping.
Signed event deliveries with retries, a delivery log, replays and test events.
2026-11-02 · Read API 1.0.0
Read-only endpoints for contacts, lists, campaigns, replies, signals, the do-not-contact list and jobs, with API keys, scopes, rate limits and the OpenAPI document.