Overview

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

  1. 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.
  2. Under API keys, name the key (for example "CRM sync"), tick the scopes it needs, optionally pick an expiry date, and create it.
  3. 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.
  4. Make your first call:
Shell
export TECHSALES_API_KEY="tsk_live_…"
curl -s "https://app.techsalestr.com/v1/contacts?limit=5" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

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.

ScopeWhat it allowsEndpoints
contacts:readContacts in the pipeline, with stage, persona and do-not-contact flagGET /v1/contacts, GET /v1/contacts/{id}, GET /v1/contacts/{id}/external-ids
lists:readLists and their rowsGET /v1/lists, GET /v1/lists/{id}, GET /v1/lists/{id}/rows
campaigns:readCampaigns (sequences) and their launch stateGET /v1/campaigns, GET /v1/campaigns/{id}
replies:readClassified replies, including the reply textGET /v1/replies, GET /v1/replies/{id}
signals:readAccount signalsGET /v1/signals, GET /v1/signals/{id}
suppression:readThe do-not-contact listGET /v1/suppression
jobs:readList preparation jobs and their stepsGET /v1/jobs, GET /v1/jobs/{id}
contacts:writeLink 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:

EndpointSince filterOther filters
GET /v1/contactsupdated_since, created_sincestatus, persona_id, integration, external_id
GET /v1/contacts/{id}/external-ids––
GET /v1/listsupdated_sincestatus
GET /v1/lists/{id}/rows––
GET /v1/campaignsupdated_sincestatus, list_id
GET /v1/repliesupdated_sincecontact_id
GET /v1/signalsdetected_sincekind
GET /v1/suppressioncreated_sincetype
GET /v1/jobscreated_sincelist_id, status
# 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:

HeaderMeaning
X-RateLimit-LimitThe limit that applies to this request, per minute.
X-RateLimit-RemainingRequests left in this window.
X-RateLimit-ResetUnix time (seconds) the window resets.
Retry-AfterOn 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"
  }
}
CodeHTTPMeaning
invalid_parameter400A 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.
unauthorized401No key, a malformed key, or a key that is unknown, revoked or expired. Also when the key's workspace is no longer active.
insufficient_scope403The key is valid but does not carry the scope this endpoint needs.
feature_not_available403The workspace's plan does not include the API. The API is on every paid plan.
not_found404No record with this id in the key's workspace.
conflict409The write clashes with what is there, e.g. that external id is already linked to another contact in the same system.
rate_limited429Too many requests this minute. Wait the Retry-After seconds, then try again.
not_enabled503The API is not enabled on this server yet (setup pending). Nothing you can fix.
service_unavailable503A 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.

openapi.json

GET/v1/openapi.jsonThis API's OpenAPI 3.1 document

Public, no key needed.

Scope: none (public)

curl -s "https://app.techsalestr.com/v1/openapi.json"
Try it
/v1/openapi.json
The same request as cURL
curl -s "https://app.techsalestr.com/v1/openapi.json"

contacts

GET/v1/contactsList contacts

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

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
updated_sincequerystring (date-time)Only records updated at or after this time (ISO 8601, with Z or an offset).
created_sincequerystring (date-time)Only records created at or after this time (ISO 8601, with Z or an offset).
statusquerystringOnly this pipeline stage key.
persona_idquerystring (uuid)Only contacts matched to this persona.
integrationquerystringWith external_id: find the contact linked to this id in this system.
external_idquerystringWith integration: the record's id in your system.
Response: a page of Contact (32 fields)
FieldTypeDescription
object"contact"–
idstring (uuid)–
full_namestring | null–
first_namestring | nullDerived from full_name: the first word.
last_namestring | nullDerived from full_name: everything after the first word.
emailstring | null–
linkedin_urlstring | null–
companystring | null–
company_domainstring | null–
job_titlestring | null–
statusstringPipeline stage key, e.g. interested, meeting_booked.
status_labelstringThe stage's English name.
sourcestring | nullThe channel the contact came in on (email, linkedin...).
icp_scorenumber | null–
personaobject | null–
persona.idstring (uuid)–
persona.namestring | null–
email_statusstring | null–
suppressedbooleanTrue when the email, its domain or the LinkedIn profile is on Do Not Contact.
external_idsobject–
external_ids.hubspot_contact_idstring | null–
external_linksarray of objectIds of this contact in your systems, saved with PUT /v1/contacts/{id}/external-ids/{integration}.
external_links[].integrationstringYour name for the system, e.g. hubspot, salesforce, my_crm.
external_links[].external_idstringThe 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_atstring (date-time) | nullYour last successful sync, as you reported it.
external_links[].last_errorstring | nullYour last sync error, as you reported it.
external_links[].updated_atstring (date-time)–
last_message_atstring (date-time) | null–
meeting_atstring (date-time) | null–
created_atstring (date-time)–
updated_atstring (date-time)–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/contacts?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/contacts

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/contacts" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/contacts/{id}Get a contact

One contact by id.

Scope: contacts:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: Contact (33 fields)
FieldTypeDescription
object"contact"–
idstring (uuid)–
full_namestring | null–
first_namestring | nullDerived from full_name: the first word.
last_namestring | nullDerived from full_name: everything after the first word.
emailstring | null–
linkedin_urlstring | null–
companystring | null–
company_domainstring | null–
job_titlestring | null–
statusstringPipeline stage key, e.g. interested, meeting_booked.
status_labelstringThe stage's English name.
sourcestring | nullThe channel the contact came in on (email, linkedin...).
icp_scorenumber | null–
personaobject | null–
persona.idstring (uuid)–
persona.namestring | null–
email_statusstring | null–
suppressedbooleanTrue when the email, its domain or the LinkedIn profile is on Do Not Contact.
external_idsobject–
external_ids.hubspot_contact_idstring | null–
external_linksarray of objectIds of this contact in your systems, saved with PUT /v1/contacts/{id}/external-ids/{integration}.
external_links[].integrationstringYour name for the system, e.g. hubspot, salesforce, my_crm.
external_links[].external_idstringThe 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_atstring (date-time) | nullYour last successful sync, as you reported it.
external_links[].last_errorstring | nullYour last sync error, as you reported it.
external_links[].updated_atstring (date-time)–
last_message_atstring (date-time) | null–
meeting_atstring (date-time) | null–
created_atstring (date-time)–
updated_atstring (date-time)–
request_idstring–

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

curl -s "https://app.techsalestr.com/v1/contacts/00000000-0000-4000-8000-000000000101" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/contacts/

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/contacts/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/contacts/{id}/external-idsList a contact's external ids

The contact's ids in your systems, one per integration, with the sync state you reported. The same links are on the contact as external_links.

Scope: contacts:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: a page of ExternalLink (6 fields)
FieldTypeDescription
integrationstringYour name for the system, e.g. hubspot, salesforce, my_crm.
external_idstringThe record's id in that system.
sync_status"synced" | "pending" | "failed"What you last reported for this record's sync.
last_synced_atstring (date-time) | nullYour last successful sync, as you reported it.
last_errorstring | nullYour last sync error, as you reported it.
updated_atstring (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

curl -s "https://app.techsalestr.com/v1/contacts/00000000-0000-4000-8000-000000000101/external-ids" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/contacts//external-ids

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/contacts//external-ids" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

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

ParameterInTypeDescription
id *pathstring (uuid)–
integration *pathstringYour name for the system, e.g. hubspot, salesforce, my_crm.

Request body (JSON)

FieldTypeDescription
external_id *stringThe record's id in your system.
sync_status"synced" | "pending" | "failed"Default synced.
last_synced_atstring (date-time) | nullWhen you last synced it. Default: now, when sync_status is synced.
last_errorstring | nullYour last sync error.
Response: ExternalLink (7 fields)
FieldTypeDescription
integrationstringYour name for the system, e.g. hubspot, salesforce, my_crm.
external_idstringThe record's id in that system.
sync_status"synced" | "pending" | "failed"What you last reported for this record's sync.
last_synced_atstring (date-time) | nullYour last successful sync, as you reported it.
last_errorstring | nullYour last sync error, as you reported it.
updated_atstring (date-time)–
request_idstring–

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

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"}'
Try it
This is a write: sending it changes data in the workspace the key belongs to.
/v1/contacts//external-ids/

Paste an API key above to send requests.

The same request as cURL
curl -s -X PUT "https://app.techsalestr.com/v1/contacts//external-ids/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "external_id": "crm-12345",
  "sync_status": "synced"
}'

