> ## Documentation Index
> Fetch the complete documentation index at: https://docs.saasybill.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get a request to your own server when a customer, subscription or invoice changes

A webhook is a request Saasybill sends to a URL you choose when something changes. Use webhooks to learn that an invoice was paid or a subscription ended, instead of polling the API.

<CardGroup cols={3}>
  <Card title="Events" icon="bolt" href="/api-reference/webhooks/events">
    Every event and what its payload holds.
  </Card>

  <Card title="Verify signatures" icon="shield-halved" href="/api-reference/webhooks/verify-signatures">
    Check each request came from Saasybill.
  </Card>

  <Card title="Delivery and retries" icon="rotate" href="/api-reference/webhooks/delivery-and-retries">
    What happens when your server is down.
  </Card>
</CardGroup>

***

## Set up an endpoint

Endpoints are managed in the app. The API doesn't manage them yet. Only an owner or admin can add one.

<Steps>
  <Step title="Open Webhooks">
    Go to **Organisation Settings**, then **Developer Settings**, then **Webhooks**.
  </Step>

  <Step title="Add the endpoint">
    Click <Badge>Add Endpoint</Badge> and fill in the fields.

    | Field | Description |
    | - | - |
    | **Endpoint URL** | Where Saasybill sends events, such as `https://example.com/saasybill-webhooks`. |
    | **Description** | A note for you, such as `Billing sync`. Up to 255 characters. |
    | **Events** | <Badge>All events</Badge>, including any added later, or a chosen list. |
  </Step>

  <Step title="Copy the signing secret">
    Click <Badge>Add Endpoint</Badge>. The **Your Signing Secret** dialog shows a secret beginning `whsec_`. Copy it now and store it as an environment variable. It can't be shown again.
  </Step>

  <Step title="Send a test event">
    Open the endpoint's menu and click <Badge>Send Test Event</Badge>. Confirm your server receives it and passes [signature verification](/api-reference/webhooks/verify-signatures).
  </Step>
</Steps>

### Endpoint rules

| Rule | Detail |
| - | - |
| Protocol | `https://` only. |
| Port | 443 only. |
| Address | Must be reachable from the internet. Addresses such as `localhost`, private networks and link-local ranges are refused. |
| URL | No username or password, and no `#` fragment. Up to 2,000 characters. |
| Redirects | Not followed. A `3xx` response counts as a failure. |
| Limit | Up to 10 endpoints per organisation. |

The address is checked when you save the URL and again on every delivery.

<Tip>
  To receive webhooks while developing on your own machine, use a tunnelling tool that gives you a public `https://` address.
</Tip>

### Manage an endpoint

Each endpoint has a menu.

| Action | What it does |
| - | - |
| <Badge>Edit Endpoint</Badge> | Change the URL, description or events. |
| <Badge>Send Test Event</Badge> | Sends a `webhook.test` event. Only on an active endpoint. |
| <Badge>Roll Secret</Badge> | Issues a new signing secret. The old one keeps signing for 24 hours. |
| <Badge>Turn Off</Badge> / <Badge>Turn On</Badge> | Pause or resume delivery. |
| <Badge>Delete Endpoint</Badge> | Stops events and deletes the delivery history. |

***

## What a webhook looks like

Saasybill sends a `POST` with a JSON body.

```http theme={null}
POST /saasybill-webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Saasybill-Webhooks/1
Saasybill-Signature: t=1790741520,v1=9c1e4f7a2b8d3065e1a7c94b0d2f83e6a5b1c7d90e4f2a86b3d5c1e07f9a4b28
Saasybill-Event-Id: evt_6c3f0b9e2a7d4f1c8e5a9b0d3c2f1e47
Saasybill-Delivery-Id: 4e8b1a7c-2d95-4f06-b3a8-9c1e7d5f0a62
Saasybill-Delivery-Attempt: 1
```

```json theme={null}
{
  "id": "evt_6c3f0b9e2a7d4f1c8e5a9b0d3c2f1e47",
  "object": "event",
  "type": "invoice.paid",
  "created": "2026-10-04T04:12:00.000Z",
  "livemode": true,
  "data": {
    "object": {
      "id": "5f8a3c9d-7e21-4b04-9c6d-0a2b4e8f1d55",
      "object": "invoice",
      "status": "paid",
      "customer": "3b9f1c1e-6a52-4c0e-9d7e-1f6f2f0b7a11",
      "subscription": "9d4e2b7a-13c5-4f8e-b6a0-2e1d7c9f5b33",
      "number": "INV-0042",
      "total": "613.8000",
      "xero_sync_status": "synced"
    },
    "previous_attributes": {
      "status": "approved"
    }
  }
}
```

| Header | Description |
| - | - |
| `Saasybill-Signature` | The signature you verify. |
| `Saasybill-Event-Id` | The event's id, the same as `id` in the body. Use it to skip repeats. |
| `Saasybill-Delivery-Id` | The id of this delivery of the event. |
| `Saasybill-Delivery-Attempt` | Which attempt this is, starting at 1. |
| `User-Agent` | `Saasybill-Webhooks/1`. |

### The event object

| Field | Description |
| - | - |
| `id` | The event id, beginning `evt_`. |
| `object` | Always `event`. |
| `type` | What happened, such as `invoice.paid`. See [Events](/api-reference/webhooks/events). |
| `created` | When Saasybill recorded the event. |
| `livemode` | `true` for a live organisation. |
| `data.object` | The object the event is about: its `id`, `object` type and the tracked fields. |
| `data.previous_attributes` | The previous value of each tracked field that changed. Absent on `created`, `deleted` and test events. |

<Info>
  Events are thin on purpose. They carry the fields that changed and no more. To get the full object, fetch it with its `id`, for example `GET /v1/invoices/{id}`.
</Info>

***

## Respond quickly

Reply with any `2xx` status within 10 seconds. Acknowledge the event first and do the slow work afterwards, on a queue or a background job. Anything else is a failure and is [retried](/api-reference/webhooks/delivery-and-retries).

***

## Things to know

<AccordionGroup>
  <Accordion title="Events can arrive up to a minute late" icon="clock">
    Saasybill finds changes about once a minute by comparing each object with how it looked last time. An event arrives up to a minute after the change.
  </Accordion>

  <Accordion title="Changes inside one minute are combined" icon="layer-group">
    An invoice that is approved and paid within the same minute arrives as one `invoice.paid` event, and `previous_attributes.status` is `draft`. You won't see an `approved` step. Write your handler to depend on the current state, not on seeing every step.
  </Accordion>

  <Accordion title="Events can arrive more than once" icon="copy">
    Delivery is at-least-once. Store each `Saasybill-Event-Id` you have processed and skip repeats.
  </Accordion>

  <Accordion title="Events can arrive out of order" icon="shuffle">
    Order is not guaranteed, and a retry can arrive after a newer event. If order matters, fetch the object from the API and act on its current state.
  </Accordion>

  <Accordion title="A new endpoint doesn't announce what already exists" icon="seedling">
    When an organisation gets its first endpoint, Saasybill records the current state silently. You'll hear about changes from then on, not about every existing invoice.
  </Accordion>

  <Accordion title="Only customers are announced, not every contact" icon="user">
    A Xero contact that hasn't been imported as a customer produces no events until it is imported.
  </Accordion>
</AccordionGroup>
