- listens only on
127.0.0.1by default - keeps the exact raw request body for signature verification
- enforces the 64 KB request limit
- rejects stale timestamps and invalid signatures before parsing JSON
- validates customer action and ticket resolution envelopes
- matches
X-Suppio-Action-IdorX-Suppio-Event-Idto the body - handles
customer_action.testwithout changing data - durably queues
ticket.resolvedevents without duplicating work - dispatches only explicitly allowlisted action names
- validates arguments with JSON Schema
- checks the Discord actor before claiming an action
- atomically claims every
action_idin a persistent SQLite database - stores and replays completed results without repeating a side effect
- returns known action failures as structured HTTP
200results
Requirements
Use Node.js 24 or later. The example uses Node’s built-in SQLite module, Express, Ajv, andajv-formats.
Create an empty application directory:
Create package.json
Create server.mjs
action_id before it calls the handler. If the process stops after a side effect but before the result is stored, a duplicate returns execution_unconfirmed and does not run the handler again. Reconcile that action manually from your application records.
For ticket.resolved, the example validates X-Suppio-Event-Id and stores one durable pending record per event_id before returning HTTP 204. Connect a background worker to ticket_close_events to perform your Discord or application-specific close operation, then change its status to completed. Keep failed work pending for your own retry or review process.
Create smoke-test.mjs
This script tests both request envelopes accepted by the executor:
customer_action.test, which is the event sent by Test connectionticket.resolved, which Test connection does not send
ticket.resolved check creates one pending local test record in
ticket_close_events; it does not call a close worker or change a Discord
channel.
Connection and ticket resolution events verified. If
the first request succeeds but the second returns HTTP 400, confirm that your
route branches on event === "ticket.resolved" before requiring
action_id or X-Suppio-Action-Id.
Connect your real handler
Before enablingchange_customer_plan:
- Replace the
handler_not_configuredresult with one narrow application transaction. - Keep the handler mapped to the exact imported action name.
- Validate identifiers and enums against your application’s current state.
- Replace the global Discord allowlist with a lookup that proves
actor.user_idmay change the specificcustomer_id. - Make the business operation idempotent as a second layer of protection when possible.
- Do not pass user arguments into shell commands, SQL text, arbitrary URLs, dynamic imports, or unrestricted operation names.
- Test with a non-production customer and keep Require confirmation on for mutations.
Publish it
For a VPS or private server, continue with Connect a customer action executor over HTTPS. That guide creates a writable/var/lib/customer-actions state directory, keeps the Node service on loopback, and publishes it through Caddy or Cloudflare Tunnel.
For managed hosting, set these environment variables in the provider’s secret and configuration settings:
Do not deploy this SQLite example to a platform that runs several independent instances against separate disks. Use one shared transactional database for
action_id claims, event_id claims, queued ticket-close work, and stored results in a horizontally scaled deployment.