# Webhooks

Subscribe to change events on ledger and ACH entities and receive them as signed HTTP requests.

Source: https://www.twisp.com/docs/reference/api/webhooks

Webhooks deliver change events for records in your ledger to an HTTPS endpoint you control. Whenever a subscribed record is inserted, updated, or deleted, Twisp sends an HTTP `POST` to your endpoint with the new state of the record (and the previous state, when one exists).

## Creating an Endpoint

Subscribe to events by creating an endpoint with the `createEndpoint` mutation:

```graphql
mutation Setup {
  events {
    createEndpoint(
      input: {
        endpointId: "345940ed-2726-4b20-88aa-820857ac0e68"
        status: ENABLED
        endpointType: WEBHOOK
        url: "https://yourdomain.com/path/to/hooks"
        description: "subscribe to balance events"
        subscription: ["balance.*"]
        filters: {
          isMyCalcId: "document.calculation_id == uuid('5542ce2c-e43a-40b1-b04b-5dfb06f9f4d8')"
        }
      }
    ) {
      endpointId
      signingSecret
    }
  }
}
```

- Replace `url` with your actual webhook URL.
- Customize `subscription` to include the event types you are interested in. (`subscription: ["*"]` and `filters: {}` will get you the firehose.)
- Save the returned `signingSecret` — you will need it to verify request signatures.

