Skip to content

Repository files navigation

notifuse-zapier

The Zapier integration for Notifuse. Triggers are REST Hooks layered on Notifuse's outbound webhook subsystem; actions call its RPC API. Written in TypeScript against zapier-platform-core 19.

The rules that keep this app reviewable — and the failure modes that are silent rather than loud — are in CLAUDE.md. Read it before changing anything structural. The backend lives in a separate repository (Notifuse/notifuse), and so does the user-facing documentation (published at docs.notifuse.com/integrations/zapier).

What v1 ships

Triggers (REST Hooks) Subscribed event types
New Contact contact.created
Updated Contact contact.updated — a save that changes no stored field emits nothing
New List Subscriber list.subscribed, list.confirmed, list.resubscribed
Contact Unsubscribed From List list.unsubscribed
Contact Joined Segment segment.joined
Contact Left Segment segment.left
Actions Endpoint
Create or Update Contact POST /api/contacts.upsert
Subscribe Contact to List POST /api/lists.subscribe

Three dynamic dropdowns — workspace, list, segment — fill the input fields. Zapier has no separate concept for a picker, so each is registered as a hidden polling trigger.

Commands

Node 22 or newer. That is the major Zapier executes integrations on, and it is also the floor the CLI enforces at startup — despite zapier-platform-cli advertising engines.node: ">=18.20" in its own package.json. On Node 20 the install succeeds and then every zapier-platform command exits 2 with "Requires node version >= v22".

npm install
npm test                        # vitest
npm run typecheck               # tsc over src/, test/ and the config files
npm run build                   # tsc + copy the generated samples into dist/
npm run validate                # build, then schema + publishing checks
npx zapier-platform test        # npm test + validate
npx zapier-platform invoke      # run one operation locally

The CLI binary is zapier-platform, not zapier — the short name was removed in platform v19.0.0. Anything online that says zapier push is stale.

npm run typecheck deliberately points at tsconfig.test.json rather than the default project: tsconfig.json covers src/ alone, because that is what tsc emits to dist/, and a typecheck that quietly skipped test/ would let the tests drift from the interfaces they are supposed to pin.

npm run validate needs no login. Its schema half runs entirely locally and is the half that can fail the command; its publishing checks POST the definition to zapier.com, so they need network but no credentials, and they only ever print — an unreachable API is the one way they change the exit code. CI therefore runs npm run validate -- --without-style, and the answers to the warnings the full run reports are recorded under Warnings validate reports that are not bugs below. push, promote, migrate and register all need a login — see Release.

Layout

src/
  index.ts             defineApp — auth, middleware, and the trigger/action registry
  authentication.ts    custom auth: apiUrl (optional, cloud default) + apiKey
  constants.ts         the cloud API host, shared so auth and middleware need not import each other
  middleware.ts        beforeRequest: URL normalisation + bearer token; afterResponse: error mapping
  shapes/              the contract layer — one canonical record per trigger noun
  samples/             payloads.json, GENERATED by the backend, plus its loader
  triggers/ creates/ dropdowns/ hooks/
test/
  app.test.ts          the app-level audit — registry, hook contract, samples, module boundaries
  shapes/ triggers/ creates/ dropdowns/   one file per module, co-located by directory

The shape layer

Notifuse describes the same record two different ways depending on which door it comes through, and Zapier requires a hook trigger's performList records to match its hook payload in spelling, casing and nesting. When they diverge nothing errors: the Zap keeps running and every field the user mapped resolves to blank, on every run, until someone notices.

  • A webhook payload is to_jsonb() over the database row: raw column names, every column present, an unset one sent as null, and the db_* bookkeeping columns included. list.* also carries previous_status, which no read endpoint can reproduce.
  • An API response is the marshalled Go struct: JSON tags, omitempty so an unset field is absent, no db_* columns, and joins (contact_lists, contact_segments) the webhook never carries.

So each module in src/shapes/ exports fromWebhook(envelope) and fromApi(record, …) returning one canonical object, and both perform and performList return that object and nothing else. A field one path genuinely cannot know is emitted as null on that path rather than left out, because a key present in one and missing from the other is exactly what breaks a mapping.

