Skip to Content
TechnicalDevelopmentInbound lead webhook

Inbound lead webhook

An external system — a vendor’s CRM, an automation in Zapier, a workflow in HubSpot — posts a lead to Sustentus and gets back the request number it was given. The lead is indistinguishable from one typed into /service-leads/create: same sequential requestId, same pending/backlog initialisation, same activity trail, same downstream journey.

This is the reference an integrator needs. The channel itself is described in lead intake.

Endpoint

POST /api/webhooks/leads Authorization: Bearer slw_<keyId>_<secret> Content-Type: application/json

The route authenticates itself and carries no user session, so it is listed in the public matcher in apps/web/proxy.ts beside the Clerk webhook and the cron routes. Any method other than POST is rejected.

The credential

An admin issues a key from Admin → Settings → Integrations. The token is shown once, at the moment it is generated, and cannot be recovered afterwards — only a SHA-256 of its secret half is stored, so a database read never yields a working credential.

  • keyId is the public half. It travels in the token, identifies the key, and is what resolves the tenant; it is safe to display and is what the settings card lists.
  • A tenant may hold two active keys at once, so a rotation is an overlap rather than a cliff: issue the new key, switch the sending system across, then revoke the old one. The database holds the limit, not a count: each active key occupies one of two numbered slots (activeSlot), and the unique partial index tenantId_activeSlot_unique refuses a third — so two admins generating at the same moment get one key and one “already have 2 active webhook keys” refusal.
  • Revocation takes effect on the next request, and frees the key’s slot. Revoked keys are kept, not deleted — the delivery log points at them.
  • The settings card lists every active key and the newest revoked keys, 10 rows at most, and says how many older revoked keys it hid.

Every credential failure — a missing header, a malformed token, an unknown key, a wrong secret, a revoked key — answers the same 401 with the same body. The endpoint deliberately reveals nothing about whether a tenant or a key exists.

Payload

One canonical schema. Mappers that translate a Salesforce or HubSpot body into it are not part of this endpoint.

{ "externalId": "hubspot-deal-8823", "customer": { "email": "ada@northwind.example", "firstName": "Ada", "lastName": "Byron", "companyName": "Northwind Ltd", "region": "EMEA", "country": "BE" }, "product": "Cloud Migration", "service": "Readiness Assessment", "skills": ["Terraform"], "description": "Two-region Azure move, 40 workloads.", "bidDeadline": "2026-10-01", "source": "crm" }
FieldRequiredNotes
externalIdnoThe sending system’s own id. See Retries below.
customer.emailyesResolves an existing customer, or provisions a new one. See How the customer is admitted.
customer.* (the rest)noName, company and location, stored on a newly provisioned customer.
productyesAn id, or a name matched among the tenant’s active products.
serviceyesSame. Must be linked to the product where the catalogue links exist.
skills[]noSame, against the service. Up to 20.
descriptionnoUp to 5,000 characters.
bidDeadlinenoISO-8601.
sourcenoWhere the lead came from — one of the twelve ids below. Absent or unknown → sales.

product, service and skills[] accept either an ObjectId or a name. Names match case-insensitively and must be unambiguous: if two active records share the name, the request is refused rather than guessing, and the reply says to send the id instead.

source is one of: sales, product-trial, customer-success, marketing, partner, website, crm, api, support, professional-services, ai, product-onboarding — the id, not the label the platform shows (product-trial, not “Product trial”). It is lenient on purpose: a payload without it, or with a value that is not one of the twelve, is accepted and the lead recorded as sales, so a typo never costs a sender the lead. The source is not the channel — every lead this endpoint creates still records intakeChannel: webhook — and it changes no ownership: every source is owned by Sales until the BRD is approved.

A lead created through this channel inherits any region and country the customer profile carries when the request itself supplies none — the same inheritance rule the other intake channels apply — so a customer.region/customer.country posted here lands on the lead as well as the customer, and a later customer profile location change backfills the lead.

The body is capped at 32 KB.

Responses

StatusBodyMeaning
201{ ok, duplicate: false, leadId, requestId, status, customerAdmission }The lead was created.
200{ ok, duplicate: true, leadId, requestId, status, customerAdmission }This externalId was already received.
400{ ok: false, error: "malformed_json" }The body is not valid JSON.
401{ ok: false, error: "unauthorized" }Any credential failure.
413{ ok: false, error: "payload_too_large" }Over 32 KB.
422{ ok: false, error: "validation_failed", fields: [{path, …}] }Every invalid field is named, not just first.
429{ ok: false, error: "invitation_limit" } + Retry-AfterInvitation limit reached. See The invitation limit.
500{ ok: false, error: "server_error" }A fault on our side. See Retries.

How the customer is admitted

customer.email decides who the lead belongs to, and the reply’s customerAdmission says what the request did about that person’s place in your organisation. It has four values:

ValueWhat happened
joinedThe address had no account. One was created and added to your organisation.
invitedThe address already had an account. It was invited, not added.
already-memberThe person was already in your organisation. Nothing was granted.
not-provisionedNo account step was taken — the address already has a customer record, or a duplicate (below).

invited is not a member. An address that already has an account belongs to a real person, and posting their email address is not their consent to join your workspace — so they are sent an organisation invitation and become a member only by accepting it. Until then the lead exists and is pending, the customer shows as inactive, and nobody has been added to anything. An accepted lead is therefore not proof of a reachable contact inside your workspace: read customerAdmission before treating one as such.

The same rule covers adding a customer by hand from Service leads → Create → Invite customer: an address that already has an account is invited rather than added, the form says so, and the customer row keeps only the name and address the vendor typed until the person accepts. Invitations sent from the form do not count toward the limit below.

An address your organisation already holds is reused. If a customer record in your organisation already has the address, the lead attaches to that record — whichever sign-in account it is linked to — and no account step is taken: no lookup, no invitation, nothing counted against the invitation limit. The reply says not-provisioned.

An address an archived record holds is refused. If an archived (or otherwise hidden) record in your organisation already holds the address, the delivery is answered 422 validation_failed with a customer.email field error asking an admin to restore or remove that record, or to use another address. No customer and no lead are written. Retrying the same payload will not succeed until the record is dealt with.

On a duplicate, the field describes this request, not the customer. A deduped redelivery returns before any account work, so it answers not-provisioned even when the original delivery invited or joined that person. Take the first delivery’s value as the record.

The invitation limit

An invited admission makes the platform email that address an organisation invitation, so a key must not be able to email whoever its holder names. Each organisation can cause 20 invitations in any rolling 24 hours through this endpoint. A delivery that would send a 21st is refused with 429 and a Retry-After header (whole seconds until the oldest counted invitation leaves the window). It creates nothing: no lead, no customer, no account and no email. Retry the same payload after Retry-After and it is processed normally.

Only invitations count. A new address (joined), an existing member, an address that already has a customer record in your organisation, and every duplicate go through as usual at the limit. The limit is per organisation, not per key, and is not configurable.

Retries and duplicates

Send an externalId. It is what makes a retry safe. A repeated POST carrying an externalId already seen for the tenant creates nothing, changes nothing on the existing lead, and answers 200 with that lead’s original requestId — so a sender that retries after a timeout or a 500 cannot produce a second lead for the same deal. Uniqueness is enforced by an index, not only by a check, so two deliveries racing each other resolve the same way.

Without an externalId there is no way to tell a retry from a genuinely new request, and a retry after a 500 may create a second lead.

Ids are compared exactly: Deal-1 and deal-1 are two different ids.

A 429 is not a failure of the payload: retry it after the Retry-After it carries, rather than dropping the lead.

What an admin can see

The integrations settings card lists recent deliveries with their outcome — accepted, duplicate or rejected — and, for a rejection, why: a bad key reads differently from a bad payload, so an integration that has stopped working can be diagnosed without server logs. An accepted or duplicate delivery also shows what it did about the customer’s account — Invitation email sent to an existing sign-in account marks the deliveries that emailed someone, which are the ones the invitation limit counts. Deliveries recorded before that line existed show none. A delivery refused because an archived record holds the address reads An archived or existing record already holds that address. Deliveries are kept for 30 days.

Refused credentials are counted, not listed one by one. Every 401 against a real key id — a revoked key or a wrong secret, which the log does not tell apart — adds an attempt to one row per key, and that row shows how many attempts there have been since the first. The card lists the 20 most recently active deliveries, so a key that is still being refused stays at the top and takes one line, and genuine deliveries fill the rest. A refusal whose key id matches no key writes nothing. The 30 days count from a row’s first attempt, so a key refused for longer starts a new row.

Last updated on