DELETE/v1/contacts/{id}/external-ids/{integration}Remove a contact's link to your system

Removes the link for this integration. The contact itself is not touched.

Scope: contacts:write

ParameterInTypeDescription
id *pathstring (uuid)–
integration *pathstringYour name for the system, e.g. hubspot, salesforce, my_crm.
Response: Deleted (3 fields)
FieldTypeDescription
object"deleted"–
deletedtrue–
request_idstring–

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

curl -s -X DELETE "https://app.techsalestr.com/v1/contacts/00000000-0000-4000-8000-000000000101/external-ids/hubspot" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
This is a write: sending it changes data in the workspace the key belongs to.
/v1/contacts//external-ids/

Paste an API key above to send requests.

The same request as cURL
curl -s -X DELETE "https://app.techsalestr.com/v1/contacts//external-ids/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

lists

GET/v1/listsList lead lists

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

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
updated_sincequerystring (date-time)Only records updated at or after this time (ISO 8601, with Z or an offset).
statusquery"draft" | "pending_approval" | "approved" | "rejected"–
Response: a page of List (12 fields)
FieldTypeDescription
object"list"–
idstring (uuid)–
namestring–
platformstringemail, linkedin or multi_channel.
status"draft" | "pending_approval" | "approved" | "rejected"–
processing_statusstring | null–
row_countinteger–
source_platformstring | null–
external_statusstring | nullThe sending platform's state once launched.
launched_atstring (date-time) | null–
created_atstring (date-time)–
updated_atstring (date-time)–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/lists?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/lists

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/lists" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/lists/{id}Get a lead list

One list by id.

Scope: lists:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: List (13 fields)
FieldTypeDescription
object"list"–
idstring (uuid)–
namestring–
platformstringemail, linkedin or multi_channel.
status"draft" | "pending_approval" | "approved" | "rejected"–
processing_statusstring | null–
row_countinteger–
source_platformstring | null–
external_statusstring | nullThe sending platform's state once launched.
launched_atstring (date-time) | null–
created_atstring (date-time)–
updated_atstring (date-time)–
request_idstring–

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

curl -s "https://app.techsalestr.com/v1/lists/00000000-0000-4000-8000-000000000101" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/lists/

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/lists/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

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

