Node replace a Zap

A lead intake workflow accepts form submissions, routes urgent and event requests, rejects duplicates, and builds a daily digest with a JSON archive. Tokay runs the receiver as a Function and the digest as a Scheduled Service over one PostgreSQL database.

Last updated

Use this example when a visual automation has become important enough that you want its decisions in ordinary code. You can inspect each trigger, change the routing rule, and keep the resulting data in your own Project.

Runtime
Node.js
Framework
Express
Service types
Function, Scheduled Service
Resources
PostgreSQL, Persistent files
Required secrets
None

What it does

Send a name, email, service, and optional notes to /submit. The response tells you whether the lead entered the priority, events, or general route.

Repeat the same duplicate key and PostgreSQL keeps one lead while reporting the second request as a duplicate.

When the digest runs, its result counts each route and lists the last 24 hours of requests. Saved Files keeps a complete JSON archive for every run.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Try it

  1. Copy the Function URL from the top of the receiver Service page. Replace the placeholder below with that URL, then send this urgent lead.
export FUNCTION_URL="https://your-function-url"
curl -X POST "$FUNCTION_URL/submit" \
  -H 'Content-Type: application/json' \
  -H 'x-dedupe-key: patio-lights-001' \
  -d '{"name":"Mira Chen","email":"mira@example.test","service":"patio lighting","notes":"The old transformer is broken and the event is Friday."}'
  1. Send the same command again and confirm the response now reports a duplicate.
  2. Choose Run Now on the digest Service. Mira appears once under priority, and the archive is available in Saved Files.

How it works

You can find the whole automation boundary in two places. /submit replaces the trigger and routeLead owns the decision, while the schedule lives in Tokay where you can inspect or start a run.

The receiver honors x-dedupe-key or derives one from the payload. PostgreSQL increments a duplicate count instead of inserting the same lead twice. The digest reads the last 24 hours and writes its archive under Tokay's persistent data directory.

When you move another workflow, add a route for its trigger and express the decision in code. Give it another Scheduled Service only when it truly needs a separate clock.

Secrets

Name Required Purpose
ALERT_WEBHOOK_URL No Delivers the digest to a Discord or Slack compatible webhook. Add it as a Project Secret on the digest only.

DATABASE_URL comes from the shared managed PostgreSQL resource. It is not a Project Secret.

Grow from here

Try the webhook inspector when you want to study request validation and replay before adding workflow logic.

digest/digest.js Raw

const fs = require('fs/promises');
const path = require('path');
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const archiveRoot = process.env.TOKAY_DATA_DIR || path.join(__dirname, 'data');
const deliveryUrl = process.env.ALERT_WEBHOOK_URL;

const run = async () => {
  const result = await pool.query(
    `select id, name, email, service, notes, route_key, received_at
     from lead_requests
     where received_at >= now() - interval '24 hours'
     order by route_key, received_at`,
  );
  const counts = { priority: 0, events: 0, general: 0 };
  for (const lead of result.rows) counts[lead.route_key] += 1;

  const lines = [
    `Yesterday's leads: ${result.rowCount} total`,
    `Priority ${counts.priority} | Events ${counts.events} | General ${counts.general}`,
    ...result.rows.map(lead => `#${lead.id} [${lead.route_key}] ${lead.name} needs ${lead.service}`),
  ];
  const summary = lines.join('\n');
  const archive = {
    generatedAt: new Date().toISOString(),
    windowHours: 24,
    counts,
    leads: result.rows.map(lead => ({ ...lead, id: String(lead.id) })),
  };

  await fs.mkdir(archiveRoot, { recursive: true });
  const filename = `lead-digest-${new Date().toISOString().replaceAll(':', '-')}.json`;
  await fs.writeFile(path.join(archiveRoot, filename), `${JSON.stringify(archive, null, 2)}\n`);
  console.log(summary);
  console.log(`Archived ${filename}`);

  if (deliveryUrl) {
    const response = await fetch(deliveryUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: summary }),
    });
    if (!response.ok) throw new Error(`Digest delivery returned HTTP ${response.status}`);
    console.log('Delivered the digest webhook.');
  }
};

