Outbound Webhooks: Notify an External System on Signing
When a client signs, something usually has to happen outside SalesControl too: creating their space on your platform, opening a record in your management system, kicking off an automation. An outbound webhook does exactly that: on signature (or at a step of an automatic flow) SalesControl calls an address you provide and sends it the contract data plus the fields you choose.
Outbound webhooks are available on the Professional plan and above. They are managed by admins and by members with the “Can edit contract templates” permission.
What it is for
The real example the feature was born from: an agency creates a subdomain on its own site for every client. It used to do it by hand after each signature. Now it has a webhook that calls its own server and sends a subdomain field built from {{customer_name}}.{{customer_last_name}}: the subdomain appears on its own.
Do not confuse it with its opposite: inbound webhooks (leads from your management system, signature from your management system, events) are something that arrives at SalesControl. This is the other direction: something that leaves SalesControl.
Where you set it up
On the contract template, not in the general settings: that way each template can notify a different system. In Contracts › Templates, open a template and scroll to the Webhooks (signature and flows) section.
On a template just created and never saved, the section says “Save the template to add a webhook”. Save once and the + Webhook button lights up.
There is an overview too: Settings › Integrations › Webhooks › Outgoing webhooks lists every one of them with name, template, event, address, status and last outcome, plus an Open the template button to go and edit them. The delivery log lives there as well.
On signature or at a flow step
In the dialog, When it fires picks the event:
- After signing (
contract.signed): fires on its own when the client signs a contract from this template. This is the case the rest of this guide describes. - Flow step (
contract.flow_step): fires only when an automatic flow of the template reaches a Webhook step that picks it, even before signature (for example after a follow-up left unanswered).
The message of a flow step is the same as on signature (below), with event = contract.flow_step plus a flow block:
"flow": { "node_id": "n_3fa81c02", "phase": "main" }
node_idis the flow step;phaseis its column:main(Contract sent),signed(Signed) orexpired(Link expired).pdf_urlis there only if the contract is already signed, otherwise it isnull. Before signature, for a contract sent to a lead,cliente.idandcliente.contract_idarenulltoo.- If your server does not respond (network error, 429 or 5xx), the step retries on its own later, up to four times over a few hours; a webhook that is off makes the step skip.
Address, headers and safety
In the webhook dialog you fill in:
- Name — only for you, to find it again in the list and in the log.
- Address (URL) — where to send the message.
https://only: a plain address is not accepted. Internal network addresses are refused too, with the message “This address is on a private network and cannot be called”. - Headers — optional, up to 10. Use them if the receiving system requires a key (
Authorization, for instance). - Active / Off — a webhook that is off stays configured but is never called.
Header values stay encrypted and are never shown again. Reopening the webhook, the field reads “•••• saved · leave empty to keep it”: leave it alone and the saved value stays, type over it to replace it. If you rename a header, the old value can no longer be found and has to be typed again.
Two checks work quietly on every delivery, not just when you save: the address is re-checked at send time (a new rule applies to a webhook saved months ago too) and redirects are not followed. If your server answers with a redirect, the delivery counts as failed: put the final address in the field.
Some headers are ours and cannot be overridden: content-type, user-agent and anything starting with x-salescontrol-.
Custom fields and variables
In the Fields section you add the Field name and Value pairs your system expects — up to 30, with values up to 2,000 characters. In the value you use the variables from the chips below the field, on their own or mixed with text ({{customer_name}}.{{customer_last_name}}, client-{{customer_id}}…).
| Group | Variables |
|---|---|
| Contact | customer_name (first name), customer_full_name (full name), customer_last_name, customer_email, customer_phone |
| Contract | service_name, contract_value, duration_months, payment_type, currency, start_date, signature_date |
| Agency and team | agency_name, closer_name, setter_name |
| References | customer_id, contract_id, chat_link, pdf_link |
A variable that is known but has no value (a client with no phone, a contract with no setter) becomes an empty string: your system never receives the literal text {{customer_phone}} in place of a number. A misspelled variable, on the other hand, stays as it is: that way the typo shows up in the delivery log and you can fix it.
A field cannot be called event, occurred_at, agency, contract, cliente, servizio, team or flow: those are the standard blocks of the message and must always mean the same thing.
What the message looks like
The call is a POST with a JSON body. Inside sit the standard block and, at the top level next to it, your custom fields: your system reads subdomain and nothing else, without digging into a nested object.
{
"event": "contract.signed",
"occurred_at": "2026-09-18T10:32:07.512Z",
"agency": {
"id": "3f2a…",
"name": "MedWave"
},
"contract": {
"id": "8c41…",
"template_id": "b07d…",
"template_name": "Marketing services contract",
"signed_at": "2026-09-18T10:31:58.004Z",
"valore_contratto": 4500,
"durata_mesi": 6,
"tipo_pagamento": "mensile",
"currency": "EUR",
"data_inizio": "2026-10-01",
"pdf_url": "https://…"
},
"cliente": {
"id": "d914…",
"contract_id": "d914…",
"nome": "Mario Rossi",
"primo_nome": "Mario",
"cognome": "Rossi",
"email": "mario.rossi@example.com",
"telefono": "+39 333 1234567",
"lingua": "it",
"chat_url": "https://portal.salescontrol.app/c/…"
},
"servizio": {
"id": "5ab2…",
"nome": "Campaign management"
},
"team": {
"closer": { "nome": "Luca", "email": "luca@example.com" },
"setter": { "nome": "Jenny", "email": "jenny@example.com" }
},
"subdomain": "mario.rossi"
}
The last line, subdomain, is the example’s custom field: yours go there instead. A value that does not exist arrives as null (for example telefono for a client who was never a lead, or chat_url when the plan does not include the chat). pdf_url is a temporary link to the signed PDF: it lasts seven days, so download the file if you need to keep it.
When the client signs a second contract in parallel (another service while the first one is still running), the message still talks about the same person: cliente.id (and the customer_id variable) is the id you received at their first signature, and name, email, phone, language and chat_url are theirs. cliente.contract_id is the contract just signed instead: the service, closer, setter and amounts in the message are its own. For a client with a single contract the two ids are the same.
Three headers of ours travel with the body: x-salescontrol-event (here contract.signed), x-salescontrol-delivery (the id of that delivery) and the signature, below. To avoid acting twice on the same contract, the reliable reference is contract.id: it stays the same across a second attempt or a Retry.
The x-salescontrol-signature header
Every webhook has its own Signing secret, shown in the dialog with the Copy and Regenerate buttons. The receiver uses it to verify the message really comes from you and was not tampered with on the way.
The header looks like this:
x-salescontrol-signature: t=1758190327,v1=4f0c1d…
tis the moment of sending, in seconds (the “unix” format).v1is the verification code, in hexadecimal.
To check it, the receiver does three things:
The raw text of the request, before turning it into an object: reading it and writing it back would change spacing and key order, and the check would never match.
It is the value of t, a dot, and the body: 1758190327.{"event":"contract.signed",…}.
The hexadecimal result must be identical to v1. It is the same recipe Stripe uses for its webhooks: any library that already verifies those will do.
It is also worth rejecting messages that are too old, comparing t with the current time. The timestamp is fresh on every attempt: the third one, six seconds later, is not born stale.
Pressing Regenerate: “The current secret stops working: whoever verifies the signature will have to use the new one”. Update your system first, or the next deliveries will be rejected.
The delivery log and «Retry»
Every attempt leaves a row. Before sending a real contract you can press Send a test in the dialog: a real delivery goes out with sample data, and the log marks it with the Test badge.
The full log — Latest deliveries, 50 rows — sits in Settings › Integrations › Webhooks › Outgoing webhooks, with Date, Webhook, Outcome, HTTP, Attempts, and for each row Details (showing Message sent and Response) and Retry, which resends the exact same body.
On how it retries by itself:
- Three attempts: straight away, after 2 seconds and after 6 seconds.
- It only retries when that makes sense: a network error or a 5xx answer from your server. A 4xx (a 401 for a wrong key, say) is an answer, and repeating it would change nothing: the delivery fails immediately.
- Ten-second timeout per attempt.
- Once the attempts are used up there is no automatic repeat later: you retry by hand from the log.
The log keeps the body that was sent, never the headers: their values stay encrypted and are not readable anywhere.
When it does NOT fire
- If the effects of the signature failed. The webhook fires once the effects went through, when the client really exists and the message can talk about them. Fix it with Retry effects and the webhook goes out too.
- For a signature arriving from your management system (the inbound “contract signed” webhook): that signature does not go through a template, and with no template there is no webhook to call.
- If the webhook is off, or if it belongs to a template other than the signed contract’s.
- If you deleted the template: its webhooks and their log go with it.
The call goes out in the background: even if your server takes ten seconds and a second attempt is needed, the client sees their page straight away and receives the PDF.
One useful detail: customer_phone comes from the originating lead, because the client record has no phone field. A contract sent to a client who was never a lead sends it empty.