Skip to main content
This example is a runnable starting point for a customer action executor. It:
  • listens only on 127.0.0.1 by 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-Id or X-Suppio-Event-Id to the body
  • handles customer_action.test without changing data
  • durably queues ticket.resolved events 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_id in a persistent SQLite database
  • stores and replays completed results without repeating a side effect
  • returns known action failures as structured HTTP 200 results
The example plan-change handler deliberately returns handler_not_configured, and the ticket closing path only stores durable pending work. Connect those records to your application or Discord bot and replace the sample Discord allowlist with resource-level authorization before enabling either feature.

Requirements

Use Node.js 24 or later. The example uses Node’s built-in SQLite module, Express, Ajv, and ajv-formats. Create an empty application directory:

Create package.json

Install the locked dependencies and commit the generated lockfile:

Create server.mjs

The example records the 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 connection
  • ticket.resolved, which Test connection does not send
Run both checks before enabling ticket closing notifications. The 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.
Start the executor in one terminal and run the smoke test in another:
The test should print 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 enabling change_customer_plan:
  1. Replace the handler_not_configured result with one narrow application transaction.
  2. Keep the handler mapped to the exact imported action name.
  3. Validate identifiers and enums against your application’s current state.
  4. Replace the global Discord allowlist with a lookup that proves actor.user_id may change the specific customer_id.
  5. Make the business operation idempotent as a second layer of protection when possible.
  6. Do not pass user arguments into shell commands, SQL text, arbitrary URLs, dynamic imports, or unrestricted operation names.
  7. Test with a non-production customer and keep Require confirmation on for mutations.
The imported action schema and the executor schema should describe the same arguments. Update both sides together.

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.