run()
  .catch((error) => {
    console.error('Lead digest failed.', error.message);
    process.exitCode = 1;
  })
  .finally(() => pool.end());

digest/package.json Raw

{
  "name": "lead-request-digest",
  "version": "1.0.0",
  "private": true,
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "start": "node digest.js"
  },
  "dependencies": {
    "pg": "^8.22.0"
  }
}

receiver/migrations/committed/000001-lead-requests.sql Raw

--! Previous: -
--! Hash: sha1:bc7c5a411c1542c212ca272789bc44fa8301c79e
--! Message: lead requests

create table lead_requests (
  id bigint generated always as identity primary key,
  dedupe_key text not null unique,
  name text not null check (char_length(name) between 1 and 100),
  email text not null check (char_length(email) between 3 and 200),
  service text not null check (char_length(service) between 1 and 120),
  notes text not null default '' check (char_length(notes) <= 2000),
  route_key text not null check (route_key in ('events', 'priority', 'general')),
  duplicate_count integer not null default 0,
  received_at timestamptz not null default now()
);

create index lead_requests_digest_idx on lead_requests (received_at desc, route_key);

receiver/.gmrc Raw

{
  "//generatedWith": "1.4.1"
}

receiver/package.json Raw

{
  "name": "lead-request-receiver",
  "version": "1.0.0",
  "private": true,
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "start": "node server.js",
    "migrate": "graphile-migrate migrate"
  },
  "dependencies": {
    "express": "^5.2.1",
    "graphile-migrate": "^1.4.1",
    "pg": "^8.22.0"
  }
}

receiver/server.js Raw

const crypto = require('crypto');
const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const port = Number(process.env.PORT || 3000);

app.use(express.json({ limit: '64kb' }));
app.use(express.urlencoded({ extended: false, limit: '64kb' }));

const text = (value, maximum) => String(value || '').trim().slice(0, maximum);
const routeLead = (service, notes) => {
  const content = `${service} ${notes}`.toLowerCase();
  if (/urgent|repair|broken|today|emergency/.test(content)) return 'priority';
  if (/wedding|party|festival|event/.test(content)) return 'events';
  return 'general';
};

app.post('/submit', async (request, response) => {
  const lead = {
    name: text(request.body.name, 100),
    email: text(request.body.email, 200),
    service: text(request.body.service, 120),
    notes: text(request.body.notes, 2000),
  };
  if (!lead.name || !lead.email.includes('@') || !lead.service) {
    return response.status(400).json({ error: 'name, a valid email, and service are required' });
  }

  const suppliedKey = text(request.get('x-dedupe-key'), 160);
  const dedupeKey = suppliedKey || crypto.createHash('sha256').update(JSON.stringify(lead)).digest('hex');
  const routeKey = routeLead(lead.service, lead.notes);
  const result = await pool.query(
    `insert into lead_requests (dedupe_key, name, email, service, notes, route_key)
     values ($1, $2, $3, $4, $5, $6)
     on conflict (dedupe_key) do update
       set duplicate_count = lead_requests.duplicate_count + 1
     returning id, route_key, duplicate_count, received_at`,
    [dedupeKey, lead.name, lead.email, lead.service, lead.notes, routeKey],
  );
  const saved = result.rows[0];
  console.log(JSON.stringify({
    event: 'lead.accepted',
    leadId: String(saved.id),
    route: saved.route_key,
    duplicate: saved.duplicate_count > 0,
  }));
  response.json({
    accepted: true,
    leadId: String(saved.id),
    route: saved.route_key,
    duplicate: saved.duplicate_count > 0,
  });
});

app.use((_request, response) => {
  response.status(405).json({ error: 'Send a POST request to /submit.' });
});

app.listen(port, '0.0.0.0', () => console.log(`Lead receiver listening on ${port}`));

const shutdown = async () => {
  await pool.end();
  process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Built from revision 52c7b7f

Guide: Replace Zapier with code