Skip to content
ArenileArenile
Coste360.itCoste360.it

Events and outgoing webhooks

Every relevant change (a confirmed booking, a payment, a price list change) produces a domain event. Events feed the panel’s real time, emails, extensions and outgoing webhooks towards your systems.

The event is written to the outbox_events table in the same transaction as the change that produces it. So:

  • the event exists if and only if the change was committed: no event for a rolled-back operation, no change without an event;
  • a job then delivers it to consumers, at least once: consumers must be idempotent on the event id.
{
"id": "evt_01JB7K3Q9X…",
"type": "booking.confirmed",
"establishment_id": "0192f…",
"occurred_at": "2027-07-01T09:12:44Z",
"data": { "...": "..." },
"version": 1
}

version is the payload version of that type: an incompatible change creates a new version. Payloads contain identifiers, dates, amounts and statuses; never personal data in clear or secrets.

Area Events
Bookings booking.created, booking.held, booking.confirmed, booking.modified, booking.cancelled, booking.checked_in, booking.no_show, booking.completed, booking.approval_requested, booking.rejected, booking.expired
Availability allocation.released, allocation.assigned, hold.created, hold.released, hold.converted, spot_block.created, spot_block.deleted
Season passes season.day_released, season.day_release_revoked, season.renewal_offered
Payments payment.pending, payment.succeeded, payment.failed, payment.refunded, payment.refund_scheduled (automatic refund started: late or double payment)
Customers customer.created, customer.updated, customer.anonymized
Cash and fiscal cash_session.opened, cash_session.closed, cash_movement.recorded, fiscal_document.recorded
Configuration config.pricing_changed, config.map_changed, config.online_changed
Notifications message.failed

config.pricing_changed, config.map_changed and config.online_changed share payload version 1: whoever receives them treats them as “reload the establishment’s configuration”.

{
"type": "config.pricing_changed",
"version": 1,
"data": {
"entity": "price_rule",
"entity_id": "01J…",
"change": "updated",
"season_id": "01J…",
"version": 3
}
}
  • entity: for prices price_rule, fixed_price, promo_code, agreement, booking_policy, season_copy, season, rate_period, establishment_settings, pack, extra, setup; for the map map, zone, pool, setup; for the public page online_settings, terms_version, payment_settings, email_settings (never the secret values).
  • change: created, updated, deleted, archived, copied, saved, rows_generated, applied.
  • The guided setup writes a single event per type, with reason (setup_template, quick_pricelist), template, map_ids, zone_ids and counts.

The owner configures endpoints from the panel, with event filters, test event, delivery log, redelivery and secret rotation with a grace period (see API keys and webhooks). This is how delivery works:

Aspect Behaviour
Request JSON POST with the event as body, User-Agent: Arenile-Webhooks/1
Headers Arenile-Signature, Arenile-Event-Id, Arenile-Event-Type
Retries Exponential back-off up to 24 hours
Disabling After 20 consecutive failed deliveries the endpoint is disabled, with an activity log entry
Several instances Each attempt is “claimed”: two instances on the same database never send twice
Arenile-Signature: t=1782896564,v1=5f2b8c…

v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret (whsec_…). The timestamp is part of the signed message: reject requests whose t is too far from the current time (5 minutes is a good value). During a secret rotation there may be several v1 values: one match is enough.

import crypto from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.groupBy(header.split(','), (p) => p.split('=')[0]);
const t = parts.t?.[0]?.slice(2);
const sigs = (parts.v1 ?? []).map((p) => p.slice(3));
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => s.length === want.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(want)));
}

GET /api/admin/v1/stream is a Server-Sent Events stream authenticated with the session. The SSE event name is a topic and the content is light, with no personal data:

Topic Produced by
booking.changed bookings and payments
allocation.changed occupations, releases, blocks
hold.changed temporary holds
cash.changed cash desk and fiscal documents
customer.changed customers
config.changed configuration
event: allocation.changed
id: 01JB7K…
data: {"type":"booking.confirmed","booking_id":"…","spot_ids":["…"],"dates":["2027-08-14"]}

The client invalidates the affected data and reloads it. After a disconnection, the browser reconnects with Last-Event-ID and receives the missed events (if they are no longer in memory, the panel reloads its data). Several instances in parallel need an EventBus extension (for example PostgreSQL LISTEN/NOTIFY).

Go extensions receive every event through the ext.EventSink interface (see Extensions). Transactional emails are an internal consumer too: each email has a per-event de-duplication key, so at-least-once delivery never sends two emails.