> ## 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.

# Webhook Endpoints API — Manage & Receive Event Deliveries

> Register HTTPS endpoints to receive real-time Invoice AI events. Deliveries are signed with Standard Webhooks HMAC-SHA256 and retried on failure.

# Webhook Endpoints

Webhook endpoints let Invoice AI push event notifications to your server in real time. When something significant happens — an invoice is paid, an email bounces, a client views an invoice — Invoice AI sends an HTTP `POST` to each registered endpoint that subscribes to that event type.

**Base URL:** `https://invoice.horizonpay.co/api/v1`

***

## Before you begin

* Endpoint URLs must use **HTTPS**. Plain HTTP is rejected.
* URLs that resolve to private, loopback, or link-local IP ranges are blocked (this includes cloud metadata addresses like `169.254.169.254`). Redirects are never followed.
* When you create an endpoint, the API returns a **signing secret** (`whsec_…`) exactly once. Store it securely in your environment variables — it cannot be retrieved again.
* All deliveries are signed using the **Standard Webhooks** signature scheme. See [Signature verification](#signature-verification) below.

***

## The webhook endpoint object

```json theme={null}
{
  "id": "3a1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "url": "https://app.example.com/webhooks/invoice-ai",
  "events": [
    "invoice.paid",
    "invoice.voided",
    "invoice.email_failed"
  ],
  "active": true,
  "disabled_at": null,
  "failure_count": 0,
  "created_at": "2024-04-01T10:00:00.000Z"
}
```

<Note>
  When you create an endpoint, the response also includes a one-time `"secret"` field (e.g. `"whsec_abc123..."`). Subsequent reads and list calls **never** return the secret.
</Note>

<ResponseField name="id" type="string">
  UUID for this webhook endpoint.
</ResponseField>

<ResponseField name="url" type="string">
  The HTTPS URL that receives event POST requests.
</ResponseField>

<ResponseField name="events" type="string[]">
  Array of subscribed event type strings. An empty array at creation time means **all event types** are delivered. After creation, the full expanded list of event types is returned so you can see exactly what will be delivered.
</ResponseField>

<ResponseField name="active" type="boolean">
  `true` when the endpoint is active and receiving deliveries. Set to `false` automatically when the endpoint exceeds the failure threshold.
</ResponseField>

<ResponseField name="disabled_at" type="string | null">
  ISO 8601 datetime at which the endpoint was automatically disabled after repeated delivery failures. `null` while active.
</ResponseField>

<ResponseField name="failure_count" type="integer">
  Cumulative count of failed delivery attempts to this endpoint.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 datetime at which the endpoint was created.
</ResponseField>

***

## Endpoints

<AccordionGroup>
  <Accordion title="GET /api/v1/webhook-endpoints — List endpoints">
    List all registered webhook endpoints in your workspace, newest first.

    **Required scope:** `webhooks:manage`

    ### Request

    ```bash theme={null}
    curl https://invoice.horizonpay.co/api/v1/webhook-endpoints \
      -H "Authorization: Bearer inv_live_..."
    ```

    ### Response

    ```json theme={null}
    {
      "data": [
        {
          "id": "3a1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
          "url": "https://app.example.com/webhooks/invoice-ai",
          "events": [
            "invoice.created",
            "invoice.updated",
            "invoice.finalized",
            "invoice.emailed",
            "invoice.email_failed",
            "invoice.viewed",
            "invoice.downloaded",
            "invoice.paid",
            "invoice.voided"
          ],
          "active": true,
          "disabled_at": null,
          "failure_count": 0,
          "created_at": "2024-04-01T10:00:00.000Z"
        }
      ]
    }
    ```
  </Accordion>

  <Accordion title="POST /api/v1/webhook-endpoints — Create an endpoint">
    Register a new webhook endpoint. The signing secret is returned in the response **only once** and cannot be retrieved again — store it immediately.

    **Required scope:** `webhooks:manage`

    Optionally send an `Idempotency-Key` header to safely retry this request.

    ### Body parameters

    <ParamField body="url" type="string" required>
      The HTTPS URL that Invoice AI will POST events to. Must be publicly reachable and must not resolve to a private IP range.
    </ParamField>

    <ParamField body="events" type="string[]">
      Array of event type strings to subscribe to. Omit or pass `[]` to receive **all** current and future event types. See [event types](/api-reference/webhooks/events) for the full list.
    </ParamField>

    ### Request

    ```bash theme={null}
    curl https://invoice.horizonpay.co/api/v1/webhook-endpoints \
      -X POST \
      -H "Authorization: Bearer inv_live_..." \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: register-prod-webhook-2024" \
      -d '{
        "url": "https://app.example.com/webhooks/invoice-ai",
        "events": ["invoice.paid", "invoice.voided", "invoice.email_failed"]
      }'
    ```

    ### Response `201 Created`

    ```json theme={null}
    {
      "data": {
        "id": "3a1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
        "url": "https://app.example.com/webhooks/invoice-ai",
        "events": [
          "invoice.paid",
          "invoice.voided",
          "invoice.email_failed"
        ],
        "active": true,
        "disabled_at": null,
        "failure_count": 0,
        "created_at": "2024-04-01T10:00:00.000Z",
        "secret": "whsec_AbCdEfGhIjKlMnOpQrStUvWxYz012345678901234567"
      }
    }
    ```

    <Warning>
      The `secret` field appears **only in this create response**. Copy it now and store it in a secrets manager or environment variable. There is no way to retrieve it again — you would need to delete and re-create the endpoint to get a new secret.
    </Warning>
  </Accordion>

  <Accordion title="DELETE /api/v1/webhook-endpoints/:id — Delete an endpoint">
    Permanently delete a webhook endpoint. Deliveries in progress are not affected, but no new deliveries will be sent to this URL after deletion.

    **Required scope:** `webhooks:manage`

    <Note>
      Deleting an endpoint that is already gone returns `404 Not Found` rather than silently succeeding — this is intentional so you can detect stale IDs.
    </Note>

    ### Request

    ```bash theme={null}
    curl https://invoice.horizonpay.co/api/v1/webhook-endpoints/3a1b2c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
      -X DELETE \
      -H "Authorization: Bearer inv_live_..."
    ```

    ### Response `204 No Content`

    An empty body is returned on successful deletion.
  </Accordion>
</AccordionGroup>

***

## Delivery behavior

### Request format

Every delivery is an HTTP `POST` with `Content-Type: application/json`. The body is a [webhook event payload](/api-reference/webhooks/events).

### Signature headers

Each delivery carries three headers used for verification:

| Header              | Example value             | Description                                                                                                                         |
| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | `msg_2Kf4AbCdEfGh`        | Unique ID for this delivery attempt. Stable across retries.                                                                         |
| `webhook-timestamp` | `1789371234`              | Unix timestamp (seconds) of when the event was dispatched.                                                                          |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` | Signature over `"{webhook-id}.{webhook-timestamp}.{raw_body}"`. May contain multiple space-separated values during secret rotation. |
| `user-agent`        | `Invoice-AI-Webhooks/1.0` | Identifies the sender.                                                                                                              |

The signature scheme follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification. The signed string is:

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

The HMAC key is the base64url-decoded payload of your `whsec_…` secret.

### Retry schedule

If your endpoint returns a non-2xx status code, times out (10 seconds), or is unreachable, Invoice AI retries on this schedule:

| Attempt | Delay after previous |
| ------- | -------------------- |
| 1       | 1 minute             |
| 2       | 5 minutes            |
| 3       | 30 minutes           |
| 4       | 2 hours              |
| 5       | 8 hours              |
| 6       | 24 hours             |

After all 6 retries are exhausted, the delivery is abandoned. Repeated failures increment `failure_count` and may disable the endpoint automatically.

### Responding to deliveries

Return any **2xx status code** to acknowledge receipt. Invoice AI reads only the status code — the response body is ignored (beyond logging up to 500 characters on failure).

Respond **quickly** (within 10 seconds). If your processing logic takes longer, acknowledge receipt immediately and process the payload asynchronously.

Redirects (3xx) are **never followed** and are treated as failures.

### Deduplication

The `webhook-id` header is stable across retry attempts. If your endpoint processes an event and then crashes before returning `200`, you may receive the same event again. Use `webhook-id` as an idempotency key to deduplicate.

***

## Signature verification

Verify every incoming delivery before processing it.

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

function verifyWebhook(req) {
  const webhookId        = req.headers['webhook-id'];
  const webhookTimestamp = req.headers['webhook-timestamp'];
  const webhookSignature = req.headers['webhook-signature'];

  // Reject if timestamp is more than 5 minutes old (replay attack prevention)
  const now       = Math.floor(Date.now() / 1000);
  const timestamp = Number(webhookTimestamp);
  if (Math.abs(now - timestamp) > 300) {
    throw new Error('Webhook timestamp out of tolerance.');
  }

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

  // Decode the whsec_ secret (base64url after the prefix)
  const secret = process.env.INVOICE_AI_WEBHOOK_SECRET; // e.g. "whsec_AbCd..."
  const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');

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

  // The header may carry multiple space-separated signatures during 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.');
  }
}
```

<Warning>
  Always use a **constant-time comparison** (`timingSafeEqual`) when checking signatures. A standard string equality check (`===`) is vulnerable to timing attacks that can allow an attacker to forge a valid signature.
</Warning>

***

## Error responses

| Status             | Cause                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `400 Bad Request`  | Missing `url`; URL uses HTTP instead of HTTPS; URL resolves to a private IP; unrecognised event type string. |
| `401 Unauthorized` | Missing or invalid `Authorization` header.                                                                   |
| `403 Forbidden`    | The API key lacks the `webhooks:manage` scope.                                                               |
| `404 Not Found`    | No endpoint with the given ID exists in your workspace.                                                      |
| `409 Conflict`     | An `Idempotency-Key` was reused with a different request body.                                               |