ParameterInTypeDescription
id *pathstring (uuid)–
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
Response: a page of ListRow (18 fields)
FieldTypeDescription
object"list_row"–
idstring (uuid)Not stable: a row can be re-imported or merged. Match on email or linkedin_url.
list_idstring (uuid)–
row_indexinteger–
full_namestring | null–
first_namestring | null–
last_namestring | null–
emailstring | null–
linkedin_urlstring | null–
companystring | null–
job_titlestring | null–
row_statestring | null–
excludedboolean–
excluded_reasonstring | null–
email_eligiblebooleanWould go out on an email launch of this list.
linkedin_eligiblebooleanWould go out on a LinkedIn launch of this list.
duplicate_ofstring (uuid) | null–
updated_atstring (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

curl -s "https://app.techsalestr.com/v1/lists/00000000-0000-4000-8000-000000000101/rows?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/lists//rows

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/lists//rows" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

campaigns

GET/v1/campaignsList campaigns

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

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
updated_sincequerystring (date-time)Only records updated at or after this time (ISO 8601, with Z or an offset).
statusquery"draft" | "pending_approval" | "approved" | "rejected"–
list_idquerystring (uuid)–
Response: a page of Campaign (11 fields)
FieldTypeDescription
object"campaign"–
idstring (uuid)–
namestring | null–
platformstring–
status"draft" | "pending_approval" | "approved" | "rejected"–
list_idstring (uuid) | null–
pushed_atstring (date-time) | null–
external_statusstring | null–
launch_statestring | nullThe linked list's state on the sending platform.
created_atstring (date-time)–
updated_atstring (date-time)–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/campaigns?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/campaigns

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/campaigns" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/campaigns/{id}Get a campaign

One campaign by id.

Scope: campaigns:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: Campaign (12 fields)
FieldTypeDescription
object"campaign"–
idstring (uuid)–
namestring | null–
platformstring–
status"draft" | "pending_approval" | "approved" | "rejected"–
list_idstring (uuid) | null–
pushed_atstring (date-time) | null–
external_statusstring | null–
launch_statestring | nullThe linked list's state on the sending platform.
created_atstring (date-time)–
updated_atstring (date-time)–
request_idstring–

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

curl -s "https://app.techsalestr.com/v1/campaigns/00000000-0000-4000-8000-000000000101" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/campaigns/

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/campaigns/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

replies

GET/v1/repliesList classified replies

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

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
updated_sincequerystring (date-time)Only records updated at or after this time (ISO 8601, with Z or an offset).
contact_idquerystring (uuid)–
Response: a page of Reply (16 fields)
FieldTypeDescription
object"reply"–
idstring (uuid)–
contact_idstring (uuid)–
channelstring–
received_atstring (date-time)–
intentstring–
confidencenumber–
actionstring–
requires_reviewboolean–
approval_statusstring–
messageobject | null–
message.idstring (uuid)–
message.bodystringThe reply text, at most 5,000 characters.
message.body_truncatedboolean–
created_atstring (date-time)–
updated_atstring (date-time)–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/replies?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/replies

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/replies" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/replies/{id}Get a classified reply

One reply by id.

Scope: replies:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: Reply (17 fields)
FieldTypeDescription
object"reply"–
idstring (uuid)–
contact_idstring (uuid)–
channelstring–
received_atstring (date-time)–
intentstring–
confidencenumber–
actionstring–
requires_reviewboolean–
approval_statusstring–
messageobject | null–
message.idstring (uuid)–
message.bodystringThe reply text, at most 5,000 characters.
message.body_truncatedboolean–
created_atstring (date-time)–
updated_atstring (date-time)–
request_idstring–

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

curl -s "https://app.techsalestr.com/v1/replies/00000000-0000-4000-8000-000000000101" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/replies/

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/replies/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

signals

GET/v1/signalsList account signals

Signals detected on watched accounts, oldest first by detected_at, then id. Keep next_cursor to receive new signals later.

Scope: signals:read

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
detected_sincequerystring (date-time)Only records detected at or after this time (ISO 8601, with Z or an offset).
kindquerystring–
Response: a page of Signal (19 fields)
FieldTypeDescription
object"signal"–
idstring (uuid)–
kindstring–
levelstring–
titlestring–
summarystring | null–
sourcestring–
source_urlstring | null–
confidencenumber–
contact_idstring (uuid) | null–
person_namestring | null–
person_titlestring | null–
companyobject–
company.idstring (uuid)–
company.namestring | null–
company.domainstring | null–
dismissedboolean–
published_atstring (date-time) | null–
detected_atstring (date-time)–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/signals?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/signals

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/signals" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/signals/{id}Get an account signal

One signal by id.

Scope: signals:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: Signal (20 fields)
FieldTypeDescription
object"signal"–
idstring (uuid)–
kindstring–
levelstring–
titlestring–
summarystring | null–
sourcestring–
source_urlstring | null–
confidencenumber–
contact_idstring (uuid) | null–
person_namestring | null–
person_titlestring | null–
companyobject–
company.idstring (uuid)–
company.namestring | null–
company.domainstring | null–
dismissedboolean–
published_atstring (date-time) | null–
detected_atstring (date-time)–
request_idstring–

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

curl -s "https://app.techsalestr.com/v1/signals/00000000-0000-4000-8000-000000000101" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/signals/

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/signals/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

suppression

GET/v1/suppressionList the do-not-contact entries

Entries oldest first by created_at, then id.

Scope: suppression:read

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
created_sincequerystring (date-time)Only records created at or after this time (ISO 8601, with Z or an offset).
typequery"email" | "domain" | "company" | "linkedin"–
Response: a page of SuppressionEntry (6 fields)
FieldTypeDescription
object"suppression_entry"–
idstring (uuid)–
typestringemail, domain, company or linkedin.
valuestring–
reasonstring | null–
created_atstring (date-time)–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/suppression?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/suppression

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/suppression" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

jobs

GET/v1/jobsList list-preparation jobs

Jobs oldest first by created_at, then id.

Scope: jobs:read

ParameterInTypeDescription
limitqueryinteger = 50Page size, 1 to 200. Default 50.
cursorquerystringnext_cursor from the previous page. Opaque: pass it back as it came.
created_sincequerystring (date-time)Only records created at or after this time (ISO 8601, with Z or an offset).
list_idquerystring (uuid)–
statusquery"queued" | "running" | "done" | "failed"–
Response: a page of Job (12 fields)
FieldTypeDescription
object"job"–
idstring (uuid)–
list_idstring (uuid)–
kindstring–
status"queued" | "running" | "done" | "failed"–
stepsarray of object–
steps[].keystring–
steps[].statusstring–
error_codestring | nullA short code when the job failed; never raw text.
created_atstring (date-time)–
started_atstring (date-time) | null–
finished_atstring (date-time) | null–

Errors: invalid_parameter (400), unauthorized (401), insufficient_scope (403), feature_not_available (403), rate_limited (429), not_enabled (503). What they mean

curl -s "https://app.techsalestr.com/v1/jobs?limit=50" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/jobs

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/jobs" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

GET/v1/jobs/{id}Get a list-preparation job

One job by id.

Scope: jobs:read

ParameterInTypeDescription
id *pathstring (uuid)–
Response: Job (13 fields)
FieldTypeDescription
object"job"–
idstring (uuid)–
list_idstring (uuid)–
kindstring–
status"queued" | "running" | "done" | "failed"–
stepsarray of object–
steps[].keystring–
steps[].statusstring–
error_codestring | nullA short code when the job failed; never raw text.
created_atstring (date-time)–
started_atstring (date-time) | null–
finished_atstring (date-time) | null–
request_idstring–

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

curl -s "https://app.techsalestr.com/v1/jobs/00000000-0000-4000-8000-000000000101" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"
Try it
/v1/jobs/

Paste an API key above to send requests.

The same request as cURL
curl -s "https://app.techsalestr.com/v1/jobs/" \
  -H "Authorization: Bearer $TECHSALES_API_KEY"

Webhooks

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

  1. 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.
  2. 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.
  3. Copy the signing secret (whsec_…) from the dialog. Like an API key it is shown once; keep it with your receiver's secrets.
  4. Press Send test event and check that your receiver verifies the signature and answers 2xx.
  5. 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:

HeaderValue
Content-Typeapplication/json
User-AgentTechSales-Webhooks/1
X-TechSales-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-TechSales-Event-IdThe event id (evt_…). Same on every retry and replay.
X-TechSales-Event-TypeThe event type, e.g. contact.created.
X-TechSales-Delivery-IdThis delivery to this endpoint.
X-TechSales-Delivery-Attempt1 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

Example body: contact.created
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "contact.created",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "contact",
    "id": "00000000-0000-4000-8000-000000000101"
  },
  "data": {
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "company": "Acme",
    "job_title": "CTO",
    "linkedin_url": null,
    "status": "new",
    "source": "email",
    "created_at": "2026-11-02T09:30:00.000Z"
  }
}
contact.updatedA contact's details changed.

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