See [EndpointInput](/docs/reference/graphql/types/input#endpoint-input) for all configuration options.

> **Note:**
>
> Webhooks work on the local version of Twisp too.

## Subscriptions

Each subscription is a string in the format `<entity>.<action>`, where action is one of `inserted`, `updated`, or `deleted`. Wildcards (`*`) are supported, so `balance.*` matches every balance event and `*` matches everything.

Supported entities:

- `journal`
- `account`
- `accountcontext`
- `accountset`
- `accountsetmember`
- `trancode`
- `transaction`
- `entry`
- `balance`
- `customindex`
- `custombalance`
- `endpoint`

Supported ACH entities:

- `configuration` — ACH processing configurations
- `fileinfo` — processing status of an ACH file
- `filerecord` — individual records within an ACH file
- `workflowtrace` — traces of ACH workflow execution

For example, to track the processing status of ACH files, subscribe to `fileinfo.inserted` and `fileinfo.updated`.

## Filters

The optional `filters` field is a map of named [CEL expressions](/docs/reference/cel) evaluated against each candidate event. The record is available as `document`, with fields in `snake_case`. An event is sent only if _all_ expressions evaluate to `true` (they are combined with a logical AND).

For example, to receive `transaction.inserted` events only for specific tran codes:

```graphql
mutation CreateEvents($endpointId: UUID = "uuid.New()" @cel) {
  events {
    createEndpoint(
      input: {
        endpointId: $endpointId
        endpointType: WEBHOOK
        url: "https://yourdomain.com/path/to/hooks"
        subscription: ["transaction.inserted"]
        description: "Subscribe to ACH_SETTLE_CR and ACH_SETTLE_DR"
        filters: {
          isWorkflowSettle: "document.tran_code_id in [uuid('d918eb34-1ef9-4437-a82b-46c169f63e41'), uuid('5af51352-84ad-4534-aacf-4e68c2ed2e2b')]"
        }
      }
    ) {
      endpointId
      signingSecret
    }
  }
}
```

## Event Payload

Events are delivered as a JSON document with the following envelope:

| Field           | Type      | Description                                                 |
| --------------- | --------- | ----------------------------------------------------------- |
| `eventId`       | UUID      | Unique identifier for this event.                           |
| `eventType`     | String    | The matched subscription, e.g. `balance.updated`.           |
| `accountId`     | String    | The Twisp tenant account id this event originated from.     |
| `region`        | String    | Region identifier this event originated from.               |
| `created`       | Timestamp | When the event was created.                                 |
| `recordRowId`   | UUID      | Unique identifier of the record in `data`.                  |
| `recordVersion` | Integer   | Monotonically increasing version of the record.             |
| `data`          | Object    | The state of the record after the change.                   |
| `previous`      | Object    | The state of the record before the change, when one exists. |

Payloads generally follow their corresponding [protobuf schemas](https://buf.build/twisp/api/docs/main:twisp.core.v1) utilizing camelCase keys. Timestamps, enumerations, dates, and UUIDs are represented as strings.

Example `balance.updated` payload:

```json
{
  "eventId": "3f6e9283-5d96-546e-87db-3bf3c7d29011",
  "eventType": "balance.updated",
  "accountId": "000000000000",
  "region": "us-west-2",
  "created": "2025-08-15T12:55:49.67324-07:00",
  "recordRowId": "0198af4c-ffcc-44e6-bdcb-5567ba61a9e4",
  "recordVersion": 5,
  "data": {
    "accountId": "bf716346-9eee-4ec5-bbc3-64c47de7d929",
    "calculationId": "00000000-0000-0000-0000-000000000000",
    "created": "2025-08-15T19:55:15.777126Z",
    "currency": "USD",
    "encumbrance": {
      "crBalance": "0 USD",
      "drBalance": "0 USD",
      "entryId": "00000000-0000-0000-0000-000000000000",
      "modified": "2025-08-15T19:55:15.777126Z"
    },
    "entryId": "9fdc093a-78d1-4ede-a78f-a3420c6f11b7",
    "journalId": "00000000-0000-0000-0000-000000000000",
    "modified": "2025-08-15T19:55:49.453565Z",
    "pending": {
      "crBalance": "0 USD",
      "drBalance": "0 USD",
      "entryId": "00000000-0000-0000-0000-000000000000",
      "modified": "2025-08-15T19:55:15.777126Z"
    },
    "settled": {
      "crBalance": "200.00 USD",
      "drBalance": "300.00 USD",
      "entryId": "9fdc093a-78d1-4ede-a78f-a3420c6f11b7",
      "modified": "2025-08-15T19:55:49.453565Z"
    },
    "transactionId": "317f95f4-d8e5-443a-b7ce-e3e6abefdb17",
    "version": 5
  },
  "previous": {
    "accountId": "bf716346-9eee-4ec5-bbc3-64c47de7d929",
    "calculationId": "00000000-0000-0000-0000-000000000000",
    "created": "2025-08-15T19:55:15.777126Z",
    "currency": "USD",
    "encumbrance": {
      "crBalance": "0 USD",
      "drBalance": "0 USD",
      "entryId": "00000000-0000-0000-0000-000000000000",
      "modified": "2025-08-15T19:55:15.777126Z"
    },
    "entryId": "c24e0f2b-6469-4c15-a204-f48d1b78cf74",
    "journalId": "00000000-0000-0000-0000-000000000000",
    "modified": "2025-08-15T19:55:41.364982Z",
    "pending": {
      "crBalance": "0 USD",
      "drBalance": "0 USD",
      "entryId": "00000000-0000-0000-0000-000000000000",
      "modified": "2025-08-15T19:55:15.777126Z"
    },
    "settled": {
      "crBalance": "200.00 USD",
      "drBalance": "200.00 USD",
      "entryId": "c24e0f2b-6469-4c15-a204-f48d1b78cf74",
      "modified": "2025-08-15T19:55:41.364982Z"
    },
    "transactionId": "fcf99a28-0c00-4daa-a684-dbec2a046d16",
    "version": 4
  }
}
```

## Verifying Requests

Every webhook request includes two headers:

| Header               | Description                                                    |
| -------------------- | -------------------------------------------------------------- |
| `x-twisp-signature`  | HMAC SHA-256 signature of the request body, hex encoded.       |
| `x-twisp-account-id` | The Twisp tenant account identifier the event originated from. |

To validate that a request is authentic, recreate the signature with your endpoint's `signingSecret` and the raw request body, then compare it against the `x-twisp-signature` header. Reject any request where the signatures don't match.

```go
import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "net/http"
)

func validateRequest(r *http.Request, body []byte, signingSecret string) bool {
  signature := r.Header.Get("x-twisp-signature")
  mac := hmac.New(sha256.New, []byte(signingSecret))
  mac.Write(body)
  expectedSignature := hex.EncodeToString(mac.Sum(nil))
  return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
```

## Delivery and Retries

Twisp sends an event to every endpoint whose subscription and filters match. If your endpoint responds with a `2XX` HTTP status code, the delivery is considered successful.

On any other status code or connection error, Twisp retries the request every 471 seconds for up to 24 hours (roughly 185 attempts). Design your handler to be idempotent — use `eventId` to deduplicate, or `recordRowId` and `recordVersion` to detect out-of-order or duplicate deliveries.
