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

# Invoice AI Webhook Event Types and Payload Reference

> Complete reference for all Invoice AI webhook event types, payload shapes, signature verification, and handling examples for invoice lifecycle events.

# Webhook Event Types

Invoice AI fires a webhook event every time a significant action occurs on an invoice. Your registered [webhook endpoints](/api-reference/webhooks/overview) receive a signed HTTP `POST` with a JSON payload describing what happened and which invoice was affected.

***

## All event types

| Event type             | Trigger                                                                                  |
| ---------------------- | ---------------------------------------------------------------------------------------- |
| `invoice.created`      | A draft invoice was created.                                                             |
| `invoice.updated`      | A draft invoice was edited (fields changed, items added/removed).                        |
| `invoice.finalized`    | An invoice was finalized and its status moved to `open`. An invoice number was assigned. |
| `invoice.emailed`      | An invoice was successfully emailed to the customer.                                     |
| `invoice.email_failed` | An invoice email bounced or was rejected by the recipient's mail server.                 |
| `invoice.viewed`       | A customer opened the invoice's public link.                                             |
| `invoice.downloaded`   | A customer downloaded the invoice PDF.                                                   |
| `invoice.paid`         | An invoice was marked as paid.                                                           |
| `invoice.voided`       | An invoice was voided.                                                                   |

<Note>
  There is no `invoice.overdue` event. Overdue status is computed from `due_date` at read time — it is never stored — so there is no discrete moment when it transitions. To find overdue invoices, poll `GET /api/v1/invoices?status=overdue`.
</Note>

***

## Payload structure

Every event payload follows the same envelope:

```json theme={null}
{
  "type": "<event_type>",
  "data": {
    "object": { ...invoice object... }
  }
}
```

<ResponseField name="type" type="string">
  The event type string, e.g. `"invoice.paid"`. Always present.
</ResponseField>

<ResponseField name="data" type="object">
  Container for the event data.

  <Expandable title="data fields">
    <ResponseField name="object" type="object">
      The full invoice object at the time the event was fired. The shape matches the invoice resource returned by `GET /api/v1/invoices/:id`, including all line items under `lines.data`.
    </ResponseField>
  </Expandable>
</ResponseField>

***

## Event payload examples

### `invoice.paid`

Fired when an invoice is marked paid. The `paid_at` timestamp is set and `status` changes to `"paid"`.

```json theme={null}
{
  "type": "invoice.paid",
  "data": {
    "object": {
      "id": "in_01hxyz1234567890abcdefghij",
      "object": "invoice",
      "number": "INV-0042",
      "status": "paid",
      "customer": "cus_01hxyz1234567890abcdefghij",
      "currency": "USD",
      "collection_method": "send_invoice",
      "issue_date": "2024-06-01",
      "due_date": "2024-06-15",
      "description": "June consulting engagement",
      "footer": "Payment due within 14 days. Thank you for your business.",
      "subtotal": 45000,
      "discount": 2250,
      "taxable": 42750,
      "tax": 3634,
      "total": 46384,
      "amount_due": 0,
      "amount_in_words": "Four Hundred Sixty Three Dollars and Eighty Four Cents",
      "public_url_token": "tok_AbCdEfGh1234",
      "finalized_at": "2024-06-01T09:15:00.000Z",
      "paid_at": "2024-06-10T14:32:00.000Z",
      "voided_at": null,
      "void_reason": null,
      "created": "2024-05-30T11:00:00.000Z",
      "updated": "2024-06-10T14:32:00.000Z",
      "lines": {
        "data": [
          {
            "id": "ii_01hxyz1234567890abcdefghij",
            "object": "invoiceitem",
            "price": "price_01hxyz1234567890abcdefghij",
            "product": "prod_01hxyz1234567890abcdefghij",
            "description": "Professional Consulting",
            "quantity": 3,
            "unit": "HRS",
            "unit_amount": 15000,
            "amount": 42750,
            "discount_percent": 5,
            "tax_rate": 8.5,
            "tax_amount": 3634
          }
        ]
      }
    }
  }
}
```

***

### `invoice.email_failed`

Fired when an invoice email bounced or was rejected. The `meta` field on the underlying event row may contain error details from the mail provider. Use this event to detect delivery failures and notify the customer through an alternative channel.