Module fromWebhook fromApi Null on the read path
contact.ts contact.created, contact.updated a contacts.list record
listMembership.ts list.subscribed, list.confirmed, list.resubscribed, list.unsubscribed (contact, listId) from contacts.list?list_id=…&with_contact_lists=true previous_status, event_type
segmentMembership.ts segment.joined, segment.left (member, segment) from segments.contacts?expand=contact

src/shapes/index.ts re-exports all three under namespaces, so a trigger can import { contact } from '../shapes/index.js' and call contact.fromWebhook(...).

If you find yourself writing bundle.cleanedRequest.data.something outside src/shapes/, stop: that is the bug this layer exists to prevent.

The canonical contact field set is pinned in two repositories. canonicalContactFields in the backend's tests/integration/webhook_api_parity_test.go holds the webhook payload and contacts.list to the same shape against a real database, and test/shapes/contact.test.ts holds this module to the same list. Change one and change the other.

id is not a dedupe key

Every canonical record carries id: the delivery row id on the hook path, a value derived from the record's own identity (contact:<email>, list:<listId>:<email>, segment:<segmentId>:<email>) on the read path, so a poll does not appear to return new records every time.

Hook triggers are not deduplicated by Zapier — every POST fires the Zap. id is for legibility and support only, and no compound id_updatedAt key would change that; that pattern belongs to polling triggers.

Samples

src/samples/payloads.json is generated by the backend's tests/integration/webhook_payload_samples_test.go, which inserts a real record of each kind and captures the resulting webhook_deliveries.payload. Never edit it by hand, and never derive a sample from Notifuse's webhookSubscriptions.test endpoint, which invents fields (subject, url, bounce_type, tags) that appear in no real delivery.

Regenerate it in the backend repository with UPDATE_WEBHOOK_SAMPLES=1 make test-integration, then copy the file across. A trigger change that alters a payload shows up as a diff here.

Read it through sampleEnvelope(eventType) from src/samples/index.js, which hands out a copy so one caller cannot reshape another's. Building a trigger's static sample by passing that envelope through the shape module is the only way to be certain the sample matches what the hook delivers — and remember the direction the check runs in: the sample must be a subset of the live keys, never a superset.

Authentication and the self-hosting burden

Custom auth, two fields: an optional apiUrl defaulting to the Notifuse Cloud API, and a required apiKey stored as a password. The connection test is GET /api/workspaces.list — it needs no workspace_id, no permission gates it, it answers with a bare array, and it reads the database, so a revoked key fails it. The connection is labelled <workspace> (<host>), because one customer can hold connections to several instances and workspace names repeat across them.

workspace_id is deliberately not an auth field. Nearly every endpoint requires it and it cannot be derived from the key, but custom auth has no computed fields — so it lives in a per-operation input field backed by a dynamic dropdown over workspaces.list, which resolves to the key's single workspace and selects it. A connection is therefore inherently single-workspace.

beforeRequest is where the self-hosting support burden lives. It resolves /api/… against the user's instance, adds a missing scheme, strips trailing slashes and a pasted /api suffix, collapses doubled slashes, and injects Authorization: Bearer. This is not defensive programming for its own sake: the issue trackers of self-hostable Zapier apps are mostly trailing slashes and missing schemes rather than logic bugs.

Plain http:// is refused with a message naming the URL. Zapier requires HTTPS for public integrations and accepts only public-CA certificates, so a plain-HTTP or self-signed instance cannot be supported — it fails at the first request instead of somewhere less legible.

afterResponse maps 401 to ExpiredAuthError (which prompts a reconnection rather than quietly disabling the Zap), 403 to a message naming the permission the backend says is missing, and everything else to the API's own {"error": …} text. A request that sets skipThrowForStatus is passed through untouched, so an operation that wants to read its own failure — performSubscribe tolerating a subscription that already exists — still can.

Registering a trigger or an action

