# Webhooks

> Subscribe an HTTPS endpoint to invoice and expense events, verify the HMAC-SHA256 signature, and handle retries.

Webhooks push a JSON message to your HTTPS endpoint whenever an invoice or expense changes in your business, whether the change came from the API, the app, a recurring invoice or a payment. They are included in the same plan as the API.

## Subscribe

There is no API for subscriptions. In the app open **Settings**, **Integrations**, **Webhooks**, add your endpoint URL and tick the events you want. The app generates a **signing secret** for the subscription, prefixed `whsec_`. Reveal it and store it with your endpoint's configuration.

Endpoint rules:

- Must be `https://`.
- Up to 2048 characters.

## Events

| Method | Path | Summary |
|---|---|---|
| `EVENT` | [`invoice.created`](/reference/invoices/webhookInvoiceCreated/) | invoice.created |
| `EVENT` | [`invoice.updated`](/reference/invoices/webhookInvoiceUpdated/) | invoice.updated |
| `EVENT` | [`invoice.deleted`](/reference/invoices/webhookInvoiceDeleted/) | invoice.deleted |
| `EVENT` | [`expense.created`](/reference/expenses/webhookExpenseCreated/) | expense.created |
| `EVENT` | [`expense.updated`](/reference/expenses/webhookExpenseUpdated/) | expense.updated |
| `EVENT` | [`expense.deleted`](/reference/expenses/webhookExpenseDeleted/) | expense.deleted |

What each event carries and when it is sent:

| Event | `data` | When |
|---|---|---|
| `invoice.created` | full invoice, same shape as `GET /invoices/{id}` | a non-draft invoice is created |
| `invoice.updated` | full invoice | any change, including payment status changes and payments received through the app |
| `invoice.deleted` | `{ "id": "<uuid>" }` | a non-draft invoice is deleted |
| `expense.created` | full expense, same shape as `GET /expenses/{id}` | an expense is created |
| `expense.updated` | full expense | figures or file changed |
| `expense.deleted` | `{ "id": "<uuid>" }` | an expense is deleted through the API or the app |

Draft invoices never produce events. `invoice.created` is sent as soon as the invoice is saved, before its PDF is rendered; a download request made in the handler waits for the PDF.

## Delivery

Each delivery is an HTTP `POST` with:

```
Content-Type: application/json
Signature: <hex HMAC-SHA256 of the raw body, keyed with your whsec_ secret>
```

Body:

```json
{
  "event": "invoice.updated",
  "data": {
    "id": "6f1e9d2a-0b3c-4e5f-8a9b-1c2d3e4f5a6b",
    "invoice_type": "vat_invoice",
    "series": "SF",
    "invoice_number": "007",
    "payment_status": "paid",
    "…": "…"
  }
}
```

Your endpoint must answer with any `2xx` status within 10 seconds. Anything else, including a timeout, counts as a failure.

## Retries

A failed delivery is retried twice more: after about 10 seconds, then after about 100 seconds. After three failures the message is dropped. Deliveries can therefore arrive more than once and, for a busy resource, out of order. Make your handler idempotent: use `data.id` plus `data.updated_at` to detect duplicates, and fetch the current state with `GET /invoices/{id}` when the order matters.

There is no event identifier or timestamp header, and there is no delivery log in the app.

## Verify the signature

Compute HMAC-SHA256 over the **raw request body** exactly as received, using the subscription secret, and compare it in constant time with the `Signature` header.

```php tab="PHP" tab-group="signature"
$secret    = getenv('FSASKAITA_WEBHOOK_SECRET');   // whsec_…
$payload   = file_get_contents('php://input');
$expected  = hash_hmac('sha256', $payload, $secret);
$signature = $_SERVER['HTTP_SIGNATURE'] ?? '';

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true);
// handle $event['event'] and $event['data']
http_response_code(200);
```

```js tab="JavaScript" tab-group="signature"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```go tab="Go" tab-group="signature"
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
)

func Verify(rawBody []byte, signatureHeader, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
```

```python tab="Python" tab-group="signature"
import hashlib
import hmac


def verify(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (signature_header or "").encode())
```

```java tab="Java" tab-group="signature"
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class WebhookSignature {
    public static boolean verify(byte[] rawBody, String signatureHeader, String secret) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
        return MessageDigest.isEqual(
            expected.getBytes(StandardCharsets.UTF_8),
            (signatureHeader == null ? "" : signatureHeader).getBytes(StandardCharsets.UTF_8));
    }
}
```

```csharp tab="C#" tab-group="signature"
using System.Security.Cryptography;
using System.Text;

public static class WebhookSignature
{
    public static bool Verify(byte[] rawBody, string? signatureHeader, string secret)
    {
        var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody);
        var expected = Convert.ToHexString(hash).ToLowerInvariant();
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected),
            Encoding.UTF8.GetBytes(signatureHeader ?? ""));
    }
}
```

Read the body as raw bytes before any JSON parsing; re-serialising the parsed object may change whitespace or key order and break the comparison.

## Respond fast

Acknowledge with `2xx` first and process afterwards. Slow handlers hit the 10 second timeout, which triggers retries and duplicate work.

## Rotate a secret

Delete the subscription and create a new one; the new subscription gets a new secret. Update your endpoint configuration before deleting the old subscription if you cannot tolerate a gap.