Example body: contact.updated
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "contact.updated",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "contact",
    "id": "00000000-0000-4000-8000-000000000101"
  },
  "data": {
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "changed_fields": [
      "job_title",
      "company"
    ],
    "current": {
      "job_title": "CEO",
      "company": "Acme Holding"
    }
  }
}
contact.status_changedA contact moved to another pipeline stage.

When: A lead_stage_events row is inserted: the stage really changed, by any writer. data.to = "qualified" is the billable meeting stage.

Category contacts · resource contact

Example body: contact.status_changed
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "contact.status_changed",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "contact",
    "id": "00000000-0000-4000-8000-000000000101"
  },
  "data": {
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "from": "meeting_booked",
    "to": "qualified",
    "by": "person",
    "at": "2026-11-02T09:30:00.000Z"
  }
}
contact.deletedA contact was deleted or merged into another.

When: A lead_tombstones row is inserted (reason deleted or merged).

Category contacts · resource contact

Example body: contact.deleted
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "contact.deleted",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "contact",
    "id": "00000000-0000-4000-8000-000000000101"
  },
  "data": {
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "reason": "merged",
    "merged_into": "00000000-0000-4000-8000-000000000301",
    "deleted_at": "2026-11-02T09:30:00.000Z"
  }
}
suppression.createdAn entry was added to the do-not-contact list.

