Guide

Webhooks

Instead of asking Closepact what changed, let it tell you. Register an HTTPS URL, choose the events it wants, and each one arrives as a signed JSON POST.

How it works

When something happens — a deal is won, a document is signed, a work order is completed, an invoice is issued — Closepact sends a POST to every endpoint in the organization that subscribes to that event. Your server checks the signature, answers 2xx, and does the work. If it does not answer in time, Closepact tries again.

Setting up an endpoint

Endpoints can be managed two ways:

  • In the panel, under Settings → Webhooks: add the URL, pick the events, and copy the signing secret.
  • Through the API, with a key that has the admin scope — see Webhooks in the reference. Creating, changing, testing or deleting an endpoint also needs the member the key acts as to have permission to manage webhooks.

Each endpoint has its own signing secret, starting whsec_. It is shown once, when the endpoint is created or its secret is rotated — store it with your other secrets. The URL must be HTTPS and reachable from the internet.

The request

Every delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
X-Signaturet=<timestamp>, v1=<signature>: HMAC-SHA256 of {t}.{raw body} keyed with the endpoint's signing secret, hex-encoded. Verify it, and that t is within 300 seconds of now, before trusting the request.
X-EventThe event name, the same as event in the body — for routing before parsing.
X-Event-IdThe event id, the same as event_id in the body. Identical for every endpoint that receives this event and for every retry.
X-Delivery-IdThis delivery's id — one per event per endpoint, unchanged across retries. Matches id in the endpoint's deliveries list.

The body is the same envelope for every event; only data differs:

  • eventstringrequired

    The event name, e.g. deal.won. Also sent as the X-Event header.

  • event_idstringrequired

    evt_ followed by a UUID. Unique per event; the same for every endpoint that receives it and for every retry. Deduplicate on it. Also sent as the X-Event-Id header.

  • createdintegerrequired

    Unix time in seconds at which the event was queued. The same on every retry (the time of each attempt is the signature's t).

  • dataobjectrequired

    The event's payload.

An example delivery
POST /webhooks/closepact HTTP/1.1
Content-Type: application/json
X-Signature: t=1792314846, v1=fa94cda11bea7d9dd2b7056fb63c376231e5a50275df0544f4aec2adcb461fa1
X-Event: deal.won
X-Event-Id: evt_3f9b2c1e-8a4d-4e6f-9b21-7c5d0e8a1f42
X-Delivery-Id: 01a1c412-77d0-7a3e-b1c4-5e9f0a2d6c81

{
  "event": "deal.won",
  "event_id": "evt_3f9b2c1e-8a4d-4e6f-9b21-7c5d0e8a1f42",
  "created": 1792314845,
  "data": {
    "id": "01a1b9f2-2c6e-7f40-8d13-6b0e4a9c5f17",
    "title": "Byggpartner Norden AB – serviceavtal 2027",
    "value_cents": 48000000,
    "currency": "SEK",
    "stage": "contract_signed",
    "status": "won",
    "owner_id": "01a14eb3-c634-7238-bc76-18b09b287f98",
    "account_id": "01a15a77-0d3b-7e21-a6f9-3c8e2b1d4a05"
  }
}

Verifying signatures

Anyone can send a POST to your URL. The X-Signature header proves a request came from Closepact and was not changed or replayed. It looks like t=<timestamp>, v1=<signature>, where v1 is an HMAC-SHA256 of {t}.{raw request body}, keyed with the endpoint's signing secret — the whole string, whsec_ included — and hex-encoded.

  1. Read the raw request body exactly as received, before any JSON parsing — re-serialising parsed JSON changes the bytes and the signature will not match.
  2. Split the X-Signature header on commas, trim the spaces, and read the values of t and v1.
  3. Compute HMAC-SHA256 over the string t + "." + body, keyed with the endpoint's signing secret, and hex-encode it.
  4. Compare your digest with v1 using a constant-time comparison. Reject the request if they differ.
  5. Reject the request if t is more than 300 seconds away from your clock, in either direction, so a captured request cannot be replayed later.
# Send your local endpoint a correctly signed test request.
SECRET='whsec_…'
BODY='{"event":"ping","event_id":"evt_test","created":1792314845,"data":{}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -X POST 'http://localhost:3000/webhooks/closepact' \
  -H 'Content-Type: application/json' \
  -H "X-Signature: t=$T, v1=$SIG" \
  -d "$BODY"

To check your implementation, this is what it should produce for a known input:

Worked example
secret          whsec_example
t               1792314846
body            {"event":"ping"}
signed payload  1792314846.{"event":"ping"}
X-Signature     t=1792314846, v1=fa94cda11bea7d9dd2b7056fb63c376231e5a50275df0544f4aec2adcb461fa1

Responding and retries

Answer with any 2xx status within 15 seconds. Anything else — another status, a redirect (redirects are not followed), a timeout, a refused connection — is a failed attempt, and Closepact tries again:

AttemptSent
1When the event happens
25 seconds after the first failure
330 seconds later
45 minutes later
51 hour later
66 hours later
724 hours later — the last attempt

After seven failed attempts — about 31 hours end to end — the delivery is abandoned. Every attempt, with the status and body your server answered, is listed under the endpoint's deliveries in the panel and the API. Pausing an endpoint stops new events reaching it; retries already scheduled still run.

Answer first, work later. Acknowledge straight away and do the processing on your own queue. A handler that does its work before answering is the most common reason for timeouts, retries and duplicates.

Duplicates and order

  • Deliveries can arrive more than once — for example when your server did the work but answered after the timeout. Make your handler idempotent: record event_id and skip one you have already handled. It is the same for every endpoint and every retry.
  • Order is not guaranteed. Events are delivered independently and retries interleave with newer events. Use created to order them, and fetch the record from the API when you need its current state.
  • Payloads grow. New fields can be added to any event; ignore the ones you do not recognise. Answer 2xx to event types you do not handle, too — new ones can be added. See Versioning.

Event catalogue

19 events, generated from the OpenAPI document. Open one to see the attributes of its data and an example body.

Deals

Deal created

A deal was created — in the panel, through the API or by a CRM sync.

deal.stage_changed #

Proposals & e-signing

Deal moved to another stage

A deal moved from one pipeline stage to another, by hand or automatically (for example to opened when the buyer first opens the deal room). from_stage and to_stage say where it was and where it is now; stage equals to_stage.

Deal won

A deal was marked won — by hand, or automatically when its contract was signed. Sent once per deal.

Deal lost

A deal was marked lost. reason is what the seller wrote, or null.

Deal rooms

Deal room created

A deal room was created. It is a draft — not visible to buyers — until room.published.

room.published #

Proposals & e-signing

Deal room published

A deal room was published: invited buyers can now open it.

stakeholder.added #

Proposals & e-signing

Stakeholder added to a deal room

A person on the buyer's side was invited to a deal room. Sent once per person and room; inviting the same email address again sends nothing.

message.posted #

Chat message posted

A message was posted in a chat: a deal room's chat between buyer and seller (deal_room_id set), or a document's internal team chat (document_id set). author_type is stakeholder for someone on the buyer's side and user for a member of your organization.

Quotes

quote.accepted #

Proposals & e-signing

Quote accepted

The buyer accepted a quote in the deal room. total_cents is the accepted total including VAT.

Documents and e-signing

signature.requested #

Document sent for signing

A document was sent for signing. recipients lists everyone it was sent to, in signing order: signer and approver must sign, cc and viewer only receive it.

document.viewed #

Document opened by a recipient

A recipient opened a document sent to them for signing, for the first time. Sent once per recipient — later views send nothing.

signer.signed #

Recipient signed

One recipient signed. When the last required signature is in, signature.completed follows.

signature.completed #

Document fully signed

Every recipient who had to sign has signed. The signed document can now be downloaded through the documents endpoints with document_id.

signature.canceled #

Signing canceled

The sender canceled a signing before it was completed; the recipients' links stop working. reason is what the sender wrote, or null.

Booking

work_order.completed #

Scheduling & work orders

Work order completed

A work order was completed — in the field (POST /work-orders/{workOrder}/complete) or by the office (POST /work-orders/{workOrder}/status with completed): the checklist is done and the times are recorded. customer_signed_at is set when the customer signed for the work on site. This is the moment the job can be invoiced.

work_order.created_from_quote #

Scheduling & work orders

Work order created from a quote

An accepted quote became a work order: automatically when the buyer accepted it (created_automatically true), or when somebody pressed Create job on the quote or called POST /quotes/{quote}/convert. Sent only to organizations with the scheduling module and the quote-to-job capability (proposals.convert, proposals Väx and Pro).

The work order is booked with no time on it — waiting to be planned — for the quote's customer, with the lines the buyer accepted (optional lines only if they ticked them) at the quoted quantities, prices, discounts and VAT rates, and the quote's ROT/RUT choice. warnings lists what could not be filled in: service_address_missing or service_address_ambiguous when the customer has no service address or several and none is the default (the work order then has none), recurring_lines when lines charged monthly or yearly were carried over once, no_labour_lines when the quote had only materials, and no_lines when it had none. Each quote becomes at most one work order, so this is sent once per quote.

Invoicing

invoice.issued #

Invoicing

Invoice issued

An invoice or credit note was issued: it has its number and OCR reference and has been sent to the customer. type is credit_note for a credit note, which points at the invoice it credits through credited_invoice_id. Sent only once the issue is committed, so the number is final.

invoice.created_from_quote #

Invoicing

Invoice draft created from a quote

An accepted quote became an invoice draft. Sent to organizations with the invoicing module but not the scheduling module — where there is a schedule, the quote becomes a work order instead and the invoice is written from the finished job — and with the quote-to-job capability (proposals.convert, proposals Väx and Pro): automatically when the buyer accepted (created_automatically true), or when somebody pressed Create invoice draft or called POST /quotes/{quote}/convert.

The draft has no number and has not been sent: issuing it is always a person's decision. It carries the lines the buyer accepted with their quantities, prices, discounts and VAT rates, and the quote's ROT/RUT choice. warnings lists what could not be filled in: deduction_not_included when the quote estimated ROT or RUT and the invoicing tier cannot claim it (the draft then has none), tax_profile_missing when the buyer the deduction is for could not be told from the customer, recurring_lines when lines charged monthly or yearly were carried over once, and no_lines when the quote had none. Each quote becomes at most one draft, so this is sent once per quote; invoice.issued follows when the draft is sent.

Testing

ping #

Test event

Sent only when you call POST /webhooks/{endpoint}/test, and only to that endpoint. Not something an endpoint subscribes to. Signed and retried like every other event, so it exercises your whole receiver.