Skip to content

Webhooks ​

Everything here is exported from @klappay/types's webhook-related modules (webhook-events.ts, webhooks.ts, webhook-event-data.ts) — import from the package root either way, this split is an internal implementation detail.

Event types, and the two categories ​

Every event Klappay can send is one WebhookEventTypeSchema value, built from two sub-enums (each independently exported, useful if you only ever care about one group):

Sub-schemaCategoryEvents
ChargeWebhookEventTypeSchemapaymentscharge.created, charge.partially_paid, charge.confirmed, charge.expired, charge.underpaid, charge.settled, charge.settlement_failed, charge.overpaid, charge.escrow_released
WebhookDeliveryEventTypeSchemawebhookswebhook.delivery_failed, webhook.delivery_recovered, webhook.endpoint_unhealthy

EVENT_CATEGORY_MAP (Record<WebhookEventType, WebhookCategory>) is the single source of truth for which category an event belongs to — generated from the two enums above, never hand-duplicated. Its inverse, WEBHOOK_EVENT_CATEGORIES (Record<WebhookCategory, readonly WebhookEventType[]>), gives you every event in a category, e.g. for building a subscription UI.

Two events worth calling out specifically:

  • charge.confirmed vs. charge.settled — confirmed means the payment was detected on-chain; settled means the merchant's wallet actually received the funds, a separate, later step (see settlementStatus in charges.md). Subscribe to confirmed if you only need "will I get paid", or settled if you need "has the money actually arrived".
  • charge.overpaid fires alongside charge.confirmed/ charge.partially_paid whenever the cumulative amount received ends up above amount — it's an additional signal, not a replacement status.
  • charge.escrow_released fires once an escrow-configured charge's funds have moved from its Safe to the split address via POST /v1/charges/{id}/release — see charges.md's escrow section. A normal charge.settled still follows once the split itself finishes distributing.

Every event in the payments category carries the full Charge object as data.

Subscribing — CreateWebhookSchema ​

POST /v1/webhooks's body. At least one of events or eventCategories is required (enforced by a .refine(), not just documentation). Type a pre-parse request object as CreateWebhookRequest, not CreateWebhookInput — events/eventCategories/excludeEvents are all optional there (the schema defaults each to []):

ts
import { CreateWebhookSchema, type CreateWebhookRequest } from '@klappay/types'

const request: CreateWebhookRequest = {
  url: 'https://example.com/webhooks/klap',
  eventCategories: ['payments'],
}

CreateWebhookSchema.parse(request)
  • events — individual event types, or "*" (WEBHOOK_EVENTS_WILDCARD) for every event.
  • eventCategories — subscribe to a whole category at once (see the table above) — new events added to that category later arrive automatically, no subscription update needed.
  • excludeEvents — opt back out of specific events even under a "*" or category subscription.
  • url must be HTTPS and resolve to a public address — private/internal IPs are rejected server-side.

WebhookSchema / Webhook is the response to creating one — critically, secret (the HMAC signing secret) is only ever present in this response. WebhookListItemSchema / WebhookListItem is what every subsequent list/read returns instead: the same shape minus secret, plus hint (a truncated, safe-to-display form, e.g. whsec_...ab12).

environment ('live' | 'test' | null) is set automatically from whichever API key created the webhook — there's no request field for it, you can't choose or override it. null means the webhook was created before this field existed and receives every environment, same as before. Both payments and webhooks-category events carry an environment, and are only delivered to a webhook whose environment matches the event's own. Register a webhook with each of your live and test API keys if you want separate endpoints/handlers per environment — a test-key webhook never receives a real live charge event, and a live-key webhook never receives a sandbox-triggered one.

The payload envelope ​

WebhookPayloadSchema / WebhookPayload is the wire shape of every delivery: { id, event, createdAt, data }. data is typed unknown here on purpose — its real shape depends on event, which a single flat schema can't express. id is also sent as the X-Klappay-Delivery header, distinct from the delivery-id-per-registered-webhook used internally.

For the typed, narrowed version, use TypedWebhookPayload — a discriminated union over event (same pattern as Stripe's Event.data.object):

ts
import type { TypedWebhookPayload } from '@klappay/types'

function handle(payload: TypedWebhookPayload) {
  if (payload.event === 'charge.confirmed') {
    payload.data.amountReceived // typed as Charge, no cast needed
  } else if (payload.event === 'webhook.endpoint_unhealthy') {
    payload.data.failureRatio // typed as WebhookHealthEventData
  }
}

WebhookEventDataMap is the underlying { event: dataShape } mapping TypedWebhookPayload is built from — every charge.* event carries a full Charge; every webhook.* delivery-health event carries a small, event-specific object (a plain TypeScript type, not a Zod schema, since it's never validated standalone, only as part of TypedWebhookPayload):

Event(s)data shape
webhook.delivery_failed / webhook.delivery_recovered / webhook.endpoint_unhealthyWebhookHealthEventData — { webhookId, url, failureRatio? } (failureRatio only on endpoint_unhealthy)

Delivery health ​

WebhookDeliveryStatusSchema / WebhookDeliveryStatus — 'pending' | 'delivered' | 'failed' (failed means all 5 retry attempts over ~24h were exhausted; retry manually via POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry). WebhookDeliverySchema / WebhookDelivery is one entry from GET /v1/webhooks/{id}/deliveries — responseCode: null means every attempt failed to connect at all, not just a non-2xx response. ListWebhookDeliveriesSchema/PaginatedWebhookDeliveriesSchema are the query/response shapes for that endpoint — same shared cursor pagination as GET /v1/charges, see charges.md. GET /v1/webhooks itself (listing the webhooks, not their deliveries) stays unpaginated — it's hard-capped at 20 active webhooks per tenant.

The webhook.* meta-events (WebhookDeliveryEventTypeSchema) let you monitor this without polling: webhook.endpoint_unhealthy fires once when a webhook's trailing-24h failure rate crosses 20%, webhook.delivery_recovered fires once when a delivery next succeeds afterward.

See also ​

  • charges.md — the Charge shape carried by every charge.* event's data.
  • sandbox.md — TriggerableChargeEvent, the subset of charge events you can simulate without a real on-chain transfer.

Public docs for @klappay/types, mirrored from this package's own docs/ folder.