Skip to content
Pathbound DOCS

Integrate from your backend

Your backend knows who signed up, which account they belong to, which plan that account is on and which deals are open. This page is the set of calls that keeps Pathbound in step with it. Each record is written through its own route and keyed on your own id, and sending the same request twice changes nothing, so you can send it again whenever you are not sure the first one arrived.

Something happensCalls
A user registersPOST /v1/companies for their account, then POST /v1/contacts naming it
An account changes planPOST /v1/companies again, with the new values
A user joins a team accountPOST /v1/contacts naming that account
A deal is opened or movesPOST /v1/deals with your deal id
You load what you already haveThe bulk forms of the same calls
  • An API key from Settings → API keys with the scopes contacts:write, companies:write and, for deals, deals:write. See Authentication.
  • Your own ids: one for each account, one for each user, one for each deal. They should be stable and opaque. See Choose a safe external ID.

First the account, keyed on your account id:

Terminal window
curl -X POST https://api.pathbound.ai/v1/companies \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "acct_51c0e7",
"domain": "northwind.example",
"properties": { "name": "Northwind", "plan": "trial", "seats": 5 }
}'

Then the user, keyed on your user id, naming the account by the same account id:

Terminal window
curl -X POST https://api.pathbound.ai/v1/contacts \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "usr_8f3a2c9e",
"email": "[email protected]",
"properties": { "firstname": "Ada", "lastname": "Lovelace", "registered_at": "2026-10-22T09:00:00Z" },
"company": { "external_id": "acct_51c0e7" }
}'
{
"status": "success",
"message": "Contact created successfully",
"contact_id": "665f1c2ab3d4e5f6012345ab",
"created": true,
"contact": { "…": "the contact record" },
"timestamp": "2026-10-22T09:00:01.000Z"
}
  • Each call is 201 with "created": true when it creates the record and 200 with "created": false when it finds it.
  • A record is found by your id; else it is one with no id yet that holds the domain or the email (a company or a contact your CRM brought in, say), which takes your id; else it is created.
  • A user who signs up with a personal address (gmail.com) still gets an account: a free email provider’s domain is left off the company, which is written all the same.
  • company only looks the account up. Write the account first: a user naming an account that does not exist is refused with 422.

Create a Company and Create a Contact have every field and every case.

If the events snippet is on your app, identify the user there when they sign in, with the same user id:

window.pathbound = window.pathbound || [];
window.pathbound.push(['identify', user.pathboundExternalId, { email: user.email }]);

That ties what they do in the browser, before and after signing up, to the contact your backend wrote. It writes no account and changes no property of an existing contact. See Identify logged-in users.

Send the account call again with the new values. Only what you send changes:

Terminal window
curl -X POST https://api.pathbound.ai/v1/companies \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "external_id": "acct_51c0e7", "properties": { "plan": "pro", "seats": 20 } }'

A change to a user is the contact call again in the same way.

Every member’s contact names the same account id, and every member is linked to that one company:

Terminal window
curl -X POST https://api.pathbound.ai/v1/contacts \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "external_id": "usr_2b9d41", "email": "[email protected]", "company": { "external_id": "acct_51c0e7" } }'

A user who moves to another account is sent with the new account id: they are linked to it and no longer listed under the old one.

Send your deal id and link the deal by the same ids you used above. A deal that already has the id is updated, so the same call opens the deal and moves it later:

Terminal window
curl -X POST https://api.pathbound.ai/v1/deals \
-H "Authorization: Bearer $PATHBOUND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_deal_id": "deal_7731",
"properties": { "name": "Northwind annual", "stage": "proposal", "amount": 24000 },
"contact": { "external_id": "usr_8f3a2c9e" },
"company": { "external_id": "acct_51c0e7" }
}'

Like company on a contact, contact and company here only look records up: a key that finds nothing is a 422. See Create a Deal.

To load the accounts, users and deals you already have, send the same items through POST /v1/companies/bulk, then POST /v1/contacts/bulk, then POST /v1/deals/bulk, up to 1,000 items a request. An item keyed on your id is created or updated, and reported created or updated; a bad item is reported on its own and the others go through. Sending a batch again is safe.

RecordCall
An accountGET /v1/companies?external_id=acct_51c0e7
A userGET /v1/contacts?external_id=usr_8f3a2c9e
A dealGET /v1/deals?external_deal_id=deal_7731

Each returns zero or one record. Reads are metered.

Every call on this page can be sent again with the same body, so retry it when you do not know whether it arrived:

  • a timeout or a dropped connection;
  • a 5xx;
  • a 429 whose resource is v1_api_requests_per_min, after the number of seconds in the Retry-After header.

Do not send the same body again after these. Change the request, or what it depends on:

AnswerWhat it means
400A field is malformed. code, details or error says which.
403The key lacks a scope.
409 from a contact, code: "EXTERNAL_ID_CONFLICT"The email belongs to a contact keyed to another of your ids. contact_id is that contact.
422, code: "COMPANY_NOT_FOUND" or "CONTACT_NOT_FOUND"The account or the user a key names is not there yet: write it, then send the call again.
429 whose resource is contacts, companies or dealsThe workspace’s stored records of that kind reached their limit. Waiting does not lift it.

skipped_traits in a successful answer tells you a field was left out and why, and needs no retry: an email another contact holds, or a domain that is a free email provider’s, is no host name, or belongs to another company. The rest of the record was written.