```json theme={null}
{
  "type": "invoice.email_failed",
  "data": {
    "object": {
      "id": "in_01hxyz9876543210zyxwvutsrq",
      "object": "invoice",
      "number": "INV-0043",
      "status": "open",
      "customer": "cus_01hxyz9876543210zyxwvutsrq",
      "currency": "USD",
      "collection_method": "send_invoice",
      "issue_date": "2024-06-05",
      "due_date": "2024-06-19",
      "description": null,
      "footer": null,
      "subtotal": 120000,
      "discount": 0,
      "taxable": 120000,
      "tax": 10200,
      "total": 130200,
      "amount_due": 130200,
      "amount_in_words": "One Thousand Three Hundred Two Dollars and Zero Cents",
      "public_url_token": "tok_ZyXwVuTs5678",
      "finalized_at": "2024-06-05T10:00:00.000Z",
      "paid_at": null,
      "voided_at": null,
      "void_reason": null,
      "created": "2024-06-04T16:00:00.000Z",
      "updated": "2024-06-05T10:05:00.000Z",
      "lines": {
        "data": [
          {
            "id": "ii_01hxyzabcdef1234567890ghij",
            "object": "invoiceitem",
            "price": null,
            "product": null,
            "description": "Annual Software License",
            "quantity": 1,
            "unit": "NOS",
            "unit_amount": 120000,
            "amount": 120000,
            "discount_percent": 0,
            "tax_rate": 8.5,
            "tax_amount": 10200
          }
        ]
      }
    }
  }
}
```

When you receive `invoice.email_failed`:

1. Retrieve the customer's contact details with `GET /api/v1/customers/:id`.
2. Verify or correct the email address, then re-send through the dashboard or the send endpoint.
3. Consider notifying your team via a support queue so a human can follow up.

***

### `invoice.finalized`

Fired when a draft is finalized. An invoice number is assigned and `status` moves to `"open"`.

```json theme={null}
{
  "type": "invoice.finalized",
  "data": {
    "object": {
      "id": "in_01hxyz1234567890abcdefghij",
      "object": "invoice",
      "number": "INV-0044",
      "status": "open",
      "customer": "cus_01hxyz1234567890abcdefghij",
      "currency": "USD",
      "collection_method": "send_invoice",
      "issue_date": "2024-06-10",
      "due_date": "2024-06-24",
      "description": null,
      "footer": null,
      "subtotal": 50000,
      "discount": 0,
      "taxable": 50000,
      "tax": 0,
      "total": 50000,
      "amount_due": 50000,
      "amount_in_words": "Five Hundred Dollars and Zero Cents",
      "public_url_token": "tok_MnOpQrSt9012",
      "finalized_at": "2024-06-10T08:00:00.000Z",
      "paid_at": null,
      "voided_at": null,
      "void_reason": null,
      "created": "2024-06-09T17:00:00.000Z",
      "updated": "2024-06-10T08:00:00.000Z",
      "lines": {
        "data": []
      }
    }
  }
}
```

***

### `invoice.voided`

Fired when an invoice is voided. `status` becomes `"voided"`, `voided_at` is set, and `amount_due` drops to `0`.

```json theme={null}
{
  "type": "invoice.voided",
  "data": {
    "object": {
      "id": "in_01hxyz1234567890abcdefghij",
      "object": "invoice",
      "number": "INV-0041",
      "status": "voided",
      "customer": "cus_01hxyz1234567890abcdefghij",
      "currency": "USD",
      "collection_method": "send_invoice",
      "issue_date": "2024-05-01",
      "due_date": "2024-05-15",
      "description": null,
      "footer": null,
      "subtotal": 20000,
      "discount": 0,
      "taxable": 20000,
      "tax": 0,
      "total": 20000,
      "amount_due": 0,
      "amount_in_words": "Two Hundred Dollars and Zero Cents",
      "public_url_token": "tok_GhIjKlMn3456",
      "finalized_at": "2024-05-01T09:00:00.000Z",
      "paid_at": null,
      "voided_at": "2024-05-20T11:00:00.000Z",
      "void_reason": "Duplicate invoice",
      "created": "2024-04-30T15:00:00.000Z",
      "updated": "2024-05-20T11:00:00.000Z",
      "lines": {
        "data": []
      }
    }
  }
}
```

***

## Signature verification

All deliveries are signed using the **Standard Webhooks** HMAC-SHA256 scheme. Three headers travel with every request:

