Node 26.10 SQLite binds undefined as NULL: a Sume webhook job table

Node 26.10 binds undefined as NULL in node:sqlite. A Sume job-webhook handler can still be explicit with null and dedupe on job_id without a driver.

5 min readSume
All posts

If you store Sume webhook events with node:sqlite, write null yourself and do not lean on undefined. The Node.js 26.10.0 release notes say SQLite now binds undefined as NULL (Node.js 26.10.0 release notes, read 2026-10-04). On Node 22.14 the same call throws Provided value cannot be bound to SQLite parameter, which I confirmed by running the script below. A handler that works on your laptop on one Node line can therefore fail on a server running another.

A job webhook is a natural place to hit this. A job.completed event carries payload.artifacts, and a job.failed event carries an error object instead, so one of the two optional columns is always missing. Optional chaining then yields undefined, which is exactly the value whose binding changed.

What the job events contain

Sume sends terminal job events only: job.completed, job.failed and job.canceled. Each body has event, request_id, job_id, status and either a payload with artifacts or an error object (Webhooks). The docs tell you to use job_id as the idempotency key on your side, since a delivery can repeat, and to return any 2xx after durably storing the event.

That maps to a table with job_id as the primary key and an insert that does nothing on conflict. The return value of run() then tells you whether this delivery was new.

Job webhook fields and where they land in the table (Sume docs, read 2026-10-04)
Webhook fieldColumnWhen missing
job_idjob_id (primary key)Never; reject the delivery
eventeventNever
payload.artifacts[0].urlartifact_urlnull on failed and canceled
error.codeerror_codenull on completed

A handler body that runs on Node 22 and 26

The function below parses the raw body after your signature check, flattens the optional fields with ?? null, and returns true only for a first delivery. It uses an in-memory database so you can run it as a test; swap in a file path for real use.

import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE IF NOT EXISTS sume_jobs (
  job_id TEXT PRIMARY KEY,
  event TEXT NOT NULL,
  artifact_url TEXT,
  error_code TEXT,
  received_at INTEGER NOT NULL
)`);

const upsert = db.prepare(`INSERT INTO sume_jobs (job_id, event, artifact_url, error_code, received_at)
  VALUES (?, ?, ?, ?, ?) ON CONFLICT(job_id) DO NOTHING`);

export function record(body) {
  const e = JSON.parse(body);
  const art = e.payload?.artifacts?.[0];
  const r = upsert.run(e.job_id, e.event, art?.url ?? null, e.error?.code ?? null, Date.now());
  return r.changes === 1;
}

console.log(record('{"event":"job.completed","job_id":"job_1","payload":{"artifacts":[{"url":"https://media.sume.com/artifacts/a"}]}}'));
console.log(record('{"event":"job.completed","job_id":"job_1"}'));
console.log(db.prepare("SELECT * FROM sume_jobs").all());

Operational notes

  • Verify the signature on the raw body before you parse or insert anything. The SDK exports verifyWebhook for that.
  • Reply 2xx only after the insert commits. A non-2xx reply is retried, up to 10 attempts at a fixed 30-second spacing.
  • Keep polling GET /v1/jobs/{id} as the backstop for a delivery that never arrives.
  • Pin the Node version in your container so the binding behavior you tested is the one you run.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume