Developers
Get your first answer from the Co-Signal API
This guide takes you from nothing to a correct first answer from the Co-Signal API. It covers exactly what the API supports today: reading your own accounts, leads, contacts, opportunities, partners, populations and account overlaps.
API access is released one workspace at a time, and it is not switched on for a workspace until Co-Signal adds that workspace to the rollout.
1. Get enabled
Ask your Co-Signal contact to add your workspace to the API rollout. Until then, Settings > API tells you that API access isn't turned on for this workspace yet. A valid API token for that workspace gets
503 feature_disabledfrom/api/v1; a missing or invalid token gets401 invalid_tokenwhile the global API switch is on. With a valid token, a workspace outside the rollout gets exactly the same answer as an API that is switched off everywhere, so that response only tells you access is not on for you yet. Whether the API is switched on at all is public, because a request without a valid token is answered differently once it is on; what the identical answer keeps to itself is whether a given workspace is on the rollout.2. Create a key
An admin of your workspace opens Settings > API and chooses Create key. Name the key, choose what it can read (Accounts, Leads, Contacts, Opportunities, Partners, Populations, Overlaps, Identity, Member directory, Reports, Lists, Intelligence, Attribution, Activity or Suggestions) and whether it can change lists (Manage lists), add notes to account timelines (Timeline), or prepare drafts (Drafts) and create co-sell requests (Requests) that wait for a person's approval, and choose an expiry, which defaults to No expiry.
Copy the secret as soon as it is shown: it starts with
csk_and it is shown once. If you lose it, rotate the key to get a new secret. Anyone holding the secret can read what the key can read, so keep it out of source code and shared documents.Settings > API can also run a test call: paste the secret and it runs the same two steps this guide describes,
POST /api/oauth/tokenand thenGET /api/v1/accounts?limit=8.3. Get a token
Exchange the key for an access token with the OAuth 2.0 client credentials grant. Send the key's ID as
client_id, itscsk_secret asclient_secret, and the API basehttps://app.co-signal.com/api/v1asresource. Settings > API shows the key's ID as Key ID (client_id), on the key's row and beside the secret when a key is created or rotated.You can send the ID and the secret either as those two form fields or as HTTP Basic authentication, the default in most tools, with the key's ID as the username and the secret as the password. Use one method per request: a request that carries the key both in an HTTP Basic header and in the form fields is refused with
400 invalid_request.curl -X POST https://app.co-signal.com/api/oauth/token -d grant_type=client_credentials -d client_id=YOUR_KEY_ID -d client_secret=csk_example_not_a_real_secret -d resource=https://app.co-signal.com/api/v1curl -X POST https://app.co-signal.com/api/oauth/token -u YOUR_KEY_ID:csk_example_not_a_real_secret -d grant_type=client_credentials -d resource=https://app.co-signal.com/api/v1The answer carries an
access_tokenthat is valid for 600 seconds, and there is no refresh token: when it expires, exchange the key again. Without ascopethe token carries every scope the key holds. Send a narrowerscope, such asscope=ecosystem:accounts:read, and the token carries only the scopes you asked for, which the answer'sscopefield lists.One key can exchange for a token 60 times a minute, counted in fixed 60 second windows. After that key reaches its limit, another exchange in the same minute is refused with
429 rate_limitedand aretry-afterheader giving the seconds to wait. A separate per-instance protection limit can also return 429 before a key reaches 60 exchanges, so keep using a token until it expires rather than exchanging for every request.4. Make your first call
Send the token as a bearer token to any read route, starting with your own accounts.
curl https://app.co-signal.com/api/v1/accounts -H "Authorization: Bearer YOUR_ACCESS_TOKEN"Every id you get back is an opaque public id with a resource prefix, such as
acct_for an account orlead_for a lead. Store these ids as your join key: they are stable, they belong to your workspace, and a CRM record id never appears in a response. To go the other way, from a CRM record to a Co-Signal id, callGET /api/v1/leads/resolvewith the CRM reference as input.5. Read the answer
Every list answer has the same three parts:
dataholds the rows,pagetells you whether more rows exist, andmetatells you how fresh the answer is and what it does not cover.{ "data": [ { "id": "acct_example7Qm2", "name": "Example Industries", "domain": "example.com", "type": "customer", "openOpportunityCount": 2, "openOpportunityValue": 48000, "provenance": [ { "fieldKey": "name", "source": "own", "sharing": "disclosed", "sourceUpdatedAt": null, "observedAt": "2026-09-21T08:40:00.000Z" } ] } ], "page": { "nextCursor": "EXAMPLE_NEXT_CURSOR", "hasMore": true }, "meta": { "requestId": "00000000-0000-4000-8000-000000000000", "schemaVersion": "2026-09-20", "observedAt": "2026-09-21T09:00:00.000Z", "computedAt": "2026-09-21T09:00:00.120Z", "sourceUpdatedAt": null, "limitations": [ "order_is_stable_not_alphabetical", "no_crm_source_timestamp" ] } }sourceUpdatedAtis your CRM's own last edit time for the record. It is filled on the reads of your own accounts, contacts, opportunities and leads, and is null on every other feed, because Co-Signal stores your CRM's own edit time only for those records. Rows served by the change feed and by bulk exports carry their own time inprovenance, while the change feed's ownmeta.sourceUpdatedAtstays null, because a page spans many records. A row'sobservedAtis its stored Co-Signal sync time when available.meta.observedAtis the observation time for the answer: list feeds use the request time, while account resolution and detail use the stored sync time of the returned account when available.computedAtis when this specific answer was calculated. A fast response does not mean a fresh one; read all three.meta.limitationsis a closed list of facts about the answer, such asorder_is_stable_not_alphabetical, which means the order is stable but is not a name sort.no_crm_source_timestampmeans at least one returned row has no CRM edit time, or that the resource has none (the overlaps, partners, identity and account context reads always declare it), so the nullsourceUpdatedAtis a fact, not a sign that the data is missing.
Pages and cursors
limit sets the page size: it defaults to 25, its maximum is 200, and a value outside 1 to 200 is refused with 400 invalid_request rather than quietly adjusted. When page.hasMore is true, pass page.nextCursor back as cursor to get the next page; when it is false, nextCursor is null and you have every row.
curl "https://app.co-signal.com/api/v1/accounts?limit=50&cursor=YOUR_NEXT_CURSOR" -H "Authorization: Bearer YOUR_ACCESS_TOKEN"A cursor is an opaque, readable bookmark tied to the query that produced it: it is not encrypted or signed, and it carries no permission, so every page is checked again from scratch. Using a cursor with a different query is refused with 400 cursor_query_mismatch, and a malformed cursor is refused with 400 invalid_request. If the row a cursor points at has since left the feed, the next page continues after it. Pages are not a snapshot, and no answer carries an exact total.
GET /api/v1/overlaps/accounts composes at most 25 accounts per request, so a larger limit returns at most 25 rows. It adds results_capped_by_request_budget when that budget leaves more rows, and hasMore stays true while rows remain. GET /api/v1/leads/resolve returns at most 25 rows with hasMore false, because it has no cursor, and says so with results_capped_by_request_budget when more matched. A larger page size of up to 1000 is a target for a later release, once a stable snapshot exists behind the cursor.
Errors, step by step
The two steps answer in two different shapes, so check which step answered before you debug.
At the token step
POST /api/oauth/token answers in the OAuth 2.0 shape, a body with a single error field.
401 invalid_client401 invalid_clientmeans an unknown, revoked, suspended or expired key, a wrong secret or a disabled workspace, and these are deliberately indistinguishable.- A
401 invalid_clientanswer to a request that sent the key with HTTP Basic also carries aWWW-Authenticate: Basicheader, as the OAuth 2.0 standard requires. 400 invalid_request400 invalid_requestmeansclient_id,client_secretorresourceis missing, the body could not be read, or the request carried the key both in an HTTP Basic header and in the form fields.400 invalid_scope400 invalid_scopemeans you asked for a scope the key does not hold.400 invalid_target400 invalid_targetmeansresourceis not the API base, and it is also the answer while the API is switched off for everyone, because until then the token endpoint does not accept the API as a resource.400 unsupported_grant_type400 unsupported_grant_typemeans a client credentials request named a resource other than the API while the API is switched off.429 rate_limited429 rate_limitedmeans the key reached 60 exchanges in the current minute or the token endpoint's per-instance protection limit was reached. Wait the seconds inretry-after.
At the resource step
Every /api/v1 route answers in one envelope, a body with error and requestId, and the requestId is the handle to quote when you ask for help.
400 invalid_request400 invalid_requestmeans the request itself is malformed, including an unknown or repeated query parameter, which is refused rather than ignored.401 invalid_token401 invalid_tokenmeans the token is missing, expired or revoked, belongs to a key revoked after the token was issued, or was issued for the MCP server rather than the API.403 insufficient_scope403 insufficient_scopemeans the key does not hold a scope this route needs.404 not_found404 not_foundmeans the id does not name a record you can see, and an id you cannot see behaves exactly like one that was never issued.429 rate_limited429 rate_limitedmeans you went over a rate limit, so wait the number of seconds inRetry-After.503 feature_disabled503 feature_disabledmeans API access is not switched on, either everywhere or for your workspace, and the two are the same answer on purpose.503 temporarily_unavailable503 temporarily_unavailablemeans something failed on the Co-Signal side, so retry after the seconds inRetry-After.
Rate limits
One key can make 120 requests a minute and one workspace 600 a minute, counted in fixed 60 second windows. Over either limit the answer is 429 rate_limited with Retry-After in seconds until the window ends. The limit counts who is calling, never what was asked for.
Batch resolution
POST /accounts/batch and POST /leads/batch resolve up to 100 selectors in one call. Send a JSON body such as { "selectors": [{ "domain": "acme.example" }, { "name": "Acme" }] }, where each selector is exactly the object the matching resolve route takes as its query: a company domain, a CRM reference (source with sourceRecordId) or a name, and on the lead route also an own work email.
The answer holds one item per selector, in the order you sent them. Each item's candidates and limitations are exactly the data and meta.limitations the single resolve returns for that selector alone, so a batch never shows you more than the same selectors sent one at a time. An item's status is matched for one candidate, ambiguous for two or more, none for no candidate, or invalid when the single resolve would have refused the selector with 400 invalid_request, and the other selectors are still resolved.
A body with no selector, with more than 100, with a key other than selectors or that is not JSON is 400 invalid_request. Each batch consumes its selector count from a budget of 50,000 selectors per workspace per UTC day, and a batch that would pass the budget is refused whole with 429 budget_exceeded, the budget's numbers and a Retry-After header, and is never answered partially. A batch counts once against the per minute rate limit and takes no Idempotency-Key, because it only reads.
Batch resolution is not yet enabled for every workspace, and until yours is released a call answers 503 feature_disabled.
Bulk exports
POST /exports creates an export of your own records and answers 202 with the export's status, its job_ id and an actionId for the action that follows it. Send a JSON body such as { "families": ["accounts", "contacts"] } and an Idempotency-Key header, choosing from accounts, contacts, opportunities, leads, account_overlaps and lead_overlaps. The default format is newline delimited JSON with one { "family", "data" } row per line, and "format": "csv" gives one file of exactly one family. A line of account_overlaps or lead_overlaps also carries an id, the same opaque id GET /changes gives that pair, so a tombstone can be matched to its exported row, and a CSV overlap file leads with an id column. You need ecosystem:exports:read and the scopes that read each family you ask for, and one family out of reach refuses the whole request with 403 insufficient_scope. The same Idempotency-Key with the same body answers 202 with the same export id and actionId and the export's current status and creates nothing more, a replay after the export's record has been deleted answers 404 not_found, the same key with another body is 422 idempotency_key_reused, and a missing or malformed key is 400 invalid_request.
Exports are an organization wide power: only a key that holds the exports scope, or a workspace admin through an app your workspace approved for it, can use them, and every other caller is refused exactly as a caller without the scope is. An export is private to the key or person that created it, so another key, another workspace, an id that was never issued and a part that cannot be downloaded are all answered the same 404 not_found.
GET /exports/{exportId} reads the status: queued, running, complete, failed, cancelled, revoked or expired, with the families, the snapshot watermark, the row counts and, for a complete export, its parts. Each part lists its number, family, rowCount, byteSize, sha256 and contentType, and never a storage path or a link, because Co-Signal issues none. A part holds at most 5,000 rows or 8 MiB, and a family can be several parts. GET /exports/{exportId}/download?part=N streams one part through the API with content-disposition: attachment, cache-control: no-store and an ETag of its sha256, and it never redirects to storage.
Export files are kept for 24 hours after the export completes. Download them within that time, or run the export again. The status shows the end of the window as expiresAt, and after it the export reads expired and lists no part.
A complete export lists one bookmarks entry for each family: pass it as bookmark to GET /changes with that family as resource to receive what changed after the export, without a new snapshot. An export reads current rows while it runs, so a row can already show a change made after the watermark, and the first change page sends such a row again only when the version you exported differs from the current one.
Bulk exports are not yet enabled for every workspace, and until yours is released every export call answers 503 feature_disabled, exactly as the API does for a workspace outside the rollout.
When an export ends
Every part is authorized again when it is downloaded, with your credential's rights at that moment. Any change to the rights an export relied on ends it for good: a revoked or narrowed key, a removed or demoted person, a withdrawn app approval, a disabled workspace, a partner switching export off, and any change a partner makes to its sharing after the export started, loosening it and toggling its crm_sync channel included. A running export ends at its next page, and a completed one at its next download, which is then answered 404 not_found like an unknown id. A revoked export reads revoked and says nothing about why. A partner's data is in an export only if that partner allows the export channel, which is off by default for every partnership.
POST /exports/{exportId}/cancel asks for the end of your own export. Cancelling a queued or running export is a request: it is recorded, the answer still reads queued or running, and the export ends cancelled at its next step. A complete export is cancelled at once and its parts are deleted, and cancelling twice is the same as once. A cancelled, revoked or failed export lists no part, and a cancelled export and a revoked one both end their action as refused, so the action never shows which of the two happened.
A workspace can have 3 exports open at once and can export 1,000,000 rows in a UTC day, and every key of the workspace shares both budgets. A fourth open export is refused with 429 budget_exceeded before anything is written, and an export whose next part would pass the day's rows ends failed with the budget named in its status, never silently cut short. An export is bounded: it can hold at most 2,000 parts, and the list of its parts must fit in its record. An export that would pass either bound ends failed with the code export_too_large before it writes the part that would pass it, its parts are deleted, and it is not retried. Ask for fewer families in one export, because the overlap families are the largest.
Parts you already downloaded cannot be recalled: revoking or cancelling an export stops further pages and downloads, and cannot reach a copy already in your hands.
Bulk imports
POST /imports uploads a CSV of accounts and starts a dry run. It answers 202 with the import's status, its job_ id and an actionId for the action that follows it. Send a multipart/form-data body with two fields and an Idempotency-Key header: file, the CSV, and options, JSON such as { "mapping": { "name": "Company", "domain": "Website" } }. The mapping names your columns: name and domain are required, type, ownerEmail and crmRecordId are optional, and crmRecordId needs a crmSource of salesforce or hubspot. precedence.importWinsFields is optional and is explained below.
You need ecosystem:imports:write. Imports are an organization wide power: only a key that holds the scope, or a workspace admin through an app your workspace approved for it, can use them, and every other caller is refused exactly as a caller without the scope is. An import is private to the key or person that created it. Status, cancel and apply answer only the creator, so another key, another workspace and an id that was never issued are all answered the same 404 not_found.
A file can be up to 25 MiB and 100,000 data rows. A file over 25 MiB, a body that is not multipart, a missing or unknown field, options that fail the schema and a file that does not look like a CSV are each 400 invalid_request, and no import is created. The file name can be up to 255 characters and options up to 16 KiB, and a busy instance can answer an upload 429 rate_limited with Retry-After, which you can send again. A file with more rows ends the dry run failed with the closed code too_many_rows, a file the parser cannot read ends it failed with invalid_file, a stored file that has gone missing ends it failed at once with file_unavailable, and an unexpected failure ends it with internal. The same Idempotency-Key with the same file and options answers the same import and actionId with the import's current status and creates nothing more, the same key with another file or other options is 422 idempotency_key_reused, and a missing or malformed key is 400 invalid_request.
The dry run reads every row and writes no account. GET /imports/{importId} reads the status, which is validating, dry_run_complete, applying, applied, failed, cancelled, revoked or expired, and once the dry run has finished a dryRun report. The report gives exact counts of total, creates, updates, skips, conflicts and errors, up to 200 row errors as { row, field, code } and up to 200 conflicts as { row, fields }, and it names rows and fields and never a cell value. A repeat of an earlier row of the same file is counted as a skip and listed with duplicate_in_file. The row error codes are missing_name, invalid_domain, personal_email_domain, invalid_type, invalid_owner_email, missing_identity, duplicate_in_file, ambiguous_crm_reference, identity_mismatch and value_too_long, and a code is never renamed or removed.
Bulk imports are not yet enabled for every workspace, and until yours is released every import call answers 503 feature_disabled, exactly as the API does for a workspace outside the rollout.
When an import ends
A dry run always comes first. POST /imports/{importId}/apply starts the apply, needs its own Idempotency-Key and no body, and answers 202 with the import's status and the apply's own actionId. Only an import that is dry_run_complete can be applied, and any other state, and an import with a cancel request recorded, is 400 invalid_request and starts nothing. The apply checks every row again against the data as it is then, so a value filled in since the dry run is skipped, not overwritten.
By default the CRM wins and the import fills gaps: a name or type already on an account is never overwritten, a blank cell never clears a value, an owner column only fills an empty owner, and an import never deletes anything and writes accounts only. precedence.importWinsFields can name name and type, and then the import wins for those fields only. A row is matched to an account by its domain, or by its CRM record id when you map one, a new account needs a domain, and every applied row is audited with who applied it.
A workspace can have 2 imports open at once, counting those that are validating or applying, and a third is refused with 429 budget_exceeded and no import is created. Applies are limited to 200,000 rows per UTC day for the whole workspace, shared by every key, and an apply counts every row of its dry run that passed validation. An apply that would pass it is 429 budget_exceeded, and so is an apply started while 2 other imports are open (import_concurrent_jobs), the import stays dry_run_complete, and nothing is applied or charged.
POST /imports/{importId}/cancel asks for the end of your own import. Cancelling a running import is a request: it is recorded, the answer still reads the running status, and the import ends cancelled before its next chunk of 300 rows, and rows already applied stay applied. A finished dry run is cancelled at once, and cancelling twice is the same as once. A revoked key or a withdrawn app approval ends a running import the same way, as revoked, and a chunk that had already started when the import was cancelled or revoked can still finish, so up to 300 rows, each audited, can be applied after it.
A cancelled import and a revoked one both end the action that was still open refused with the same closed code, job_ended, so the action never shows which of the two happened, and neither status says why. A failed import ends its action failed with its own code, which is too_many_rows, invalid_file, file_unavailable or internal and which the status shows as error.code, and a finished dry run and a finished apply each end their own action succeeded.
The uploaded file is kept on Co-Signal's storage only and is never returned. It is deleted when the import ends, whether it was applied, failed, cancelled or revoked, and 7 days after a dry run that was never applied, when the import reads expired.
Webhooks
Webhooks send your system a small signed event when something you subscribed to changes, so you do not have to poll. An event is a doorbell, not a parcel: it carries the event type, its time and public ids, never a name, a domain, an amount or a message, so you read the record from the API with the id it gives you. The one exception is attribution.updated, which also names the partners credited on a deal and carries the deal's value.
Webhooks are an organization wide power: only a key that holds ecosystem:webhooks:manage, or a workspace admin through an app your workspace approved for the Webhooks power, can manage subscriptions, and every other caller, a member included, is refused 403 insufficient_scope exactly as a caller without the scope is. To get a key, an admin opens Settings > API and chooses Create key with the Webhooks capability, which is offered to workspace admins once webhooks are enabled for your workspace, or connects an app an admin approved for the Webhooks power. The key also needs the scopes of the event types you subscribe to, which are listed under Event types and scopes.
POST /api/v1/webhook-subscriptions registers an endpoint and GET /api/v1/webhook-subscriptions lists your subscriptions, newest first. GET, PATCH and DELETE /api/v1/webhook-subscriptions/{subscriptionId} read, change and delete one, and a PATCH can change url, eventTypes and filters, or set status to disabled to stop deliveries or to active to start them again, which verifies the endpoint again. POST /api/v1/webhook-subscriptions/{subscriptionId}/test sends one signed test event, and POST /api/v1/webhook-subscriptions/{subscriptionId}/rotate makes a new signing secret. A subscription can be narrowed to some of your partnerships with filters.partnershipIds, and a workspace can hold 10 subscriptions.
Creating and rotating need an Idempotency-Key header, the same key with the same body answers the same subscription again without its secret and creates nothing more, and the same key with another body is 422 idempotency_key_reused. A URL your workspace already has a subscription for, and an eleventh subscription, are 409 subscription_conflict, and a URL Co-Signal would never send to, a body the contract does not allow or an unknown event type is 400 invalid_request with no detail. The signing secret starts with whsec_ and is shown once, in the answer to the create or the rotation, so store it then. A subscription that cannot be read, because it never existed, belongs to another workspace or was deleted, is the same 404 not_found every time.
Webhooks are available for workspaces in the API rollout, and for a workspace outside the rollout every webhook call still answers 503 feature_disabled.
Registering and verifying an endpoint
A new subscription starts as pending_verification, and Co-Signal activates it only after your endpoint proves it is yours. Your URL must be HTTPS on port 443 with no user name or password in it, and its host must resolve to a public address, because Co-Signal never sends to a private, loopback, link local or reserved address.
To verify it, Co-Signal sends one GET to your URL with cosignal_challenge=<token> appended to the query, using ? when the URL has no query and & when it has one. That request carries the header Accept: text/plain, application/json, no body, no signature and no event data, only the token. Answer 2xx with the token as the whole plain text body, where surrounding whitespace is ignored, or with the JSON object { "challenge": "<token>" }.
Co-Signal tries up to three times: the first attempt right away, the second 5 seconds after the first one fails and the third 15 seconds after the second one fails, and each attempt waits at most 10 seconds for an answer. A redirect is never followed, a body cut at the size limit never counts, and a wrong or missing echo is a failed attempt. After the third failure the subscription reads verification_failed, and it says nothing more about why.
A new URL, or setting status to active on a disabled, suspended or failed subscription, or on one still pending verification, starts the handshake again, and the subscription is pending_verification until it passes. Deliveries go only to an active subscription, so nothing is sent to an endpoint that has not answered the handshake. The Node handshake helper in this guide answers it in a few lines.
// Co-Signal endpoint handshake for Node 18 or later. No dependencies. Helper version 1.
//
// When you register or change a webhook URL, Co-Signal sends one GET to it with a cosignal_challenge query
// parameter and activates the subscription only when you answer 200 with that token as the whole plain text body.
// Mount this on the webhook path, before your delivery handler:
//
// import { createServer } from "node:http";
// import { answerCoSignalHandshake } from "./cosignal-handshake.mjs";
// createServer((request, response) => {
// if (answerCoSignalHandshake(request, response)) return;
// // ...read the raw body, call verifyCoSignalWebhook, then handle the delivery...
// }).listen(3000);
const CHALLENGE_PARAMETER = "cosignal_challenge";
// The token is random URL safe text. Anything else is refused, so the endpoint never echoes arbitrary input.
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,256}$/;
// Returns true when it answered the request (a GET to this path), false for anything else (your deliveries are POSTs).
export function answerCoSignalHandshake(request, response) {
if (request.method !== "GET") return false;
const url = new URL(request.url || "/", "http://example.com");
const token = url.searchParams.get(CHALLENGE_PARAMETER);
if (token === null || !TOKEN_PATTERN.test(token)) {
response.writeHead(400, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
response.end("missing or invalid cosignal_challenge");
return true;
}
response.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
});
response.end(token);
return true;
}
Events and what they carry
Every delivery is one CloudEvents 1.0 JSON object with the attributes specversion, id, source, type, time, subject, datacontenttype and cosignaleventid, and a data object. id is the id of the signal, which starts with sig_ and is one event for you and one subject, cosignaleventid is the id of the source change, which starts with sev_, and the delivery id in the X-CoSignal-Delivery-Id header, which starts with dlv_, is separate again. time is when the change was committed, subject is the primary public id of the type, and data holds public ids and a few fixed labels, never a name, a domain, an amount or a message, except in attribution.updated, described below.
overlap.created and overlap.updated carry the overlap, partnership and account ids and a kind label of account or lead. overlap.removed carries only the overlap id and its kind, never a partnership or an account id, and every removal looks the same, whatever caused it. When your CRM merges two accounts, the merged away pair reads as overlap.removed and the surviving pair as overlap.created. request.status_changed carries the request, partnership and account ids and a status label of requested, accepted, declined or closed. partner_deal_opened and partner_deal_closed_won carry the overlap, partnership and account ids, and partner_greenfield_closed_won carries the partnership and the greenfield candidate. own_deal_opened and own_deal_closed_won carry the opportunity and account ids, data_health.changed carries your workspace id with the CRM source and its state, and webhook.test carries the subscription id.
A deal that is deleted or merged in your CRM produces no event, so read the record again when you need to be sure. Greenfield candidates follow your stored greenfield snapshot, so a newly shared account is signalled only after the next rollup. Every delivery is checked again against your current access just before it is sent, and one you could no longer see is dropped.
attribution.updated is the one event whose data names partners and carries a value: opportunity is the deal's id, kind is sourced, influenced or none, partners lists { partnership, name } for the partners credited on the deal that you may name, and value is the deal's value as your CRM stores it, in the currency of your CRM, never converted. It fires after attribution is recalculated, when a deal's credit kind, its credited partners as you may see them, or its value changes, so a change of the deal's value alone fires it. A credit that is removed fires it with kind none and partners: [], while a first sighting of a deal with no credit fires nothing.
A partner that ends the partnership or stops sharing on the API channel drops out of the next event for each deal it was credited on, and earlier undelivered events that name it are not delivered and are left out of the feed. That shows at the next recalculation, which runs nightly and when a co-sell request closes, not instantly, and a deal credited only to such partners reads as its credit kind with partners: []. A partner renaming itself fires nothing, because names are not part of what is compared, and the name in an event is the one current when the event was made. Attribution events are available for workspaces in the API rollout, and demo workspaces serve no attribution items.
Event types and scopes
The event types are overlap.created, overlap.updated, overlap.removed, request.status_changed, partner_deal_opened, partner_deal_closed_won, partner_greenfield_closed_won, own_deal_opened, own_deal_closed_won, data_health.changed, attribution.updated and webhook.test, and webhook.test is only ever sent by the test call. A subscription needs ecosystem:webhooks:manage and the scopes of every type it names. The overlap, request status and partner deal types need ecosystem:signals:read, ecosystem:accounts:read and ecosystem:partners:read. The own deal types need ecosystem:signals:read and ecosystem:opportunities:read, and data_health.changed needs ecosystem:signals:read and ecosystem:accounts:read. attribution.updated needs ecosystem:signals:read, ecosystem:opportunities:read, ecosystem:attribution:read and ecosystem:partners:read, because it names partners and carries a deal value. An overlap event about a lead pair is delivered only while the subscription's key also holds ecosystem:leads:read.
A type your key lacks a scope for refuses the whole request with 403 insufficient_scope, before anything is written, and adding a type to a subscription later needs the same scopes. A subscription whose key or app is revoked, or loses a scope its types need, is suspended instead, and it reads suspended with a public reason such as credential_inactive or scope_removed. Each group of event types is released for a workspace on its own, and a subscription to a type that is not yet released for yours receives nothing for it until it is.
Signing and verifying deliveries
Every delivery is a POST with Content-Type: application/json and three headers: X-CoSignal-Signature, X-CoSignal-Timestamp and X-CoSignal-Delivery-Id. X-CoSignal-Timestamp is unix seconds, and the signature is an HMAC-SHA256 over the timestamp, a dot and the raw body bytes, keyed with the whole whsec_ secret as UTF-8 text. X-CoSignal-Signature is <keyId>=v1,<base64 HMAC>, where the key id starts with wsk_, and it holds two entries separated by a space while a rotation overlaps.
Verify the raw body exactly as you received it, before you parse it, because re-serializing the JSON changes the bytes. Accept a delivery only if an entry matches a secret you hold, compared in constant time, and its timestamp is within 300 seconds of your clock either way. Rotating makes a new secret and the old key keeps signing beside it for 24 hours, so a delivery carries two signatures until the old key retires, and you can add the new secret to your receiver before you remove the old one. GET /api/v1/webhook-subscriptions/{subscriptionId} lists the ids of the keys that sign now in signingKeyIds, and never a secret.
The helpers in this guide are tested against Co-Signal's own signer, so use them as they are. The Node verifier takes the raw body, the request headers and a map of key id to secret, answers only true or false and never says which check failed. The Python verifier does the same with the standard library, and the Node helpers also drop a repeated delivery id, keep the latest event for each subject and answer the handshake.
// Co-Signal webhook verification for Node 18 or later. No dependencies. Helper version 1.
//
// verifyCoSignalWebhook checks the signature and the 300 second window of one delivery.
// It needs the RAW body exactly as received: parse the JSON only after it returns true.
// Re-serializing the JSON changes the bytes and the check fails.
// createDeliveryDeduper and applyLatest handle at least once and unordered delivery (see below).
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
const MAX_HEADER_LENGTH = 1024;
const TIMESTAMP_PATTERN = /^[0-9]{1,15}$/;
const KEY_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const SIGNATURE_PATTERN = /^[A-Za-z0-9+/]{1,128}={0,2}$/;
// Reads one header from a Fetch Headers object or from a plain object (Node http, Express and similar).
function readHeader(headers, name) {
if (headers && typeof headers.get === "function") {
const value = headers.get(name);
return typeof value === "string" ? value : "";
}
for (const key of Object.keys(headers || {})) {
if (key.toLowerCase() === name && typeof headers[key] === "string") return headers[key];
}
return "";
}
// timingSafeEqual throws when the lengths differ, so the length check comes first.
function safeEqual(a, b) {
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// rawBody: the body bytes as received (Buffer, Uint8Array) or the exact text.
// headers: the request headers. secretsByKeyId: { "wsk_...": "whsec_..." } for every secret you hold.
// Keep both secrets in the map while you rotate: a delivery signed with either one verifies.
// options.now is a time in milliseconds (default Date.now()); options.toleranceSeconds defaults to 300.
export function verifyCoSignalWebhook(rawBody, headers, secretsByKeyId, options = {}) {
const now = options.now === undefined ? Date.now() : options.now;
const tolerance = options.toleranceSeconds === undefined ? TOLERANCE_SECONDS : options.toleranceSeconds;
if (!Number.isFinite(now) || !Number.isFinite(tolerance) || tolerance < 0) return false;
if (typeof rawBody !== "string" && !(rawBody instanceof Uint8Array)) return false;
if (!secretsByKeyId || typeof secretsByKeyId !== "object") return false;
const timestamp = readHeader(headers, "x-cosignal-timestamp");
const header = readHeader(headers, "x-cosignal-signature");
if (!TIMESTAMP_PATTERN.test(timestamp) || header.length > MAX_HEADER_LENGTH) return false;
if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > tolerance) return false;
const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : Buffer.from(rawBody);
const signed = Buffer.concat([Buffer.from(timestamp + ".", "utf8"), body]);
// Every entry is checked (no early exit), so the time taken does not show which entry matched.
let matched = false;
for (const token of header.split(" ")) {
const separator = token.indexOf("=");
if (separator <= 0) continue;
const keyId = token.slice(0, separator);
const rest = token.slice(separator + 1);
if (!KEY_ID_PATTERN.test(keyId) || !rest.startsWith("v1,")) continue;
const received = rest.slice(3);
if (!SIGNATURE_PATTERN.test(received)) continue;
// Own keys only: a key id such as "constructor" must never resolve to an inherited value.
if (!Object.prototype.hasOwnProperty.call(secretsByKeyId, keyId)) continue;
const secret = secretsByKeyId[keyId];
if (typeof secret !== "string" || secret.length === 0) continue;
const expected = createHmac("sha256", Buffer.from(secret, "utf8")).update(signed).digest("base64");
if (safeEqual(Buffer.from(expected, "utf8"), Buffer.from(received, "utf8"))) matched = true;
}
return matched;
}
// Deliveries are at least once: the same delivery can arrive twice. Call check() with the X-CoSignal-Delivery-Id
// header AFTER verifyCoSignalWebhook returned true (so forged requests cannot fill the memory), and skip the work
// when it answers "duplicate". The first sighting is remembered for ttlSeconds (default 600); asking again does not
// extend that window. This map lives in one process and is lost on restart: for several servers or longer windows
// store the delivery id in your database with a unique constraint instead, and keep your own effects idempotent.
// options.now is a function returning milliseconds (default Date.now); tests inject it.
export function createDeliveryDeduper(options = {}) {
const ttlMs = (options.ttlSeconds === undefined ? 600 : options.ttlSeconds) * 1000;
const clock = typeof options.now === "function" ? options.now : Date.now;
const seen = new Map();
return {
check(deliveryId) {
const now = clock();
// Entries are added in time order, so the expired ones sit at the front of the map.
for (const [id, expiresAt] of seen) {
if (expiresAt > now) break;
seen.delete(id);
}
const expiresAt = seen.get(deliveryId);
if (expiresAt !== undefined && expiresAt > now) return "duplicate";
seen.set(deliveryId, now + ttlMs);
return "new";
},
};
}
// Deliveries are not ordered: an overlap.removed can arrive before the overlap.created it follows.
// applyLatest keeps, per subject, the event with the latest time, so any arrival order ends in the same state.
// When two events have the same time the one whose id sorts last (plain string order) wins.
// Treat overlap.created and overlap.updated alike: either one means "this overlap exists as of that time".
// state is a Map of subject to event (start with new Map()); the function returns a new Map and never changes the old one.
// Look up the subject afterwards: state.get(subject).type is "overlap.removed" when the removal is the latest news.
export function applyLatest(state, event) {
const next = new Map(state);
if (!event || typeof event.subject !== "string" || event.subject.length === 0) return next;
const time = Date.parse(event.time);
if (Number.isNaN(time)) return next;
const current = next.get(event.subject);
if (current) {
const currentTime = Date.parse(current.time);
if (time < currentTime) return next;
if (time === currentTime && !(String(event.id) > String(current.id))) return next;
}
next.set(event.subject, event);
return next;
}
# Co-Signal webhook verification for Python 3.8 or later. Standard library only. Helper version 1.
#
# verify_cosignal_webhook checks the signature and the 300 second window of one delivery.
# It needs the RAW body exactly as received: parse the JSON only after it returns True.
# Re-serializing the JSON changes the bytes and the check fails.
import base64
import hashlib
import hmac
import re
import time
RECEIVER_TOLERANCE_SECONDS = 300
_MAX_HEADER_LENGTH = 1024
_TIMESTAMP = re.compile(r"[0-9]{1,15}")
_KEY_ID = re.compile(r"[A-Za-z0-9_-]{1,64}")
_SIGNATURE = re.compile(r"[A-Za-z0-9+/]{1,128}={0,2}")
def _read_header(headers, name):
# headers: any mapping of header names to text (Flask, Django, http.server and similar).
try:
items = list(headers.items())
except AttributeError:
return ""
for key, value in items:
if isinstance(key, str) and key.lower() == name and isinstance(value, str):
return value
return ""
def verify_cosignal_webhook(raw_body, headers, secrets_by_key_id, now=None, tolerance_seconds=RECEIVER_TOLERANCE_SECONDS):
# raw_body: the body bytes as received (or the exact text).
# secrets_by_key_id: {"wsk_...": "whsec_..."} for every secret you hold.
# Keep both secrets in the mapping while you rotate: a delivery signed with either one verifies.
# now: unix seconds (default time.time()); tolerance_seconds defaults to 300.
if now is None:
now = time.time()
if isinstance(raw_body, str):
raw_body = raw_body.encode("utf-8")
elif isinstance(raw_body, (bytes, bytearray, memoryview)):
raw_body = bytes(raw_body)
else:
return False
if not isinstance(secrets_by_key_id, dict) or tolerance_seconds < 0:
return False
timestamp = _read_header(headers, "x-cosignal-timestamp")
header = _read_header(headers, "x-cosignal-signature")
if _TIMESTAMP.fullmatch(timestamp) is None or len(header) > _MAX_HEADER_LENGTH:
return False
if abs(int(now) - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode("utf-8") + b"." + raw_body
# Every entry is checked (no early exit), so the time taken does not show which entry matched.
matched = False
for token in header.split(" "):
key_id, separator, rest = token.partition("=")
if not separator or _KEY_ID.fullmatch(key_id) is None or not rest.startswith("v1,"):
continue
received = rest[3:]
if _SIGNATURE.fullmatch(received) is None:
continue
secret = secrets_by_key_id.get(key_id)
if not isinstance(secret, str) or not secret:
continue
mac = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
expected = base64.b64encode(mac)
if hmac.compare_digest(expected, received.encode("ascii")):
matched = True
return matched
Delivery, retries and no code tools
Delivery is at least once, so the same delivery can arrive twice with the same X-CoSignal-Delivery-Id, and you should keep the ids you have seen and skip a repeat. Deliveries are not ordered, so keep the event with the latest time for each subject, and for the same time the one whose id sorts last. Co-Signal does not publish fixed sending IP addresses, so verify the signature instead of allowing addresses.
A 2xx answer ends a delivery, and a 408, a 429, a 5xx, a timeout or a network or TLS failure is retried, up to 12 attempts and never past 24 hours after the first, with a growing and jittered wait that honours Retry-After for up to 1 hour. A 410 Gone disables the subscription at once, with the reason endpoint_gone. Any other 4xx, and any redirect, which is never followed, ends that delivery with no retry. Each attempt waits at most 10 seconds for an answer and reads at most 64 KiB of it, and only the status and a Retry-After header are used.
POST /api/v1/webhook-subscriptions/{subscriptionId}/test sends one signed webhook.test event to an active subscription and answers { delivered, outcome } and nothing more, where outcome is the status the endpoint answered or timeout, tls_error or unreachable. A test call is never retried, a subscription that is not active answers the same 404 not_found as an unknown one, and a workspace can make 10 test calls a minute.
A no code tool that cannot answer a GET with the token, such as Zapier's Catch Hook, cannot register an endpoint, while a tool that can echo the challenge, such as n8n or Make, can.
Delivery log and recovery
GET /api/v1/webhook-subscriptions/{subscriptionId}/deliveries lists the deliveries of one subscription, newest first, 25 a page by default and at most 200 with limit, and status narrows the list to one status such as dead_letter. GET /api/v1/deliveries/{deliveryId} reads one delivery. A delivery shows its id, which is the X-CoSignal-Delivery-Id you received, the ids of its signal and its source event, its type, its status, the attempts of the current run, its times, the lastStatusCode and the public outcome of the last attempt, and its replayCount, and never a body, a header, a URL or a reason.
A delivery is pending, retry_scheduled, delivered, dead_letter once every retry was used, requires_correction when your endpoint answered with a status that is not retried, endpoint_disabled after a 410 Gone, dropped when the data could no longer be sent to you, or discarded. Every delivery is listed for 30 days after it ends, and a dead letter waits 30 days for you to replay or discard it. request.status_changed and data_health.changed events are recovered through the delivery log and replay.
POST /api/v1/deliveries/{deliveryId}/replay sends a delivery that ended again and answers 202 with the delivery, which reads pending with a higher replayCount. A replay keeps the delivery id and the original event time, so your endpoint sees the same X-CoSignal-Delivery-Id and the same body with a fresh signature, and it gets a fresh budget of 12 attempts and 24 hours. lastAttemptAt, lastStatusCode and outcome keep the previous run's values until the first attempt of the new run lands. A replay is refused with one 409 delivery_not_replayable, which never says why, when the data is no longer yours to receive, when the subscription is not active or no longer lists the type, when the credential that made the subscription no longer qualifies, or when the delivery cannot be replayed, such as a discarded one or a test event.
POST /api/v1/deliveries/{deliveryId}/discard takes a JSON body { "reason": "..." } of 1 to 200 characters and moves a dead_letter delivery to discarded, and any other status answers the same 409 delivery_not_replayable. A reason outside that length, a body with another key and a query parameter are 400 invalid_request, and the reason is your own note, stored with the delivery and never shown back. All four routes need ecosystem:webhooks:manage, and a delivery or subscription that never existed, belongs to another workspace or has a malformed id is the same 404 not_found every time. The delivery log and recovery routes are available for workspaces in the API rollout, and for a workspace outside the rollout these routes still answer 503 feature_disabled.
Signal feeds
Signal feeds are the pull side of webhooks: the same signals, read with GET calls and a bookmark instead of being sent to your endpoint, so a system that cannot receive a webhook, or that missed some, can catch up. GET /api/v1/signals/accounts reads the overlap signals (overlap.created, overlap.updated and overlap.removed) and the partner deal signals (partner_deal_opened, partner_deal_closed_won and partner_greenfield_closed_won) about your accounts. GET /api/v1/signals/leads reads the overlap signals about your leads, and GET /api/v1/signals/own-deals reads own_deal_opened and own_deal_closed_won for your own opportunities.
The accounts feed needs ecosystem:signals:read, ecosystem:accounts:read and ecosystem:partners:read, the leads feed needs ecosystem:signals:read, ecosystem:leads:read and ecosystem:partners:read, and the own deals feed needs ecosystem:signals:read and ecosystem:opportunities:read. A key that lacks any one of them is refused 403 insufficient_scope, with the same answer for every feed. To get a key, an admin opens Settings > API and chooses Create key with the Signals capability and the capabilities that read the feed's records, which are Overlaps for the accounts feed, Leads and Partners for the leads feed, and Opportunities for the own deals feed. The Signals capability is offered to workspace admins once a signal feed is enabled for your workspace.
A partner deal signal is about a deal you could see in Co-Signal, and a partner greenfield signal follows the partner's record level sharing with you, never counts only, so you never hear about a deal you could not see in the app. A workspace member sees only the signals about the records they own or have claimed, and a person whose membership in the workspace has ended is refused with 403 insufficient_scope.
Each feed is released for a workspace on its own, and until yours is released the feed answers 503 feature_disabled, exactly as the API does for a workspace outside the rollout. The leads feed answers the same 503 feature_disabled while lead overlaps are not enabled for your workspace. The accounts and leads feeds and GET /api/v1/signals/own-deals are available for workspaces in the API rollout, the own deals feed serves attribution items only to admin principals and keys holding the attribution scopes, and demo workspaces serve no attribution items.
The own deals feed also carries attribution.updated, the event that names partners and a deal value, so it is served only to an admin principal, an integration key or an admin's delegated token, that also holds ecosystem:attribution:read and ecosystem:partners:read. For anyone else the feed answers as if those items did not exist, with the same page and no 403, so those two scopes are not needed to call the feed. It never names a partner that ended the partnership or does not share on the API channel, types=attribution.updated selects only these items on the own deals feed, and the accounts and leads feeds refuse that name with 400 invalid_request. A subscription to the type, by contrast, is refused 403 insufficient_scope when its key lacks any scope of the type.
Snapshot and bookmark
Start with a snapshot: call a feed with neither cursor nor bookmark, follow page.nextCursor to the last page and keep the bookmark that page returns. Every snapshot item carries historical: true, because a snapshot is history and not news. A snapshot holds the signals committed before a cut-off a few seconds in the past, and the bookmark starts the change pages exactly at that cut-off, so no signal falls between the two.
Each later call with bookmark returns the signals committed since, oldest first, with a new bookmark to keep, and changes appear after a short delay of a few seconds. On a change page historical is false for a live change and true only for a signal that describes how things stood before signals were switched on for you. page.nextCursor is always null on a change page, and page.hasMore true means call again at once. A page can hold fewer items than limit, or none, while hasMore is true, because every item is checked against what you can see now, so keep calling until hasMore is false.
A bookmark stays valid for 30 days, and signals are kept for 31 days and never served past their expiry. A bookmark older than 30 days answers 410 cursor_expired. A bookmark whose view changed, because your credential's role or scopes changed, a snapshot whose position signal expired between two pages, and a bookmark issued before an update to the change feed's contract all answer 410 resync_required. Signal bookmarks share the change feed's contract version, so an update to that contract makes a signal client take a new snapshot too. Either way, take a new snapshot and replace your copy: the resync.href of the answer names the call to make, the same feed's path.
Only limit, cursor, bookmark and types are accepted, limit is 1 to 200 and 25 by default, and cursor with bookmark, a repeated or unknown parameter, a bad limit and a malformed cursor or bookmark are 400 invalid_request. One credential should page one bookmark at a time.
Feed items, types and cursors
An item has id, the sig_ id of the signal, event, the sev_ id of the source change, type, time, subject, data and historical. The data of an item is the data of the webhook for the same signal, and id, type, time and subject are the same too, so one handler can process a feed item and a webhook in the same way. data holds public ids and a few fixed labels only, never a name, a domain, an amount or a message, so read the record from the API with the id. The one exception is attribution.updated, whose data also holds the credited partners and the deal's value, as described under Events and what they carry.
overlap.removed is a tombstone that never gives a reason: it carries only the overlap id and its kind, never a partnership or an account id, and every removal looks the same, whatever caused it. A deal that is deleted or merged in your CRM produces no signal, so read the record again when you need to be sure. A member's feed serves an overlap.removed only while the record is still one the member owns or has claimed, so member clients should also follow the accounts family on GET /api/v1/changes, whose tombstone tells them to drop the account and its overlaps.
types narrows a feed to a comma separated list of event type names, for example types=overlap.created,overlap.updated. Names are compared exactly, so a space around a name, another letter case, an empty element, a name Co-Signal does not know and a type the feed does not carry are each 400 invalid_request, and a repeated types parameter is refused and never merged. To leave one type out, send the list of the other types the feed carries. Leaving types out and sending types= with nothing after it are the same unfiltered query, and the order of the names does not matter.
A cursor or bookmark belongs to the credential, the feed and the types list it was issued for. The same credential presenting it to another signal feed, or with another types list, gets 400 cursor_query_mismatch, and a cursor or bookmark issued to a different credential is 400 invalid_request, the same as a malformed one. A page is checked again against your current access, so a signal about something you can no longer see is left out and leaves no trace.
Recovering missed webhooks and no code tools
If your endpoint was down, or you missed webhooks for any other reason, read the feed: keep one bookmark for each feed and types list, call the feed with it and handle each item as you would the webhook with the same id. Skip an id you have already handled, because a webhook and the feed can both carry the same signal. The feeds carry the overlap, partner deal, own deal and attribution types. request.status_changed and data_health.changed have no feed, so recover those through the delivery log and replay.
A no code tool that cannot answer the endpoint handshake, such as Zapier's Catch Hook, can still follow a feed on a schedule, while a tool that can echo the challenge, such as n8n or Make, can receive webhooks. Create a key in Settings > API with the Signals capability, which is offered to workspace admins once a signal feed is enabled for your workspace, and the capabilities that read the feed's records. On a timer, such as every minute, exchange the key for a token as the guide's token step describes, call the feed with the bookmark you stored, handle each item, and store the new bookmark the answer returns. If hasMore is true, call again at once, and if the answer is a 410, take a new snapshot and replace what you hold.
Start the first run with a snapshot and store its bookmark without acting on the items, since a snapshot is history, and act only on the items that arrive on change pages. The accounts and leads feeds and GET /api/v1/signals/own-deals are available for workspaces in the API rollout and answer 503 feature_disabled outside it.
Freshness
Every read of your own accounts, contacts, opportunities and leads carries meta.sourceFreshness, one entry for each connected CRM, sorted by provider, and an empty list when you have none, except the two batch reads, POST /api/v1/accounts/batch and POST /api/v1/leads/batch, which carry none. An entry holds provider, state, reason, cadence, lastSuccessAt and staleAfterSeconds, a CRM you have disconnected is not listed, and if Co-Signal could not read the state for a request the member is left out rather than filled with a default.
state is fresh, stale or degraded. fresh is earned: the last successful refresh is within staleAfterSeconds and nothing has failed since. stale means there has been no successful refresh yet or the last one is older than staleAfterSeconds. degraded outranks both, however recent the last success, and Co-Signal never shows a degraded connection as fresh. reason is set only when state is degraded: quota when the CRM's request allowance is nearly spent, entitlement when your CRM edition or connected user lacks access to an object, authorization when the connection must be reconnected, provider_error when the CRM failed, and reconciliation_guard when a deletion of more than 20 percent of an object or more than 1,000 records was held for a person to review and nothing was removed. A scheduled_full connection whose latest full sync could not refresh a whole object, such as leads, reads degraded with entitlement or provider_error until a later sync completes without that failure.
cadence says how the connection is refreshed. An incremental connection polls its CRM every 2 minutes, slowing to 5 minutes, 15 minutes and 1 hour as the day's request allowance falls below 50, 25 and 10 percent, and is stale after the larger of three intervals and 10 minutes. A scheduled_full connection is refreshed by a full sync about every 6 hours and is stale after 7 hours, which is staleAfterSeconds 25200, and a demo connection always reads fresh, because its data is seeded rather than synced. Incremental polling is not yet enabled for every workspace, so most connections read scheduled_full today.
Every row carries sourceUpdatedAt in its provenance: the time your CRM says it last changed that record, whoever changed it, so a write Co-Signal makes into your CRM through a destination moves it too. meta.sourceUpdatedAt is the latest of the rows returned, and a figure Co-Signal computes itself, such as the open opportunity count, carries none. observedAt is still when Co-Signal observed the data and computedAt when the answer was calculated, and none is filled from another. no_crm_source_timestamp appears only when at least one returned row has no CRM edit time, such as an account that came from a CSV or a record synced before that time was stored, or when the resource has none, as on the overlaps, partners, identity and account context reads, which always declare it.
A change in the CRM's edit time on its own is not a change to the record, so it produces no item on GET /api/v1/changes and a row is not sent again for it, while a real change to the record still is. So a row from the change feed can show an older sourceUpdatedAt than the same record in an export or a list read; use meta.sourceFreshness on a list read to judge how fresh your data is. A deletion in HubSpot is found by reading 10 pages of 100 archived records per poll, so a portal with many archived records of one type can take one poll for every 1,000 of them to be seen, and Salesforce deletions are found within its 14 day deleted records window.
Three things to know before you build
Channels are off by default for every partner.
A partner's data flows through this API only once that partner has switched the automation channel on for your partnership, and it is off by default for every partnership. So on day one
GET /api/v1/overlaps/accountsreturns one row for each of your accounts with an emptypartnersarray, until a data owner turns automation on. That empty array is the owners' current choice, not a broken feed.An id you can't see behaves exactly like an id that doesn't exist.
An id issued to another workspace, or one that names a record you may not see, answers the same
404 not_foundas an id that was never issued, with no distinguishing error.No CRM writes through this API yet.
No request to this API writes to your CRM in this release, and CRM updates stay in the Co-Signal app.
What is available today
- Reading your own accounts, leads, contacts, opportunities, partners, populations and account overlaps.
- An admin created key with the capabilities you choose, a test call, rotation and revocation, all at Settings > API.
- Partner custom fields on
GET /api/v1/accounts/{accountId}/context: each partner entry'scustomFieldslists the account fields that partner has chosen, and the account's standard industry, employee count and annual revenue, that the partner has ticked for you on a sharing rule above counts and shares with you through Automation, each with itsprovenance, and is empty otherwise. - Resolving your own accounts from company evidence:
GET /api/v1/accounts/resolvetakes a companydomain, a CRM reference (sourceandsourceRecordIdtogether) or a companyname, and a selector that names an account you cannot see answers with no rows, exactly as a selector that names nothing anywhere does. - Resolving your own leads:
GET /api/v1/leads/resolvetakes a companydomain, a CRM reference (sourceandsourceRecordIdtogether, a Salesforce id in its 15 or 18 character form), a personname(an exact match first, then names beginning with the same characters, at most ten candidates) or anemailaddress you already hold, and returns every match as a candidate. - Your partner tags:
GET /api/v1/partner-tagslists the tags your admins created, and every partner row fromGET /api/v1/partnerscarries the tags your workspace applied to it. Tags are read only through the API and are never visible to the partner you tagged. - Populations your partners share with you:
GET /api/v1/partner-populationslists each one by the name your partner gave it, whether it holds accounts or leads, the sharing level your partner granted you, and how many of your own records overlap it (a count below five is shown as suppressed when only counts are shared). It never shows the partner's definition or the population's size, and it answers only an admin created key or a signed in admin.GET /api/v1/partner-populations/{partnerPopulationId}reads one of them by theppop_id the list returns, and any id you cannot read gets the same404 not_foundas an id that was never issued. - Reading one of your own accounts by id:
GET /api/v1/accounts/{accountId}returns the account in the same row shape the accounts list serves, and an id you cannot see answers the same404 not_foundas an id that was never issued. - Asking who is calling:
GET /api/v1/mereturns the calling key's name or the signed in person's public id, display name and current role, the one workspace the credential belongs to, and the scopes it holds. - Your workspace and its members:
GET /api/v1/workspacesreturns the one workspace your credential belongs to, and an admin created key with the Member directory capability reads the current members' public id, display name, work email, role and joined date fromGET /api/v1/workspaces/current/members; a member's own API token cannot read the directory. - Searching your account overlaps:
GET /api/v1/overlaps/accounts/searchnarrows your overlaps by one of your populations, a population a partner shares with you, a partner or a partner tag, and, for one partner at a time, by the fields that partner shares with you and marks filterable, such as industry or employee count. - Your lead overlaps:
GET /api/v1/overlaps/leadslists your leads with every partner lead or account they match on company domain, where your partner shares the population that covers it with you, each labelled a company match or, only when you and your partner both share person details, the same person;GET /api/v1/overlaps/leads/searchnarrows them by population, partner, tag, match type and the fields your partner shares with you. - The change feed,
GET /api/v1/changes, serves snapshots, change bookmarks and tombstones for accounts, contacts, opportunities, leads and account overlaps, and for lead overlaps where they are switched on. - Reading saved reports:
GET /api/v1/reportslists the reports you may see, andGET /api/v1/reports/{reportId}/rowsrecomputes a report's rows under today's sharing every time you call it; a report never returns rows saved from an earlier moment. - Reading lists:
GET /api/v1/listslists the lists you may see,GET /api/v1/lists/{listId}returns a list and its link, andGET /api/v1/lists/{listId}/rowsrecomputes its rows under today's sharing, page by page. - Managing lists: create, rename, change and delete lists with
POST /api/v1/lists,PATCH /api/v1/lists/{listId}andDELETE /api/v1/lists/{listId}. Send anIdempotency-Keywith every change so a retry never repeats it, and send the version you read asIf-Matchso you never overwrite someone else's change. - Finding new accounts:
GET /api/v1/greenfieldranks accounts your partner has that you do not (or the reverse, withdirection=us_to_partner), in two modes:top_prospectsorders by the computed rank, andrecent_partner_customerkeeps your partner's customers with the most recent activity first. Filters work only on firmographic fields your partner shares with you and marks filterable. A key needs both the Intelligence and the Partners capability to read them. - Recommendations and partner context:
GET /api/v1/recommendationsranks which partner to involve on an account, andGET /api/v1/partners/{partnerId}/contextreturns a partner's score with every dimension, its weight, the period it covers and what each measure means. Rankings are computed by fixed rules you can inspect; an optional plain language explanation never changes them. - Attribution and activity:
GET /api/v1/attributionreturns what each partner is credited with in your stored intelligence rollup, the same figures the Intelligence page shows: the opportunities it is credited on, how many of those it is the primary credit for and their value, over the rollup period, with what each figure means; the figures come from the stored rollup, so the list is empty until one has been produced.GET /api/v1/activityreturns your co-sell activity newest first with the period it covers and the same summary the Performance tab shows. - Account timelines:
GET /api/v1/accounts/{accountId}/timelinelists notes, requests, CRM syncs and commands on an account, newest first, andPOST /api/v1/timeline/entriesadds an internal note attributed to your key or app. - Drafts and co-sell requests:
POST /api/v1/draftsandPOST /api/v1/requestscreate an item a named person approves in Co-Signal before anything reaches a partner, unless your admin's automation policy permits that exact request;GET /api/v1/actions/{actionId}follows it. - Answering requests:
POST /api/v1/requests/{requestId}/accept,/declineand/closework only on a person's own delegated token, never an integration key; a signed in person gets one by consenting in Co-Signal, for a workspace in the API rollout. - Partner suggestions:
GET /api/v1/suggestionslists companies that chose to join the Co-Signal directory, ranked on your own workspace data, with the evidence for each and how many companies are listed in every response. Every listed company chose to join, because Co-Signal has not loaded any licensed directory data yet. An admin created key with the Suggestions capability reads it. - An integration key resolves your workspace's accounts. Members can get their own API tokens: a signed in member consents in Co-Signal to the scopes an app asks for, for a workspace in the API rollout.
- Overlapping partners:
GET /api/v1/accounts/{accountId}/overlapping-partnerslists the partners that also have one of your accounts, with your own partner tags; ordering by score is an admin view, so workspace admins also see each partner's score where one is stored and can sort by it, while a member sees partners only for accounts they own or claimed, always in name order and without a score, even when the member asks forsort=score. - Connecting an assistant: add
https://app.co-signal.com/api/mcpas a custom connector in the Claude web app or Claude Desktop, withclaude mcp add --transport httpin Claude Code, or as a custom MCP server in ChatGPT, sign in with Google and approve the access you want to give. - A connection that holds only the original Co-Signal scopes is answered with five tools (
find_overlap_opportunities,get_account_context,get_partner_context,summarize_attribution,create_cosell_draft), and the MCP server speaks protocol versions 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. - An assistant can consent to the ecosystem tools for a workspace in the API rollout: whoever approves the connection chooses which ecosystem scopes to give, and a tool is listed to a connection only when its scopes were given, among them
find_overlapping_partners,get_own_account_records,get_partner_overlaps_shared_context,list_partnersandget_ecosystem_signals; a connection that holds only the original Co-Signal scopes keeps the five tools above and no others. - Client ID Metadata Documents are supported: an assistant that identifies itself with one keeps the same identity on every connection, so an admin's approval of it for organization wide powers keeps applying, while an assistant that registers a new client on every connection (dynamic registration) needs approving again each time.
- The authorization server metadata carries one member for them,
client_id_metadata_document_supported, which is audience neutral: a delegated REST client reads it too and ignores it. - Protocol version 2026-07-28 is available: a request that carries it in its
MCP-Protocol-Versionheader and in the protocol version metadata of the request is answered in that version, one request at a time and without a handshake, while a client on an older version keeps the version it negotiates, and a connection that asks for 2026-07-28 in itsinitializehandshake is given 2025-11-25; on 2026-07-28 the server does not push messages, sosubscriptions/listenis answered with the JSON-RPC error-32601. - Searching Co-Signal's own documentation over REST:
GET /api/v1/knowledge/searchanswers a person's own delegated token that holds the knowledge scope, and no integration key can be given that scope. - Searching Co-Signal's own documentation from an assistant: the
search_cosignal_knowledgetool searches the same reviewed corpus asGET /api/v1/knowledge/searchand cites every answer, for a connection that was given the knowledge scope.
What is not available yet
- Usage reporting and audit logs are not part of this release.
- A member's own API token reads only the accounts and leads that member owns or claimed, and their contacts, opportunities and overlaps, on every read, the same view the app shows them; an admin created key or a signed in admin reads the whole workspace. That covers account resolution and detail and lead resolution too. The Co-Signal app now applies the same ownership boundary to a member's accounts page and direct account detail, including View as member; members do not have a Team tab.
- Contacts and opportunities are top level feeds only, so there are no per account sub resources other than
GET /api/v1/accounts/{accountId}/context; reading one account of your own isGET /api/v1/accounts/{accountId}. - Every key of your workspace sees every one of your partnerships, because there is no per partner key restriction yet.
- CRM commands are built but not switched on yet:
POST /api/v1/crm/accounts/{accountId}/push-fieldsandPOST /api/v1/crm/overlaps/{overlapId}/syncare Co-Signal extensions that will write overlap data into your connected CRM through Co-Signal's destination, after a preview whose hash you send back to execute.
Details worth knowing
GET /api/v1/populationsis one feed carrying both account and lead populations, told apart byresourceType.GET /api/v1/populations/{populationId}reads one of your own population definitions by the id the list returns; like the list, it answers only an admin created key or a signed in admin, and a member's token gets the same404 not_foundas an id that was never issued.- Your chosen custom field values are served on
GET /api/v1/leads/{leadId}only: a contact carries no chosen field, and chosen opportunity values are stored but not served, which the opportunities feed states withchosen_field_values_not_served. meta.sourceUpdatedAtis the latest CRM edit time among the rows of your own account, contact, deal and lead reads, and null on every other feed (change feed and export rows carry their own) and onGET /api/v1/accounts/{accountId}/context.- An account, lead, contact or deal whose stored name is blank is served with a stated placeholder name such as
Unnamed company, and where the row carries provenance it marks that name asabsent. - The one exception is
GET /api/v1/accounts/{accountId}/context, which still answers503 temporarily_unavailablewhen your own account's stored name is blank, whereGET /api/v1/accounts/{accountId}serves that account asUnnamed companywith anabsentname provenance. - On
GET /api/v1/accounts/{accountId}the freshness keys mean what they mean on the other account reads:sourceUpdatedAtis the CRM's edit time, null withno_crm_source_timestampwhen none is stored,observedAtis the stored sync time, andcomputedAtis when this answer was computed. - Account name search matches within the same leading characters only and is tolerant of case, punctuation and legal suffix words, so a stored
Acme, Inc.answers a query ofacme, and it never does substring matching or similarity ranking; a name that matches several of your accounts returns every match. - On
GET /api/v1/accounts/{accountId}/context, a partner entry'sidis the partner's matching account and itsnameis the partner workspace's name. - On
GET /api/v1/accounts/{accountId}/context, a partner'scontactsappear only with the Contacts capability and only when that partner shares contacts with you. Each partner contact also says whether it is a shared contact, a person you both hold, which is only ever true when you and your partner both share contacts with each other. - A population whose
updatedAtequals itscreatedAthas never been edited. - An opportunity
amountcarries the sign your CRM gave it, so a negative amount is a valid credit, reversal or churn deal, and an account'sopenOpportunityValueis a sum of those amounts, so it can be negative too. - A lead's
owneris omitted when the lead has no owner, and on your own reads nothing is withheld, so an omitted owner means there is none. - Length limits in the API reference are response size guards, not validation rules to rely on.
- Route names are Co-Signal's own, and this API does not promise wire compatibility with any other vendor's API.
- A partnership that ends and is later created again starts on the default channel settings (in-app and AI on, Automation and Export off), so a choice either side made for the earlier partnership does not carry over.
- Every disclosed value carries its
provenance:provideris the CRM that asserted it,sourceFieldthe CRM's own field name,sourceUpdatedAtwhen the CRM last changed it (null when the CRM gives no per value time),observedAtwhen Co-Signal observed it, andmappingVersionthe field catalog mapping that produced its key; each lead value onGET /api/v1/leads/{leadId}carriessourceFieldandmappingVersionbeside itssource,sourceUpdatedAtandobservedAt. - A Salesforce id is matched in its 15 or 18 character form on both
GET /api/v1/accounts/resolveandGET /api/v1/leads/resolve, so an id copied from a Salesforce page finds the record; its first fifteen characters are case sensitive, and a HubSpot or CSV id is matched exactly. - A partner that has not switched on Automation for your partnership contributes nothing to
GET /api/v1/partner-populations, exactly as a partner that shares nothing, and a lead population'soverlapappears only once lead overlaps are available for your workspace. - On
GET /api/v1/overlaps/leads/searchyou can narrow your lead overlaps by your own lead population, an incoming shared population, a partner, a partner tag, the match type and the kind of partner record, and filter or sort on a field only when the partner you name shares it with you and it is marked filterable; any other field is refused exactly like an unknown one, and a value a partner does not share never satisfies a filter or moves a row. - An integration key reads only the reports shared with your workspace, and a report's rows are computed for whoever asks, so a member's own token sees only the accounts that member owns or claimed.
- API reads follow each partner's channel settings: a partner who has not switched on automation for your partnership contributes no rows over the API, while the same report or list in the app shows them. Every report and list response says this, whether or not anything was withheld.
- An integration key and an admin read every list of your workspace, and a member's own token reads the lists shared with members.
- A list's rows over the API are computed for whoever asks, so a member's token sees only the accounts that member owns or claimed, while the list page in the app shows a shared list's rows in full.
- Every list and report detail carries
link, its authenticated Co-Signal link: it opens the list or report in the app only for a signed in member of your workspace who may see it, checked when it is opened, and there is no public or anonymous share link. - Only an integration key or a workspace admin can create, change or delete lists over the API; a member's token is refused exactly as a key without the scope, and every change is attributed to the key or the person and recorded.
- When a partner shares nothing with you, or nothing that matches, the answer is the same empty page, so a result never hints at what a partner keeps private.
- Greenfield candidates are an admin view in Co-Signal, so a member's own token is answered the same empty page. Send
explain=trueto get a short explanation on each of the first five rows of a page: it only restates that row's own computed factors and never changes the order. - A partner that shares nothing relevant, or that has switched the API off for your partnership, contributes no recommendation and no score, and every one of those causes answers the same empty page or the same not found, so a result never hints at what a partner keeps private. A partner's score is an admin view in Co-Signal, so a member's own token is answered an empty context; a member's recommendations cover only the accounts that member owns or claimed. A ratio in a partner's score context whose denominator is zero is null, never zero.
- Activity lists your own workspace's co-sell requests, so partners' channel settings do not change it; attribution follows them like every other partner figure. Attribution is an admin view in Co-Signal, so a member's own token is answered an empty page; a member's activity covers only the requests that member sent and the received requests on accounts that member owns. Activity never lists who on your team acted.
- Suggestions are an organization wide read: a key, or an admin whose app your workspace approved for it, can call them. Every other caller, a member included, is refused exactly as a key without the scope is. A suggestion says whether the company is an
opt_inlisting or alicensedone, and a licensed one names the provider and the observation date of each field it shows. An answer states how many companies were counted inmeta.coverageand never the size of any wider network, and an empty answer saysno_candidatesand never why. A company that leaves the directory, and a licensed record that is withdrawn, is gone from the very next answer. - A timeline note is internal to your workspace: it is never sent to a partner and never triggers a message or a CRM write. Every entry names who or what made it.
- Send an
Idempotency-Keywith every note so a retry never adds it twice. A note is 1 to 1,000 characters after trimming, and an account you cannot see answers the same404 not_foundas one that does not exist. - Every command needs an
Idempotency-Key. Retrying with the same key and body returns the first result; the same key with a different body is refused. - A draft or a request created over the API is a pending item: nothing reaches a partner until a member of your workspace approves it in Co-Signal within seven days, and
GET /api/v1/actions/{actionId}shows it aspending_approvaland then assucceededwith the request it sent. A key's request is never sent by an automation policy, and no policy ever sends a draft. - An action's status is shown only to the key or app that started it, or to a workspace admin acting through a delegated token. Any other caller gets the same
404 not_foundas an action that does not exist. - CRM commands are off by default. An admin switches each one on for each approved app in Settings > API. Integration keys can never run them, and a command whose preview changed is refused with
409 preview_staleso you can preview again. - A CRM command's preview lists the target records and the CRM field names it would write, with a hash, and never a value. Send an
Idempotency-Keywith the execute so a retry never writes twice. - Changes, deletions and what cannot be recalled:
- Start a change feed with a snapshot: call
GET /api/v1/changes?resource=accountswithout a cursor, followpage.nextCursorto the last page, and keep thebookmarkthat page returns. The resources areaccounts,contacts,opportunities,leads,account_overlapsandlead_overlaps. - Each later call with
bookmarkreturns what changed since, oldest first, with a new bookmark to keep. A bookmark belongs to the credential, workspace, resource and query that created it, and one credential should page one bookmark at a time. - A bookmark stays valid for 30 days, and change history is kept for 30 days. Poll within 30 days: each call returns a new bookmark, even when nothing changed. An older bookmark answers
410 cursor_expired; a bookmark whose view changed, because the credential's role or scopes changed, answers410 resync_required. Either way, take a new snapshot and replace your copy. - The feed sends a record only when what your credential can see of it has changed since it last received it, so a client that discards its copy takes a new snapshot rather than rewinding a bookmark.
- If a page's response never reaches you, call again with the same bookmark or cursor: what that page sent is sent again, and the bookmark you present next acknowledges it.
- A row whose stored value cannot be served within the response limits, such as a CSV name longer than 500 characters, is left out of a snapshot or change page, which then lists
rows_withheld_unservableinmeta.limitations; that row arrives once a later change makes it servable. - Overlap changes are sent per overlap: one item for each of your records and each partner it overlaps with.
- When something you received is no longer available to you, the feed sends a tombstone: its id and when it changed, with no data and no reason. A tombstone looks the same whether the partner stopped sharing it, it left a shared population, it was deleted, or the partnership ended. Records you never received produce nothing.
- Only synchronized copies are tracked: snapshots and change feed pages record what reached your credential, while ordinary list, detail and assistant reads record nothing and never produce tombstones.
- Revoking a key, removing a member or disabling a workspace stops its reads and any queued delivery immediately.
- Co-Signal cannot recall data that has already been delivered to another system. Tombstones tell a synchronizing client what to delete; acting on them is that client's responsibility.
- Changes made inside Co-Signal, such as a claim or an ownership correction, reach the feed after the next CRM sync, at most six hours later.
- In the overlapping partners answers (
GET /api/v1/accounts/{accountId}/overlapping-partners, and thefind_overlapping_partnersassistant tool), a partner that shares only counts with you is simply not listed, the same as a company you have no partnership with; there, over the API a partner is listed only when it allows Automation access, and through an assistant only when it allows AI access. - Claude (the web app, Claude Desktop and Claude Code) and ChatGPT are the assistants this server is built for; other clients, such as Cursor, VS Code and Gemini, may work through the standard protocol but are untested.
- Client status: the Claude web app is not supported yet, Claude Desktop is not supported yet, Claude Code is not supported yet and ChatGPT is not supported yet; Claude Code and ChatGPT were each walked against this server in production on 2026-11-04 and negotiated protocol version 2026-07-28, the Claude web app and Claude Desktop have not been walked yet, no client is listed as supported, and a client not named here is untested.
- When an assistant can ask for more than the original access, whoever approves it chooses what to give: a connection that holds only the original Co-Signal scopes keeps the original five tools and no others, and a new tool is listed to a connection only when its scopes were given.
- Closing the connection cancels a call that is still running, and a separate cancel message cannot reach a call already running on another instance, so cancel by closing the connection.
- For the ecosystem write tools (for example
create_list,add_account_noteand, once the CRM commands are switched on, the CRM command tools), a cancel that arrives before a write starts is answered as cancelled and charges nothing, while a cancel that arrives after the write has completed leaves the write in place and the call is answered and charged normally, and so does a cancel that lands while the write is running, which resolves to success; a CRM command sent withpreview: truewrites nothing, so a cancel that arrives late is answered as cancelled and charges nothing; a repeat of a write with the same idempotency key that is cancelled late is answered as a success and charges nothing, because the write it repeats was committed and charged the first time; a cancelled read charges nothing. - Every call must finish within 10 seconds, so lists are paged: through the ecosystem tools a list answer is never longer than 16 KiB, a page can end before
limitrows, andnextCursorresumes where it stopped until it is null. - A call still running at the 10 second deadline ends with the JSON-RPC error
-32000and the messageThe request deadline was reached.: on 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05 a call is answered with an event stream that begins at once, so the stream ends with that one error and the request id, on 2026-07-28 the call is answered with HTTP 408 and that error with a null id, and on every version a request whose deadline passes before its answer begins gets HTTP 408 with the same error. - A write tool still running at the 10 second deadline can commit and be charged even though the client reads
-32000, so after that error send the same call again with the sameidempotencyKey: the repeat answers the result of the first write (the same draft, list, note or action) and charges nothing. - A request already cancelled before the server begins it is answered with HTTP 499 and no body, on every served version.
- A call to a tool name the connection does not have, whether the name never existed or its scopes were not given, gets the same answer for both, worded by the version: on 2025-11-25 and the older versions a tool result marked
isErrorthat readsMCP error -32602: Tool x not found, and on 2026-07-28 the JSON-RPC error-32602with the messageTool x not found, where x is the name that was called. - The CRM command tools
push_crm_account_fieldsandsync_crm_overlap_recordare Co-Signal's own extensions: once assistants can use them they stay off by default, an admin switches each one on for each approved app, and each acts only after a preview. - Signal feeds are readable over REST and through the
get_ecosystem_signalstool, which reads one feed at a time (accounts,leadsorown_deals), serves thesev_id of each source event, and serves a removed overlap as anoverlap.removeditem that carries only the overlap id and its kind. - Through an assistant a signal feed equals the REST feed only for partners whose partnership allows both Automation and AI access: for any other partner it serves fewer items, because a partner that withholds AI access is absent, except that a removal is served whatever channel the partner allows, and no feed serves an attribution event.
- A signal page is sized before a member's own records are chosen, so a member can receive a short or empty page while
hasMoreis true: keep followingpage.nextCursoruntilhasMoreis false. - The
list_partnerstool lists your current partnerships with your own tags and finds one by name; an ended partnership is never listed or named, and a score appears for a workspace admin only. - The
get_own_account_recordstool reads your own accounts with no partner data, each with its domain, type, open deals and last CRM change, and it does not return an account's owner, populations or chosen field values. - The
get_partner_overlaps_shared_contexttool shows one partner's records that overlap one of your accounts or leads, with only the fields that partner shares with you and where each value came from. - In that tool's answers an overlapping account is named by the partner company alone, with no partner account, so several overlapping accounts with one partner read as entries with the same label, told apart only by their shared fields; an overlapping lead names the partner lead or account, as REST does.
- That tool shows at most 30 shared fields for one record, and an entry carries
moreSharedFields: truewhen the partner shares more than it shows, which no page can reach. - On its lead branch that tool lets a member search only the leads they own, although its coverage label still reads workspace.
Changes to this API
Each change a caller, an admin or a workspace member can notice, newest month first.
November 2026
- The guide now states the status of each client this server is built for: the Claude web app, Claude Desktop, Claude Code and ChatGPT are each listed as not supported yet, Claude Code and ChatGPT were each walked against the MCP server in production on 2026-11-04 and negotiated protocol version 2026-07-28, and no client is listed as supported; no route, tool or answer changes.
- The MCP server now also serves protocol version 2026-07-28: a request that carries it in its
MCP-Protocol-Versionheader and in the protocol version metadata of the request is answered in that version, and a client on an older version keeps the version it negotiates and sees no change. - Members can now consent to API tokens of their own, for a workspace in the API rollout, and an assistant can be given the ecosystem tools through consent in Co-Signal, with only the least privilege scopes those tools need and no CRM writes; an assistant can also identify itself to the MCP server with a Client ID Metadata Document; and bulk exports (
POST /api/v1/exportsand its status, cancel and download routes) are now available to the workspaces they are enabled for, instead of503 feature_disabled. - The guide now describes connecting an assistant to the MCP server at
https://app.co-signal.com/api/mcp, the five tools it answers with today, the protocol versions it speaks, what is not available yet for assistants and the details of cancelling a call, the 10 second deadline and signal feeds; no route, tool or answer changes.
October 2026
GET /api/v1/mereturns who is calling: the key's name or the signed in person's public id, display name and current role, the one workspace the credential belongs to, and the scopes it holds.GET /api/v1/workspacesreturns the one workspace your credential belongs to, andGET /api/v1/workspaces/current/memberslists the current members' public id, display name, work email, role and joined date to an admin created key with the Member directory capability; a member's own API token cannot read the directory.- An admin creating a key at Settings > API can now choose the Identity and Member directory capabilities, so a key can reach
GET /api/v1/meand the member directory. - Each entry of a partner's
customFieldsonGET /api/v1/accounts/{accountId}/contextnow carries itsprovenance, and each lead value gainssourceFieldandmappingVersiononGET /api/v1/leads/{leadId}. - A partner can now share an account's standard industry, employee count and annual revenue with you by ticking them on a sharing rule above counts; each appears in that partner's
customFieldsonly while the account holds a value for it. GET /api/v1/leads/resolvenow also takes a personname(an exact match first, then names beginning with the same characters, at most ten candidates) or anemailaddress one of your own leads holds, and returns every match as a candidate.- Both
GET /api/v1/accounts/resolveandGET /api/v1/leads/resolvenow match a Salesforce id in its 15 character form as well as its 18 character form, so a 15 character id from a Salesforce page finds the record. GET /api/v1/partner-tagslists the partner tags your admins created, read only, and every partner row fromGET /api/v1/partnersandGET /api/v1/partners/{partnerId}now carriestags, the tags your workspace applied to that partnership; the partner you tagged never sees them.GET /api/v1/populations/{populationId}reads one of your own account or lead population definitions by thepop_idGET /api/v1/populationsreturns, in the list's row shape; a member's token, and any id you cannot read, gets the same404 not_foundas an id that was never issued.GET /api/v1/partner-populationslists the populations your partners share with you, each with its name, kind, the sharing level granted to you and your own overlap count, and never the partner's definition or the population's size;GET /api/v1/partner-populations/{partnerPopulationId}reads one by itsppop_id, and both answer only an admin created key or a signed in admin.GET /api/v1/overlaps/leadslists each of your own leads with the partner leads and accounts it matches that the partner shares with you.- A member's own API token now reads only the records of the accounts and leads that member owns or claimed on
GET /api/v1/leads,GET /api/v1/leads/{leadId},GET /api/v1/overlaps/accounts,GET /api/v1/accounts/{accountId}/context,GET /api/v1/contactsandGET /api/v1/opportunities, the same boundary account and lead resolution already apply; an admin created key and a signed in admin still read the whole workspace. GET /api/v1/overlaps/leads/searchnarrows the lead overlaps feed by population, partner, partner tag, match type and partner record type, and filters or sorts on the fields a partner shares with you that are marked filterable.GET /api/v1/overlaps/accounts/searchsearches your account overlaps by your own account population, a population a partner shares with you, a partner or a partner tag, and, with one partner named, filters or sorts on the account fields that partner shares with you and marks filterable; each entry also names the partner's shared populations that hold the pair and carries the fields the partner shares, andGET /api/v1/overlaps/accountsis unchanged.- Each partner contact on
GET /api/v1/accounts/{accountId}/contextnow carriessharedContact, which is true only when you and that partner both share contacts with each other and the contact's work email matches a contact you hold at your own account; the email itself is never returned. - Lead overlaps are now switched on for the workspaces in the API rollout:
GET /api/v1/overlaps/leadsandGET /api/v1/overlaps/leads/searchanswer with your lead overlaps instead of503 feature_disabled, and a lead population a partner shares with you at the overlapping or all level now shows you the lead overlaps it covers, through each channel that partner has switched on for your partnership. - The change feed is now switched on for the workspaces in the API rollout:
GET /api/v1/changesanswers with snapshots, change bookmarks and tombstones for accounts, contacts, opportunities, leads and account overlaps, and for lead overlaps where they are switched on, instead of503 feature_disabled. GET /api/v1/reports,GET /api/v1/reports/{reportId}andGET /api/v1/reports/{reportId}/rowsread the saved reports you may see, with each report's rows recomputed under today's sharing on every call, and an admin creating a key at Settings > API can now choose the Reports capability.GET /api/v1/lists,GET /api/v1/lists/{listId}andGET /api/v1/lists/{listId}/rowsread the lists you may see, with each list's rows recomputed under today's sharing on every call; every list and report detail now carrieslink, its authenticated Co-Signal link; and an admin creating a key at Settings > API can now choose the Lists capability.POST /api/v1/lists,PATCH /api/v1/lists/{listId}andDELETE /api/v1/lists/{listId}create, change and delete lists with anIdempotency-Keyand the version you read asIf-Match; the list detail now carriesversionand itsETag; and an admin creating a key at Settings > API can now choose the Manage lists capability.GET /api/v1/greenfieldreads the new account candidates for one partner in two modes,top_prospectsandrecent_partner_customer, with filters on the firmographic fields the partner shares with you, optional explanations on the first five rows, and the same empty page whenever a partner shares nothing; and an admin creating a key at Settings > API can now choose the Intelligence capability.GET /api/v1/recommendationsranks which partner to involve on an open opportunity, with the score, the factors, the evidence and the window behind each row, andGET /api/v1/partners/{partnerId}/contextreturns a partner's score with its five dimensions, what each measures and what every ratio is divided by; both take optional explanations on the first five rows, and the Intelligence capability a key already has reads them.GET /api/v1/attributionreads each partner's stored attribution rollup, the opportunities it is credited on, its primary credits and their value, andGET /api/v1/activityreads your co-sell activity newest first with its period summary; and an admin creating a key at Settings > API can now choose the Attribution and Activity capabilities.GET /api/v1/accounts/{accountId}/timelinereads the timeline of one of your accounts, newest first, andPOST /api/v1/timeline/entriesadds an internal note with anIdempotency-Key; and an admin creating a key at Settings > API can now choose the Timeline capability.POST /api/v1/draftsandPOST /api/v1/requestscreate an item a named person approves in Co-Signal before anything reaches a partner,GET /api/v1/actions/{actionId}follows it, andPOST /api/v1/requests/{requestId}/accept,/declineand/closeanswer on a person's own token; and an admin creating a key at Settings > API can now choose the Drafts and Requests capabilities.POST /api/v1/crm/accounts/{accountId}/push-fieldsandPOST /api/v1/crm/overlaps/{overlapId}/syncare built to push one account's overlap fields, or sync one overlap record, into your connected CRM after a preview, but they are not switched on yet: no key can run them, and an admin will switch each one on for each approved app in Settings > API.GET /api/v1/suggestionslists companies from the Co-Signal directory, ranked on your own workspace data with the evidence and the coverage behind each answer, and an admin creating a key at Settings > API can now choose the Suggestions capability.POST /api/v1/webhook-subscriptions,GET /api/v1/webhook-subscriptions,GET,PATCHandDELETE /api/v1/webhook-subscriptions/{subscriptionId},POST /api/v1/webhook-subscriptions/{subscriptionId}/testandPOST /api/v1/webhook-subscriptions/{subscriptionId}/rotateregister, list, read, change, delete, test and rotate the secret of webhook subscriptions, which send you thin, signed events after your endpoint answers a challenge, with anIdempotency-Keyon create and rotate, the secret shown once and the Webhooks capability for a key at Settings > API, and webhooks are available for workspaces in the API rollout.GET /api/v1/webhook-subscriptions/{subscriptionId}/deliveries,GET /api/v1/deliveries/{deliveryId},POST /api/v1/deliveries/{deliveryId}/replayandPOST /api/v1/deliveries/{deliveryId}/discardlist the deliveries of a webhook subscription, read one, send a delivery that ended again with the same delivery id and event time, and discard a dead letter, and a refused replay or discard is the new409 delivery_not_replayable, and these routes are available for workspaces in the API rollout.GET /api/v1/signals/accounts,GET /api/v1/signals/leadsandGET /api/v1/signals/own-dealsread the overlap, partner deal and own deal signals as pull feeds with a snapshot, a bookmark and the change feed's own410answers, so a system that missed webhooks can catch up, and the accounts and leads feeds also requireecosystem:partners:read; an admin creating a key at Settings > API can choose the Signals capability once a feed is enabled for the workspace, and the accounts, leads and own deals feeds are available for workspaces in the API rollout.attribution.updatedis a new webhook event type and an item ofGET /api/v1/signals/own-deals: it fires after attribution is recalculated when a deal's credit kind, its credited partners or its value changes, and it is the one event that names partners and carries a value, in the currency of your CRM; a subscription needsecosystem:attribution:readandecosystem:partners:readbeside the signals and opportunities scopes, and the feed serves the item only to an admin principal that holds the two, and attribution events are available for workspaces in the API rollout.PATCH /api/v1/webhook-subscriptions/{subscriptionId}withstatusset toactivenow also restarts the handshake of a subscription stillpending_verification, and a deal whose credit disappears entirely now firesattribution.updatedwith kindnoneandpartners: [], as this guide describes.- The MCP tool
summarize_attributionno longer counts intotalDeduplicatedValuea deal whose only credited partners are withheld from the AI channel, so such a partner cannot be inferred from the total; a deal shared with a partner the channel permits and the credit an ended partnership earned still count, and no field, scope or tool is added or removed. GET /api/v1/accounts/{accountId}/overlapping-partnerslists the partners that also have one of your accounts, one row for each partnership, with your own partner tags; it needs both theecosystem:accounts:readandecosystem:partners:readscopes, a partner is listed only when it allows Automation access, and workspace admins also see each partner's score and can sort by it while a member's list is in name order.
September 2026
GET /api/v1/accounts/resolvereads your own accounts from exactly one selector per request, a companydomain, a CRM reference (sourceandsourceRecordIdtogether) or a companyname, and returns every match as a candidate rather than choosing one; a selector that names an account you cannot see answers with no rows, exactly as a selector that names nothing anywhere does.GET /api/v1/accounts/{accountId}reads one of your own accounts in the accounts list's row shape, and an id you cannot see answers the same404 not_foundas an id that was never issued, so an absent id and an unauthorized one are indistinguishable.- In the Co-Signal app, the possible match review queue no longer shows a partner account's name or domain unless that partner shares with you in app, and a withheld row still reads as a match against an unnamed partner account that can be confirmed or rejected.
- The token endpoint accepts HTTP Basic authentication for the client credentials grant, and a request that sends the key both with HTTP Basic and in the form fields is refused with
400 invalid_request. - One key can exchange for a token at most 60 times a minute, and the next exchange in that minute is refused with
429 rate_limitedandretry-after. - A token requested with a narrower
scopenow carries only the scopes asked for, where before the requestedscopewas checked against the key but did not narrow the token. sourceUpdatedAtis now null, withno_crm_source_timestamp, on the accounts list, the account overlaps feed, the account context and the three lead routes, where it used to carry Co-Signal's own sync time; that time is now each row'sobservedAt.- An account whose stored name is blank no longer makes
GET /api/v1/accountsorGET /api/v1/overlaps/accountsanswer503 temporarily_unavailable; it is served asUnnamed company. - Partner entries on
GET /api/v1/accounts/{accountId}/contextalways carrycustomFields, which holds the account fields a partner has chosen, ticked for you and shares through Automation, and is empty otherwise. - The discovery documents list only what is served: the authorization server metadata names the client credentials grant with
client_secret_basicandclient_secret_post, and the API's protected resource metadata lists the seven scopes the served routes require instead of 25. - A key's Last used time at Settings > API now moves only for a call that got past the rollout check and the rate limit.
- For an AI assistant that a workspace member connects through MCP, the five Co-Signal tools now answer only from the accounts that member owns, the accounts listed under Mine in My accounts, withhold partner scores and attribution, and say so in a
notice; for admins the one change is that a partner who switched AI off no longer contributes a partner score.
Find out what you share.
Building on Co-Signal for your partnerships? Book a call and we'll walk through the API with you.
About 30 minutesReply within one business day
