Subchapter 3.2
references/function-triggers.mdMarkdown11 KBView on GitHub
A Function Trigger is a branch-scoped rule that POSTs to a Neon Function so recurring work does not need a separate scheduler. The request is a normal fetch invocation: same public URL, same 15-minute time-to-first-byte limit, same injected env (DATABASE_URL, …).
Same regions as Functions: aws-us-east-2, aws-us-east-1, aws-eu-central-1, and aws-ap-southeast-1. Needs Neon CLI 4.21 or newer to declare triggers in neon.ts.
If neon deploy returns 404 function triggers not available for this project, the project does not have Function Triggers yet. Deploy the function without applying the trigger (neon functions deploy <slug> --src <entry>) and retry neon deploy once the project has them.
triggers is a keyed map on defineConfig. Types:
type | When it fires | neon.ts fields | CLI create |
|---|---|---|---|
schedule | On a five-field UTC cron expression | function, cron | neon triggers create --cron '…' |
storage_object_created | When an object is created in a declared bucket | function, bucket, optional prefix | neon triggers create --bucket <name> |
create takes --cron or --bucket, not both. @neon/functions ≥ 0.11.0: parseTriggerDelivery accepts both types; parseTriggerInvocation and Hono parseTrigger(c) stay schedule-only (storage_object_created is invalid_body there).
The trigger name is the neon.ts map key (CLI --name). It must be unique among every trigger visible on the branch, including other functions.
| Field | Required | Notes |
|---|---|---|
type | yes | "schedule" or "storage_object_created" |
function | yes | Function slug. REST/MCP: function_slug |
cron | schedule | Five-field UTC expression, e.g. 0 * * * *, */15 * * * * |
bucket | storage | Bucket name. REST: storage_object_created.bucket_name |
prefix | no | Object-key prefix filter. REST: storage_object_created.prefix |
functionPath | no | Path on the function. Default /. CLI: --function-path |
enabled | no | Default true. CLI: --enabled false to create disabled |
Declare triggers next to functions (and buckets when using storage). neon deploy applies triggers after the functions they target. Triggers that exist remotely but are omitted from neon.ts are left alone.
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
ingest: { name: "Object ingest", source: "src/index.ts" },
cron: { name: "Cron", source: "src/cron.ts" },
},
buckets: { assets: { access: "public_read" } },
triggers: {
"on-upload": {
type: "storage_object_created",
function: "ingest",
bucket: "assets",
prefix: "logos/",
functionPath: "/object",
},
"every-minute": {
type: "schedule",
function: "cron",
cron: "* * * * *",
functionPath: "/cron",
},
},
});neon deployChange the cron string, bucket, or prefix and deploy again to reschedule. Starter: neon bootstrap --template cron-job.
Use when you are not applying neon.ts, or to list, enable, disable, or delete.
neon triggers create --function-slug cron --name hourly --cron '0 * * * *' --function-path /cron
neon triggers create --function-slug ingest --name on-upload --bucket assets --prefix 'logos/' --function-path /object
neon triggers list
neon triggers list --output json
neon triggers update <id> --branch <branch> --cron '*/30 * * * *'
neon triggers update <id> --branch <branch> --bucket assets --prefix 'incoming/'
neon triggers enable <id> --branch <branch>
neon triggers disable <id> --branch <branch>
neon triggers delete <id> --branch <branch>enable / disable wrap update --enabled. Updating the cron recomputes Next Run At. Disabling clears Next Run At. Alias: neon trigger. --cron on a storage trigger, or --bucket / --prefix on a schedule trigger, is rejected.
Inspect a trigger with neon triggers list --output json. Pass --branch on get/update/enable/disable/delete: without it the CLI resolves the trigger id as a branch name.
Project and branch otherwise resolve from --project-id / --branch, then .neon, then a single-project auto-detect.
The Neon MCP server (?category=functions) exposes list_triggers, get_trigger, create_trigger, update_trigger, and delete_trigger. branch_id is a br-… id, not a branch name (list_branches to resolve). Create a schedule trigger with snake_case:
{
"type": "schedule",
"function_slug": "cron",
"name": "hourly",
"function_path": "/cron",
"schedule": { "cron": "0 * * * *" },
"enabled": true
}create_trigger required fields for schedule: type, function_slug, name, schedule. REST is the same payload at POST /projects/{project_id}/branches/{branch_id}/triggers. For storage_object_created, use CLI or REST with "type": "storage_object_created" and storage_object_created: { "bucket_name": "assets", "prefix": "logos/" }. CLI docs: https://neon.com/docs/cli/triggers.md (opens in a new tab).
Neon POSTs JSON. The Functions proxy drops client-supplied x-neon-* headers, so a present x-neon-trigger-invocation-id is from a trigger delivery. It must match invocation_id in the body.
A Function that also serves app or public HTTP must not apply JWT or X-Secret middleware to the trigger path. Neon trigger POSTs do not send those. Caller shapes: production-hardening.md.
Schedule wire JSON (snake_case):
{
"version": 1,
"invocation_id": "…",
"trigger": {
"type": "schedule",
"id": "trigger-…",
"name": "hourly"
},
"data": { "scheduled_at": "2026-09-15T23:35:00Z" }
}Storage-object-created wire JSON:
{
"version": 1,
"invocation_id": "…",
"trigger": {
"type": "storage_object_created",
"id": "trigger-…",
"name": "on-upload"
},
"data": { "bucket_name": "uploads", "object_key": "smoke.txt" }
}Parsed (@neon/functions ≥ 0.11.0) is camelCase. parseTriggerDelivery also sets a top-level type. Schedule: data.scheduledAt. Storage: data.bucketName, data.objectKey. Narrow on invocation.type (or isScheduleTriggerInvocation / isStorageObjectCreatedTriggerInvocation) before reading data — a check on trigger.type does not narrow the sibling data field.
import { parseTriggerDelivery } from "@neon/functions/triggers";
export default {
async fetch(request: Request): Promise<Response> {
const parsed = await parseTriggerDelivery(request);
if (!parsed.ok) {
const status = parsed.error === "invalid_body" ? 400 : 401;
return new Response(parsed.error, { status });
}
const invocation = parsed.invocation;
if (invocation.type === "storage_object_created") {
return Response.json({
bucketName: invocation.data.bucketName,
objectKey: invocation.data.objectKey,
});
}
return Response.json({
scheduledAt: invocation.data.scheduledAt,
});
},
};parseTriggerDelivery(request) clones the Request before json(), so request.json() still works. If you already have the body: parseTriggerDelivery({ headers, body }) (sync). parsed.error is missing_header, invalid_body, or invocation_id_mismatch. Unknown trigger.type values fail as invalid_body.
Hono: parseTriggerDelivery(c.req.raw).
Throws HTTPException. c.req.json() still works afterwards. Returns ScheduleTriggerInvocation. A storage_object_created delivery is invalid_body.
| Failure | Status | Message |
|---|---|---|
| missing header | 401 | Missing x-neon-trigger-invocation-id header |
header ≠ invocation_id | 401 | Invocation id mismatch |
| invalid JSON or payload | 400 | Invalid trigger payload |
import { parseTrigger } from "@neon/functions/hono";
app.post("/cron", async (c) => {
const invocation = await parseTrigger(c);
return c.json({ ok: true, invocationId: invocation.invocationId });
});import { parseTriggerInvocation } from "@neon/functions/triggers";
export default {
async fetch(request: Request): Promise<Response> {
const parsed = await parseTriggerInvocation(request);
if (!parsed.ok) {
const status = parsed.error === "invalid_body" ? 400 : 401;
return new Response(parsed.error, { status });
}
return Response.json({
ok: true,
invocationId: parsed.invocation.invocationId,
});
},
};neon dev forwards x-neon-trigger-invocation-id, so you can simulate a tick:
curl -X POST http://localhost:8787/cron \
-H 'content-type: application/json' \
-H 'x-neon-trigger-invocation-id: local-dev' \
-d '{
"version": 1,
"invocation_id": "local-dev",
"trigger": { "type": "schedule", "id": "trigger-local", "name": "hourly" },
"data": { "scheduled_at": "2026-09-15T00:00:00Z" }
}'A public POST to the deployed function that includes that header still returns 401: the proxy strips client x-neon-* headers.
Triggers are branch-scoped. A trigger created on a parent is visible on children (inherited: true, source_branch_id points at the origin) and starts disabled there.
neon deploy of a neon.ts that declares the same trigger (default enabled: true) enables that inherited copy on the child. Omit it from neon.ts to leave the inherited trigger disabled. Enable without applying neon.ts with neon triggers enable <id> --branch <branch>.
neon logs query --source function --since 1hPass --branch when the function is not on the branch in .neon.