Skip to content

Integrations

Connect an App to a payment, messaging or CRM provider through a package, with each tenant’s secrets, signed webhooks and retried calls kept by core.

A vendor package exports one defineIntegration from tablewalk/integration. It says which secrets it needs, how to build a client from them, the actions a command may call, the webhook events it verifies, and an optional channel. It never stores a secret, retries a call or reads a row: core does.

import { defineIntegration, int, refuse, shape, text } from 'tablewalk/integration';
export const fakepay = () =>
defineIntegration({
name: 'fakepay',
secrets: ['apiKey', 'webhookSecret'],
client: ({ secrets }) => new FakePayClient(secrets.apiKey),
actions: {
charge: {
input: shape({ amount: int({ min: 1 }), customer: text() }),
output: shape({ id: text(), status: text() }),
async run(client, input, { idempotencyKey, signal }) {
const charge = await client.charge(input, { idempotencyKey, signal });
if (charge.declined) refuse('The card was declined.');
return { id: charge.id, status: charge.status };
},
},
},
webhooks: {
verify: ({ headers, body }, { secrets }) => verifySignature(headers, body, secrets.webhookSecret),
events: {
paid: { type: 'charge.succeeded', payload: shape({ charge: text(), amount: int() }) },
stopped: { type: 'sms.stop', payload: shape({ from: text() }), optOut: 'from' },
},
},
channel: { name: 'sms', send: (client, message) => client.text(message) },
});
// Types each App's calls: `integrations.fakepay.charge(input)` takes and returns these shapes.
declare module 'tablewalk/integration' {
interface IntegrationRegistry {
fakepay: ReturnType<typeof fakepay>;
}
}

verify returns the event ({ id, type, at, data }) or null for a signature that does not hold. refuse(reason) is a definite no, recorded and not retried; any other throw is retried.

The signature must cover the event time, and at must be read from what it covers, never from a header of its own: core refuses a delivery more than five minutes from now and takes the same event id and time once, but an opt-in or opt-out carries no other key, so a package that trusted an unsigned time would let a captured delivery be replayed with a fresh one.

The jobs module lists the integrations, maps each webhook event to a command and names the principal it runs as. A command declares the actions it calls and calls them from its lookup, before its transaction opens: typed by the package’s shapes, and only the actions it declares. A send(...) effect goes out on an integration’s channel after commit.

jobs.ts
export default defineJobs({}, {
principals: { payments: { roles: ['payments'] } }, // a role with "service": true
integrations: {
use: [fakepay()],
webhooks: { fakepay: { runAs: 'payments', events: { paid: 'markPaid' } } },
quietHours: { from: '21:00', to: '08:00', timezone: 'America/New_York' },
editors: ['owner'],
},
});
// commands.ts
chargeOrder: defineCommand({
source: 'business', target: 'orders', input: {},
integrations: { fakepay: ['charge'] },
lookup: ({ record, integrations }) => integrations.fakepay.charge({ amount: record.amount, customer: record.customer }),
handle: ({ db, record, lookup }) => db.orders.update(record, { status: 'charged', charge_id: lookup.id }),
effects: { text: send('sms', (order) => ({ to: order.phone, text: 'Thanks for your order.', consent: order.sms_consent })) },
}),

A notify rule tells on a channel too with send: the title is the message, queued in the write’s own transaction and sent after it commits, under the same consent, opt-out and quiet hours as send(...). to may be left out.

orders: { notify: [{ on: { changes: 'status' }, when: 'status = shipped', title: 'Order {id} has shipped.',
send: { channel: 'sms', to: 'customer.phone', consent: 'customer.sms_ok', timezone: 'customer.timezone' } }] }

Add integrationsForm('Integrations') to a dashboard for the screen where an editor enters the secrets. It loads on demand and never shows a saved value.

  • Secrets: sealed per App, tenant, integration and name under TABLEWALK_SECRETS_KEY (at least 32 bytes); a server that lists integrations without it does not start.
  • Calls: each is journaled under an idempotency key the provider dedupes by, tried up to three times when it fails on the way, and answered from the journal when the command is replayed.
  • Webhooks: an inbound endpoint, the same one a defineInbound is: POST /api/inbound/<name> (on a tenant App, /api/inbound/<name>/<tenant>, verified with that tenant’s secret). The package’s verify reads the signature; everything else is the inbound endpoint’s: past 256 KB is 413, a bad signature or a time more than five minutes off is 401, the same delivery again answers the first one’s receipt, an event the App does not map is 202.
  • Events: a mapped event runs its command as the service principal, with that principal’s grants, in the tenant whose secret verified it.
  • Channels: a message is suppressed when the recipient opted out or the App holds no consent, held through quiet hours, and logged either way. channel: 'email' is built in and mails an address the record holds (email).
  • Check: tablewalk check refuses an unknown integration, action, event or channel by its code.

Provision once, from tablewalk/maintenance: provisionStores with the App and its jobs creates the integrations store beside the job store, with the App’s commands alone the delivery of each post(...) and send(...), and only: ['integrations'] the store by itself. Serving never creates them; tablewalk migrate stores brings the integrations store, as every store, to this release’s layout.