When: A suppression_entries row is inserted.

Category suppression · resource suppression · personal data in value

Example body: suppression.created
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "suppression.created",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "suppression",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "suppression_id": "00000000-0000-4000-8000-000000000301",
    "type": "domain",
    "value": "acme.io",
    "created_at": "2026-11-02T09:30:00.000Z"
  }
}
suppression.deletedAn entry was removed from the do-not-contact list.

When: A suppression_entries row is deleted.

Category suppression · resource suppression · personal data in value

Example body: suppression.deleted
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "suppression.deleted",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "suppression",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "suppression_id": "00000000-0000-4000-8000-000000000301",
    "type": "email",
    "value": "[email protected]"
  }
}
list.createdA list was created.

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.

Category lists · resource list

Example body: list.created
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "list.created",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "status": "draft",
    "created_at": "2026-11-02T09:30:00.000Z"
  }
}
list.processing_startedA list started being prepared.

When: A list's processing_status becomes running.

Category lists · resource list

Example body: list.processing_started
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "list.processing_started",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "from": "queued",
    "to": "running"
  }
}
list.processing_completedA list finished preparing and can go into a campaign.

When: A list's processing_status becomes ready.

Category lists · resource list

Example body: list.processing_completed
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "list.processing_completed",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "from": "running",
    "to": "ready"
  }
}
list.processing_needs_reviewA list finished preparing and waits for a person's review.

When: A list's processing_status becomes needs_review.

Category lists · resource list

Example body: list.processing_needs_review
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "list.processing_needs_review",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "from": "running",
    "to": "needs_review"
  }
}
list.processing_partially_completedA list was prepared, but only part of it.

When: A list's processing_status becomes partial.

Category lists · resource list

Example body: list.processing_partially_completed
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "list.processing_partially_completed",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "from": "running",
    "to": "partial"
  }
}
list.processing_failedPreparing a list failed.

When: A list's processing_status becomes failed.

Category lists · resource list

Example body: list.processing_failed
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "list.processing_failed",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "from": "running",
    "to": "failed"
  }
}
campaign.approvedA campaign's copy was approved.

When: A content_sequences row's status changes to approved.

Category campaigns · resource campaign

