Back to App
Back to Contracts

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.

Plan required

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.

Save the template first

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:

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" }

Address, headers and safety

In the webhook dialog you fill in:

Header values are written, never read back

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}}…).

GroupVariables
Contactcustomer_name (first name), customer_full_name (full name), customer_last_name, customer_email, customer_phone
Contractservice_name, contract_value, duration_months, payment_type, currency, start_date, signature_date
Agency and teamagency_name, closer_name, setter_name
Referencescustomer_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.

Reserved names

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.

One client, several contracts

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…

To check it, the receiver does three things:

1. Take the body exactly as it arrived

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.

2. Build the string to sign

It is the value of t, a dot, and the body: 1758190327.{"event":"contract.signed",…}.

3. Compute HMAC SHA-256 with the secret and compare

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.

Regenerating the secret changes it for everyone

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:

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

The signer does not wait

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.