src/index.ts is the registry: every trigger, action and dropdown is imported there and registered under module.key, so the key a Zap stores, the key a dynamic dropdown references and the key the module declares are the same string by construction. An operation registered under any other key is accepted by the schema and then unreachable — test/app.test.ts is what stops that, along with the rest of the app-level audit.

The one app-wide flag is flags.cleanInputData: false. By default the platform strips empty strings, nulls and empty arrays out of bundle.inputData before an operation ever sees them, which would erase the distinction the contact actions are built on: an omitted field leaves the stored value alone, an empty one would blank it. That decision belongs to buildContactPayload, not to a default, and the flag is set once so a new operation inherits the same reading.

Adding an operation is not just wiring: every visible operation needs a live Zap and one successful run in the integration admins' account before public review passes (S002, T001), and Zapier has blocked migrating users across integration majors since February 2026 — the first published major can never be escaped. Six well-tested triggers beat twelve shaky ones.

Three tests are required for a trigger, all described in CLAUDE.md: shape parity, sample conformance, and the operation itself (perform returns an array; performList works with zero subscription state and no side effects; performSubscribe is idempotent). test/app.test.ts then re-checks the review rules across the whole app — that every hook has performSubscribe, performUnsubscribe and performList; that every sample is a subset, at every level, of the keys a real run produces from src/samples/payloads.json; that no canonical record carries a raw envelope or db_* field; and that bundle.subscribeData, bundle.targetUrl and bundle.cleanedRequest are each read in exactly one module.

Warnings validate reports that are not bugs

npx zapier-platform validate is clean of errors and of publishing tasks. The general warnings it still reports are answers rather than omissions:

  • D004 on external_id, twice — "looks like an ID field but lacks a dynamic dropdown". It is the customer's identifier for the contact in their system, a CRM or billing id, so there is nothing on the Notifuse side to enumerate. The field key has to stay external_id because that is what the API stores it as, and renaming it would break the parity between what a user types into an action and what they map out of a trigger.
  • D026 on apiUrl — "consider manual validation if allowing users to enter domain or subdomain". Notifuse is self-hostable, so the field exists on purpose; beforeRequest normalises what is typed and refuses plain HTTP outright, and Zapier's own guidance names a self-hosted domain as a legitimate connection label.

General warnings do not block pushing or publishing; they are read by a human reviewer. Do not silence them by weakening the design.

Backend prerequisites

This app requires Notifuse 39.0 or newer. Specifically it depends on webhookSubscriptions.create accepting source and the list_ids / segment_ids filters, on segments.contacts?expand=contact, on contacts.upsert returning the stored contact and lists.subscribe returning one entry per list, and on permission denials answering 403. An older instance answers some of those with a 400 that names nothing useful.

Release

pushpromotemigrate FROM TO [PERCENT]deprecate, all of them requiring npx zapier-platform login and an integration admin account.

npx zapier-platform push               # upload this version — private, visible only to admins
npx zapier-platform promote 1.0.0      # make a version public and the default for new Zaps
npx zapier-platform migrate 1.0.0 1.0.1 100   # move existing users onto a newer version
npx zapier-platform deprecate 1.0.0 2027-01-01

migrate works only within a major, and cross-major migration has been blocked since February 2026. There is no path from 1.x to 2.x for a user who already has a Zap: their Zaps stay on the major they were built against forever. So the first published major is not a first draft — a trigger key, an output field name or a sample that is wrong when promote runs is wrong for the life of the integration.

What follows from that, in order:

  1. Push early and often. A pushed version is private until promoted, so it costs nothing and zapier-platform invoke -a <auth-id> can drive it with production auth.
  2. Recruit beta users into the private version before the first promote — S001 needs three users with a live Zap, S002 needs one per visible operation, and T001 needs a successful run for each in the admins' Zap History.
  3. Only then promote. The directory listing carries a Beta tag for 90 days.

Reviewer access is a real dependency and the demo instance cannot serve it: createAPIKey is restricted in demo mode and the webhook delivery worker is disabled there entirely, so no trigger would ever fire for a reviewer. Someone has to own a stable, permanently reachable instance with a non-expiring test account before submission.

About

Zapier integration

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages