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/jsonThe 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.
keyIdis 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 indextenantId_activeSlot_uniquerefuses 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"
}| Field | Required | Notes |
|---|---|---|
externalId | no | The sending system’s own id. See Retries below. |
customer.email | yes | Resolves an existing customer, or provisions a new one. See How the customer is admitted. |
customer.* (the rest) | no | Name, company and location, stored on a newly provisioned customer. |
product | yes | An id, or a name matched among the tenant’s active products. |
service | yes | Same. Must be linked to the product where the catalogue links exist. |
skills[] | no | Same, against the service. Up to 20. |
description | no | Up to 5,000 characters. |
bidDeadline | no | ISO-8601. |
source | no | Where 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
| Status | Body | Meaning |
|---|---|---|
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-After | Invitation 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:
| Value | What happened |
|---|---|
joined | The address had no account. One was created and added to your organisation. |
invited | The address already had an account. It was invited, not added. |
already-member | The person was already in your organisation. Nothing was granted. |
not-provisioned | No 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.