# Outbound webhooks

> Receive a signed POST from Couplr when something happens in an account. Covers events, payload, signature verification, retries and automatic disabling.

- Canonical URL: https://couplrfsm.com/developers/outbound-webhooks
- Markdown: https://couplrfsm.com/developers/outbound-webhooks.md
- API version: v1 (base URL https://couplrfsm.com/api/v1)
- OpenAPI specification: https://couplrfsm.com/developers/openapi.yaml
- All documentation pages: https://couplrfsm.com/llms.txt

> **Flip:** This is me telling your system when something happens, the moment it happens: a new customer, a finished job, a paid invoice. You give me an HTTPS address and I'll post to it.

An outbound webhook sends an HTTP `POST` to a URL you choose each time a chosen event happens in the company's Couplr account.

## Set up a destination

A company admin does this on the **Integrations** page (`https://couplrfsm.com/dashboard/integrations`).

1. Add a new destination and enter the URL. It must start with `https://` and use a public hostname. IP addresses and private or local hosts are refused.
2. Choose the events to send, or all events.
3. Copy the signing secret when it is shown. It starts with `whsec_` and is shown once.
4. Use the test button to send a test delivery and confirm your endpoint receives it.

An app can also set up its own destination through the API, with a key that has the `webhooks:manage` ability. See [Create a webhook subscription](https://couplrfsm.com/developers/api-reference/webhook-subscriptions#create-a-webhook-subscription). The signing secret comes back in that response, once. A subscription created this way belongs to the key that created it: once that key is revoked or expires, it gets no more deliveries. The Integrations page shows which key created each one.

## Events

| Event | Sent when |
| --- | --- |
| `customer.created` | A customer is created. |
| `job.created` | A job is created. |
| `job.completed` | A job is completed. |
| `estimate.completed` | An estimate visit is completed. |
| `invoice.sent` | An invoice is sent to the customer. |
| `invoice.paid` | An invoice is paid. |
| `invoice.refunded` | An invoice is refunded in full. |
| `estimate.accepted` | A customer accepts an estimate. |
| `lead.created` | A new lead conversation starts. |
| `booking.created` | A customer books a visit themselves. |

## Payload

Each delivery is a `POST` with a JSON body:

```json
{
  "id": "evt_48213",
  "event": "customer.created",
  "created_at": "2026-11-14T15:02:11+00:00",
  "data": {
    "id": 1842,
    "name": "Dana Whitfield",
    "email": "dana@example.com",
    "phone": "5552018890"
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | Unique id of this delivery. The same value on every retry of it. |
| `event` | The event name. A test delivery uses `test`. |
| `created_at` | When the event happened, ISO 8601 in UTC. |
| `data` | A short summary of the record: its id and a few key fields. Fetch the full record from the [API](https://couplrfsm.com/developers/api-reference) using the id. |

Request headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `HVACPro-Webhooks/1.0` |
| `X-Hvacpro-Event` | The event name. |
| `X-Hvacpro-Signature` | Hex HMAC-SHA256 of the raw request body, using the destination's signing secret. |

## Verify the signature

> **Flip:** Check my signature before you trust a delivery. It proves the message came from me and wasn't changed on the way.

Compute the HMAC-SHA256 of the raw request body with your signing secret and compare it with `X-Hvacpro-Signature` using a constant-time comparison. Do this before parsing the JSON, and use the raw bytes, not a re-serialized copy.

```javascript
const crypto = require('crypto');

function isFromCouplr(rawBody, signatureHeader, signingSecret) {
  const expected = crypto.createHmac('sha256', signingSecret).update(rawBody).digest('hex');
  const given = Buffer.from(signatureHeader || '', 'utf8');
  const wanted = Buffer.from(expected, 'utf8');
  return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}
```

The signature covers the body only. It does not include a timestamp, so it cannot tell you how old a delivery is. Use the body's `id` to ignore a delivery you have already handled.

## Respond and retry

> **Flip:** Answer me quickly and I'll leave you alone. If I don't hear a `2xx` I'll try again a few times, so be ready to see the same delivery twice.

- Answer with any `2xx` status within 10 seconds to accept a delivery. Do slow work after responding.
- Any other status, a timeout or a connection error counts as a failure. Couplr tries up to 4 times in total, waiting about 1, 5 and 15 minutes between attempts.
- Redirects are not followed.
- The same delivery can arrive more than once, and deliveries can arrive out of order. Deduplicate on `id` and do not assume order.
- After 10 deliveries in a row have used up all their attempts, the destination is turned off automatically. The Integrations page shows this. Fix your endpoint, then turn the destination back on there.

The Integrations page lists recent deliveries for each destination with their status and response code.

## Not supported

- Replacing a signing secret in place. Delete the destination and add it again to get a new secret.
- Choosing the fields inside `data`.
- An event for refunded invoices.