Example body: campaign.approved
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "campaign.approved",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "campaign",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "campaign_id": "00000000-0000-4000-8000-000000000301",
    "name": "Fintech CTOs: email",
    "list_id": "00000000-0000-4000-8000-000000000201",
    "from": "pending_approval",
    "to": "approved"
  }
}
campaign.rejectedA campaign's copy was sent back with changes requested.

When: A content_sequences row's status changes to rejected.

Category campaigns · resource campaign

Example body: campaign.rejected
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "campaign.rejected",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "campaign",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "campaign_id": "00000000-0000-4000-8000-000000000301",
    "name": "Fintech CTOs: email",
    "list_id": "00000000-0000-4000-8000-000000000201",
    "from": "pending_approval",
    "to": "rejected"
  }
}
campaign.launchedSending started (or resumed) for a list.

When: A list's external_status or secondary_external_status becomes active. data.channel says which; data.from = "paused" means a resume.

Category campaigns · resource list

Example body: campaign.launched
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "campaign.launched",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "channel": "primary",
    "from": "draft",
    "to": "active"
  }
}
campaign.pausedSending was paused for a list.

When: A list's external_status or secondary_external_status becomes paused.

Category campaigns · resource list

Example body: campaign.paused
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "campaign.paused",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "list",
    "id": "00000000-0000-4000-8000-000000000201"
  },
  "data": {
    "list_id": "00000000-0000-4000-8000-000000000201",
    "name": "Fintech CTOs Q4",
    "channel": "secondary",
    "from": "active",
    "to": "paused"
  }
}
reply.receivedA contact replied.

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.

Category replies · resource message

Example body: reply.received
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "reply.received",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "message",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "message_id": "00000000-0000-4000-8000-000000000301",
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "channel": "email",
    "received_at": "2026-11-02T09:30:00.000Z"
  }
}
reply.classifiedA reply was read and classified.

When: A reply_decisions row is inserted.

Category replies · resource reply

Example body: reply.classified
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "reply.classified",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "reply",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "reply_id": "00000000-0000-4000-8000-000000000301",
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "channel": "email",
    "intent": "interested",
    "confidence": 0.92,
    "action": "propose_meeting",
    "requires_review": false
  }
}
reply.sentAn answer to a reply was sent.

When: A sent_replies row reaches status sent (the sending platform accepted the answer).

Category replies · resource sent_reply

Example body: reply.sent
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "reply.sent",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "sent_reply",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "sent_reply_id": "00000000-0000-4000-8000-000000000301",
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "platform": "instantly"
  }
}
intent.signal_detectedA buying signal was found for an account or a person.

When: An account_signals row is inserted and not dismissed. A repeat is never re-sent.

Category signals · resource signal

Example body: intent.signal_detected
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "intent.signal_detected",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "signal",
    "id": "00000000-0000-4000-8000-000000000301"
  },
  "data": {
    "signal_id": "00000000-0000-4000-8000-000000000301",
    "account_id": "00000000-0000-4000-8000-000000000201",
    "contact_id": null,
    "kind": "funding",
    "level": "company",
    "title": "Acme raised a Series B",
    "source": "news",
    "detected_at": "2026-11-02T09:30:00.000Z"
  }
}

Verifying signatures

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.

  1. Read the raw request body as bytes, before any JSON parsing or re-serialising.
  2. Split the header on commas; take t and every v1 (there can be two).
  3. Reject a t more than 300 seconds from your clock (replay protection).
  4. 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 answerWhat we do
2xxDelivered.
408, 429, 5xxRetry. On 429 or 503 a Retry-After header is honoured, kept between 30 s and 6 h.
Timeout, refused connection, DNS or TLS failureRetry.
3xxFailed, no retry: redirects are not followed.
Your host resolves to a private or internal addressFailed, 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 fieldTypeDescription
idstringTechSales contact id (UUID). Stable.
full_namestringFull name as held.
first_namestringFirst word of the full name.
last_namestringEverything after the first word.
emailstringWork email, when known.
linkedin_urlstringLinkedIn profile URL.
companystringCompany name.
company_domainstringCompany website domain.
job_titlestringJob title.
statusstringPipeline stage key, e.g. meeting_booked.
sourcestringChannel the contact came in on.
icp_scorenumberICP fit score, 0 to 100.
persona_idstringMatched persona id.
email_statusstringEmail verification result.
meeting_atdatetimeBooked meeting time.
created_atdatetimeWhen the contact was added.
external_idstringYour 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).

