---
title: Outbound Webhooks
description: >-
  Send signed SwiftReporter events for completed inspections, paid invoices, and
  new bookings to your HTTPS endpoint, with payload examples, retries, and dedupe.
canonical: 'https://www.swiftreporter.com/docs/outbound-webhooks'
pathname: /docs/outbound-webhooks
category: integrations
date: '2026-09-27T00:00:00.000Z'
---

# Outbound Webhooks

Outbound webhooks let SwiftReporter notify your own systems when something important happens in your account. When an inspection is completed, an invoice is paid, or a client books through your public booking page, SwiftReporter sends a signed JSON `POST` request to the HTTPS URL you configure.

Use webhooks to update a CRM, post to a team channel, start a follow-up email sequence, or trigger a Zapier workflow.

Outbound webhooks are an included capability. There is no per-delivery charge or usage meter.

## Before you begin

- Have a publicly reachable `https://` URL that accepts `POST` requests with a JSON body.
- In the SwiftReporter web app, open **Account** (profile menu or `/account`) and use the **Webhooks** card to add an endpoint. Choose which events it should receive.
- Copy the endpoint's signing secret when it is shown. It is displayed only once, so store it somewhere safe, such as your server's environment variables.

## Events catalog

| Event                  | When it is sent                                                |
| ---------------------- | -------------------------------------------------------------- |
| `inspection.completed` | An inspection's status changes to **Complete**.                |
| `invoice.paid`         | An invoice's status changes to **Paid**.                       |
| `booking.created`      | A new appointment is created through your public booking page. |

Event IDs are based on the record, such as `inspection.completed:<inspection_id>`. If an inspection is reopened and completed again, or an invoice returns to **Paid**, the new event reuses the same ID.

## Request format

Every delivery is an HTTP `POST` with these headers:

| Header                      | Value                                                          |
| --------------------------- | -------------------------------------------------------------- |
| `Content-Type`              | `application/json`                                             |
| `User-Agent`                | `SwiftReporter-Webhooks/1.0`                                   |
| `X-SwiftReporter-Event`     | The event type, such as `invoice.paid`.                        |
| `X-SwiftReporter-Delivery`  | The event ID. Matches the `id` field in the body.              |
| `X-SwiftReporter-Signature` | `t=<unix_seconds>,v1=<hex_hmac>` (see signature verification). |

The body is a JSON envelope:

- **`id`:** Unique event ID. Stays the same across every retry of the same event.
- **`type`:** The event type.
- **`created_at`:** ISO 8601 timestamp when the event was created.
- **`owner_id`:** The SwiftReporter account the event belongs to.
- **`data`:** Event-specific fields. Optional fields are left out when the source record does not have a value.

## Payload examples

### inspection.completed

```json
{
  "id": "inspection.completed:insp_seed_1",
  "type": "inspection.completed",
  "created_at": "2026-09-27T07:42:42.970Z",
  "owner_id": "owner_seed",
  "data": {
    "inspection_id": "insp_seed_1",
    "name": "123 Main St",
    "client_name": "Jordan Smith",
    "status": "complete"
  }
}
```

`data` fields: `inspection_id`, `status` (always `complete`), and optional `name`, `client_name`, and `org_id`.

### invoice.paid

```json
{
  "id": "invoice.paid:inv_seed_1",
  "type": "invoice.paid",
  "created_at": "2026-09-27T07:42:42.970Z",
  "owner_id": "owner_seed",
  "data": {
    "invoice_id": "inv_seed_1",
    "inspection_id": "insp_seed_1",
    "total": 25000,
    "currency": "USD",
    "paid_at": "2026-09-27T07:42:42.900Z"
  }
}
```

`data` fields: `invoice_id`, `paid_at`, and optional `inspection_id`, `appointment_id`, `total`, and `currency`. `total` is an integer in the smallest currency unit, so `25000` in `USD` is $250.00.

### booking.created

```json
{
  "id": "booking.created:appt_seed_1",
  "type": "booking.created",
  "created_at": "2026-09-27T07:42:42.970Z",
  "owner_id": "owner_seed",
  "data": {
    "appointment_id": "appt_seed_1",
    "source": "public_booking",
    "start_at": "2026-10-01T15:00:00.000Z",
    "timezone": "America/Chicago"
  }
}
```

`data` fields: `appointment_id`, `source` (always `public_booking`), and optional `inspection_id`, `start_at`, and `timezone`.

## Verify the signature

Every request is signed with your endpoint's signing secret using HMAC-SHA256. Verify the signature before trusting the payload.

1. Read the **raw** request body exactly as received. Do not parse and re-serialize the JSON first, because any change to spacing or key order breaks the signature.
2. Split the `X-SwiftReporter-Signature` header on `,` and read the `t` (timestamp) and `v1` (signature) values.
3. Reject the request if `t` is more than 300 seconds (5 minutes) away from your server's current time. This blocks replayed requests.
4. Build the signed content as `${t}.${rawBody}`, the timestamp, a period, then the raw body.
5. Compute `HMAC_SHA256(signing_secret, signed_content)` as lowercase hex.
6. Compare your result with `v1` using a constant-time comparison. Reject the request if they do not match.

Each retry is signed again with a fresh timestamp, so a delayed retry still passes the 5-minute check.

### Node.js example

```js
const crypto = require('crypto')

/**
 * Verifies a SwiftReporter webhook signature.
 * @param {string} rawBody - The exact request body string.
 * @param {string} header - The X-SwiftReporter-Signature header value.
 * @param {string} secret - The endpoint signing secret.
 * @returns {boolean} True when the signature is valid and recent.
 */
function verifySwiftReporterSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header || '')
      .split(',')
      .map((part) => part.trim().split('='))
  )
  const t = Number(parts.t)
  const v1 = String(parts.v1 || '').toLowerCase()
  if (!Number.isFinite(t) || !/^[0-9a-f]+$/.test(v1)) return false

  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - t) > 300) return false

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex')

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(v1, 'hex')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
```

With Express, capture the raw body using `express.raw({ type: 'application/json' })` on the webhook route and pass `req.body.toString('utf8')` to the function.

## Respond quickly

- Return any `2xx` status as soon as you have verified and stored the event.
- Do slow work, such as calling other APIs, after you respond or in a background job.
- SwiftReporter waits up to 10 seconds for each attempt before treating it as a timeout.

## Retries and backoff

If a delivery fails, SwiftReporter retries it up to 4 attempts in total:

| Attempt | When it is sent                  |
| ------- | -------------------------------- |
| 1       | Immediately                      |
| 2       | About 1 second after attempt 1   |
| 3       | About 4 seconds after attempt 2  |
| 4       | About 16 seconds after attempt 3 |

**Retried:** network errors (DNS failures, refused or reset connections), timeouts, `408 Request Timeout`, `429 Too Many Requests`, and any `5xx` response.

**Not retried:** any `2xx` response (success), and any other `4xx` response such as `400`, `401`, `403`, or `404`. A `4xx` usually means the URL, authentication, or handler needs to be fixed.

If you have several endpoints subscribed to the same event, each one is delivered and retried independently.

## Handle duplicates

Retries mean your endpoint can receive the same event more than once, for example when your server processed a request but the response timed out.

Use the `X-SwiftReporter-Delivery` header (the same value as the body's `id`) as an idempotency key:

1. Before processing, check whether you have already stored this delivery ID.
2. If you have, return `200` and skip processing.
3. If you have not, process the event and store the ID.

Because event IDs are per record, a repeat completion of the same record also looks like a duplicate. If you need to act on repeats, use the delivery ID together with `created_at`, which stays the same across retries but changes for each new event.

## Use with Zapier

You can connect outbound webhooks to Zapier without a dedicated SwiftReporter Zapier app:

1. In Zapier, create a Zap and choose **Webhooks by Zapier** → **Catch Hook** as the trigger.
2. Copy the webhook URL Zapier gives you.
3. In SwiftReporter, open **Account → Webhooks**, add that URL as an endpoint, and select the events you want.
4. Trigger a test event, such as completing a test inspection, so Zapier can read a sample payload.
5. Map fields from `data` into the rest of your Zap.

Zapier's Catch Hook does not verify the `X-SwiftReporter-Signature` header. If you need signature verification, send webhooks to your own server first, verify them, and forward them from there.

## Webhooks compared to MCP

Outbound webhooks and SwiftReporter MCP solve different problems:

- **Outbound webhooks** push event notifications from SwiftReporter to your systems automatically.
- **SwiftReporter MCP** lets AI apps such as Cursor, Claude, ChatGPT, or Gemini read and act on your SwiftReporter data when you ask them to. See [Connect AI Apps with SwiftReporter MCP](/docs/mcp).

## Troubleshooting

- **Signature does not match:** Confirm you are hashing the raw body, not parsed and re-serialized JSON, and that you are using the secret for this specific endpoint.
- **Signature rejected as too old:** Check that your server clock is synced (for example, with NTP).
- **No events arrive:** Confirm the endpoint is enabled, uses `https://`, and is subscribed to the event you expect.
- **Events stop after one attempt:** Your endpoint returned a `4xx` status other than `408` or `429`. Fix the handler and trigger a new event.
- **Duplicate records in your system:** Deduplicate on `X-SwiftReporter-Delivery`.

## Related

- [Connect AI Apps with SwiftReporter MCP](/docs/mcp)
- [Public Booking](/docs/public-booking)
- [Invoices](/docs/invoices)
