Webhooks

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

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:

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 for all configuration options.

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 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:

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:

FieldTypeDescription
eventIdUUIDUnique identifier for this event.
eventTypeStringThe matched subscription, e.g. balance.updated.
accountIdStringThe Twisp tenant account id this event originated from.
regionStringRegion identifier this event originated from.
createdTimestampWhen the event was created.
recordRowIdUUIDUnique identifier of the record in data.
recordVersionIntegerMonotonically increasing version of the record.
dataObjectThe state of the record after the change.
previousObjectThe state of the record before the change, when one exists.

Payloads generally follow their corresponding protobuf schemas utilizing camelCase keys. Timestamps, enumerations, dates, and UUIDs are represented as strings.

Example balance.updated payload:

{
  "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:

HeaderDescription
x-twisp-signatureHMAC SHA-256 signature of the request body, hex encoded.
x-twisp-account-idThe 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.

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.

Previous
Extensions