A mapping
{
  "integration": "hubspot",
  "on_missing": "omit",
  "fields": [
    {
      "source": "first_name",
      "destination": "FirstName",
      "required": false,
      "transform": "none"
    },
    {
      "source": "last_name",
      "destination": "LastName",
      "required": false,
      "transform": "none"
    },
    {
      "source": "email",
      "destination": "Email",
      "required": true,
      "transform": "lowercase"
    },
    {
      "source": "company",
      "destination": "Company",
      "required": false,
      "transform": "none"
    },
    {
      "source": "job_title",
      "destination": "Title",
      "required": false,
      "transform": "none"
    },
    {
      "source": "linkedin_url",
      "destination": "LinkedIn_URL__c",
      "required": false,
      "transform": "none"
    },
    {
      "source": "external_id",
      "destination": "crm_id",
      "required": false,
      "transform": "none"
    }
  ]
}
contact.created as that endpoint receives it
{
  "id": "evt_3f1c2a9b8d7e4c6f9a0b1c2d3e4f5a6b",
  "type": "contact.created",
  "version": "2026-11-02",
  "created_at": "2026-11-02T09:30:00.000Z",
  "workspace_id": "00000000-0000-4000-8000-000000009001",
  "resource": {
    "type": "contact",
    "id": "00000000-0000-4000-8000-000000000101"
  },
  "data": {
    "contact_id": "00000000-0000-4000-8000-000000000101",
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "company": "Acme",
    "job_title": "CTO",
    "linkedin_url": null,
    "status": "new",
    "source": "email",
    "created_at": "2026-11-02T09:30:00.000Z"
  },
  "mapped": {
    "mapping_id": "00000000-0000-4000-8000-000000000601",
    "mapping_version": 1,
    "fields": {
      "FirstName": "Ada",
      "LastName": "Lovelace",
      "Email": "[email protected]",
      "Company": "Acme",
      "Title": "CTO"
    },
    "missing_required": []
  }
}

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

  1. Create a key with contacts:read and contacts:write (Quickstart).
  2. Register a webhook pointing at your receiver and subscribe it to contact.created and contact.status_changed (Setup). Store the signing secret.
  3. Attach a field mapping with your CRM's field names and an integration (for example hubspot), including external_id (Field mapping).
  4. Receive and verify: read the raw body, check X-TechSales-Signature, answer 2xx within 10 s, then process (Verifying signatures).
  5. Dedupe on the event id before doing anything (Duplicates and order).
  6. Map: take mapped.fields, or map data yourself when no mapping is attached.
  7. 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).
  8. Handle retries: answer 5xx or time out only when you want the event again; a 4xx stops retries for that delivery (Retries).
  9. 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 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.
# 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 fieldHubSpot contactSalesforce Lead / ContactPipedrive person
first_namefirstnameFirstNamefirst_name
last_namelastnameLastNamelast_name
emailemailEmailemail
companycompanyCompany (Lead)org_id (an organization)
job_titlejobtitleTitlea custom field
linkedin_urla custom propertya custom fielda custom field
statusa custom propertyStatus or a custom fielda 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

SymptomLikely causeFix
401 unauthorizedNo 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_scopeThe 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_availableThe workspace's plan doesn't include the API.Choose a paid plan.
429 rate_limitedOver 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_parameterAn 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 mismatchThe 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 expiredYour 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 off5 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 twiceAt-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 oneEvents 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.

  • 2026-11-02 · Outbound webhooks webhooks 2026-11-02

    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.