| Header              | Description                                                 |
| ------------------- | ----------------------------------------------------------- |
| `webhook-id`        | Unique delivery ID, stable across retries.                  |
| `webhook-timestamp` | Unix timestamp (seconds) of dispatch.                       |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` over `"{id}.{timestamp}.{body}"`. |

The signed string is constructed as:

```
{webhook-id}.{webhook-timestamp}.{raw_request_body}
```

The HMAC key is derived from your `whsec_…` secret by stripping the `whsec_` prefix and base64url-decoding the remainder.

### Node.js verification example

```javascript theme={null}
import { createHmac, timingSafeEqual } from 'crypto';

/**
 * Verify an incoming Invoice AI webhook delivery.
 *
 * @param {string} rawBody     - The raw (unparsed) request body as a string.
 * @param {object} headers     - Request headers object (lowercase keys).
 * @param {string} secret      - Your whsec_… signing secret.
 * @throws {Error}             - If the signature is invalid or the timestamp is stale.
 */
function verifyInvoiceAIWebhook(rawBody, headers, secret) {
  const webhookId        = headers['webhook-id'];
  const webhookTimestamp = headers['webhook-timestamp'];
  const webhookSignature = headers['webhook-signature'];

  if (!webhookId || !webhookTimestamp || !webhookSignature) {
    throw new Error('Missing webhook signature headers.');
  }

  // Reject deliveries with a timestamp older than 5 minutes (Standard Webhooks default).
  const now       = Math.floor(Date.now() / 1000);
  const timestamp = Number(webhookTimestamp);
  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > 300) {
    throw new Error('Webhook timestamp is out of tolerance (> 5 minutes).');
  }

  // Build the signed content string.
  const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;

  // Derive the HMAC key from the whsec_ secret.
  const secretKey = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');

  // Compute the expected signature.
  const expectedMac = createHmac('sha256', secretKey)
    .update(signedContent)
    .digest('base64');
  const expected = `v1,${expectedMac}`;

  // The header may carry multiple space-separated signatures during key rotation.
  const presented = webhookSignature.split(' ').filter(Boolean);
  const matched = presented.some((candidate) => {
    if (candidate.length !== expected.length) return false;
    return timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
  });

  if (!matched) {
    throw new Error('Webhook signature verification failed.');
  }
}
```

### Express.js handler example

```javascript theme={null}
import express from 'express';
import { verifyInvoiceAIWebhook } from './verify.js'; // the function above

const app = express();

// Use express.raw() so req.body contains the raw Buffer — never parse JSON
// before verifying the signature, as JSON.parse + re-stringify can alter whitespace
// and invalidate the HMAC.
app.post(
  '/webhooks/invoice-ai',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');

    try {
      verifyInvoiceAIWebhook(
        rawBody,
        req.headers,
        process.env.INVOICE_AI_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error('Webhook verification failed:', err.message);
      return res.status(400).send('Bad signature');
    }

    const event = JSON.parse(rawBody);

    switch (event.type) {
      case 'invoice.paid':
        // Mark order as paid in your database
        handleInvoicePaid(event.data.object);
        break;

      case 'invoice.email_failed':
        // Alert your support team
        handleEmailFailed(event.data.object);
        break;

      case 'invoice.voided':
        // Reverse any provisional accounting entries
        handleInvoiceVoided(event.data.object);
        break;

      default:
        // Acknowledge events you don't handle so retries stop
        console.log(`Unhandled event type: ${event.type}`);
    }

    // Respond quickly — process async if needed
    res.status(200).send('OK');
  }
);
```

<Warning>
  You **must** use `timingSafeEqual` (or an equivalent constant-time comparison) when comparing signatures. A standard `===` check is vulnerable to timing side-channel attacks.
</Warning>

<Tip>
  Use the `webhook-id` as an idempotency key in your handler. If your server crashes after processing but before responding `200`, Invoice AI will retry with the same `webhook-id`. Deduplicate on this value to avoid processing the same event twice.
</Tip>

***

## Secret rotation

To rotate your webhook signing secret:

1. Delete the existing endpoint (`DELETE /api/v1/webhook-endpoints/:id`) and immediately create a new one with the same URL (`POST /api/v1/webhook-endpoints`). The creation response includes your new `whsec_…` secret.
2. Update the secret value in your server's configuration before any new deliveries arrive on the new endpoint.
3. Any in-flight deliveries against the old endpoint will exhaust their retry schedule and stop; the new endpoint starts with a clean slate.

Because the `webhook-signature` header may carry multiple space-separated signatures, you can verify against both the old and new secret during your deployment window to avoid dropping events mid-rotation. Pass both secrets through your verification logic and accept the delivery if either matches.
