How to build APIs and scheduled jobs with Val Town

By

Tagged: Services

Learn Val Town with practical TypeScript examples: build APIs, store data, automate email, schedule jobs, use AI agents, and understand its limits.

You write a bit of code, hit save, and a second later it’s live on a URL. No server to set up, no deploy step, no config files.

That’s Val Town.

It gives you a place to run small APIs, webhooks, and scheduled scripts, with storage and email already connected.

We’ll start with an endpoint, build a small guestbook API, and put together a scheduled change notification. Then we’ll look at local editing, AI tools, collaboration, and the limits you need to plan around.

If you’re new to the language, my free JavaScript course covers the functions, modules, and asynchronous code used here.

What is Val Town?

Val Town is a place to write JavaScript and TypeScript in the browser. When you save, your code is live on a URL.

The unit of code is called a val. Think of a val as a small folder of code that runs in the cloud. Like a GitHub repo, except it actually runs.

A file inside a val can have a trigger. The trigger is what makes your code run. A file can run through one of these entry points:

  • HTTP: the code runs when someone visits a URL. This is how you build APIs and websites.
  • Cron: the code runs on a schedule.
  • Email: the code runs when an email arrives at a special address.

Val Town also gives you the things you usually need, ready to go: a database (SQLite), file storage, and a way to send email. Nothing to set up.

It runs on Deno, so you write modern JavaScript and TypeScript with web-standard APIs.

The trigger belongs to a file inside the val. That means a single project can contain an HTTP API, a scheduled task, and shared helper files. Saving a helper doesn’t turn it into another public endpoint.

What makes Val Town code runAn HTTP request, a scheduled time, or incoming email invokes a triggered file. Files can use shared helpers and persistent storage.HTTP requestHTTP handlerScheduled timeCron handlerIncoming emailEmail handlerShared helper filesSQLite or blobstorage

The val overview also describes its version history, branches, and collaboration tools. You can start with one file and split it as the project grows.

What runs on the server?

Your code runs in a hosted Deno runtime. It can make HTTP requests, read environment variables, and use web APIs such as fetch, Request, and Response.

The editor runs in your browser. The code handling an incoming request runs on Val Town’s servers, even after you close that browser tab.

There is no general filesystem access. Store persistent data in SQLite or blob storage. An in-memory array or variable is temporary: a later request can run in a fresh worker.

This also affects npm compatibility. A library that only parses JSON or makes HTTP calls is a much easier fit than one that expects to write local files or launch a subprocess.

Getting started

Go to val.town and sign up. It’s free, no credit card.

You’ll land in the editor. That’s where you write and run vals, right in the browser.

Your first val

Create a new val and give it an HTTP trigger, by clicking the + button at the top right of the editor and picking HTTP.

An HTTP val is a function. It gets a Request and returns a Response:

export default async function (req: Request): Promise<Response> {
  return new Response('Hello from Val Town!')
}

Save it, copy the HTTP trigger’s URL, and open it. You should see Hello from Val Town!. Val Town handles hosting and HTTPS for that endpoint. Its advertised 100ms deployment loop describes making code available, rather than a guarantee about every request’s response time.

If you’ve used the Fetch API or Cloudflare Workers, this is the same Request and Response you already know.

Let’s return JSON instead:

export default async function (req: Request): Promise<Response> {
  return Response.json({ ok: true })
}

Response.json() sets the content type and turns the object into JSON for you.

You can also read from the request. Here we grab a query parameter:

export default async function (req: Request): Promise<Response> {
  const url = new URL(req.url)
  const name = url.searchParams.get('name') || 'stranger'
  return new Response(`Hello, ${name}!`)
}

Add ?name=flavio to your URL and it greets you by name. It’s all standard web APIs, nothing Val Town specific.

Building an API

One route in a plain function is fine. For multiple routes, a framework helps.

Val Town works well with Hono, a tiny web framework. You don’t install it, you import it straight from npm:

import { Hono } from 'npm:hono'

const app = new Hono()

app.get('/', (c) => c.text('Home'))
app.get('/about', (c) => c.text('About'))

export default app.fetch

Look at the last line. app.fetch is a function that takes a Request and returns a Response, which is exactly what an HTTP val expects. So we hand it over as the default export.

You can read route parameters too:

app.get('/users/:name', (c) => {
  const name = c.req.param('name')
  return c.json({ user: name })
})

Visit /users/flavio and you get { "user": "flavio" }.

You can return HTML by setting the content type and returning a string:

export default async function (): Promise<Response> {
  return new Response('<h1>My page</h1>', {
    headers: { 'Content-Type': 'text/html' },
  })
}

Val Town supports JSX too, if you want to build HTML with components. A plain string is enough to start.

Storing data with SQLite

Most apps need to store something. Each val has a scoped SQLite database. Files in that val use the same database through the import below. There’s also an older account-wide database, so the import path matters.

Use the scoped SQLite import, including /main.ts:

import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'

Omitting /main.ts selects the legacy account-scoped database. Keep it here so the guestbook’s tables belong to this val.

Val Town uses Turso for its SQLite service. If SQL is new to you, the free SQL course explains tables and queries in more detail.

Let’s build a tiny guestbook. First, the table:

await sqlite.execute(`create table if not exists messages (
  id integer primary key autoincrement,
  text text not null
)`)

create table if not exists means it’s safe to run every time. It creates the table once and skips it after that.

To add a row, pass your SQL with arguments. The ? is a placeholder for a value:

await sqlite.execute({
  sql: 'insert into messages (text) values (?)',
  args: ['Hello world!'],
})

Always use placeholders for values. They keep user input from messing with your query, which is how SQL injection happens.

To read the data back:

const result = await sqlite.execute('select text from messages order by id desc')
console.log(result.rows)

The rows are in result.rows. Each row is an object with your column names as keys.

Now put the API in main.ts with an HTTP trigger. This version accepts a JSON object containing a message, validates it, and limits the list to the latest 50 entries.

Before using it, add a GUESTBOOK_TOKEN environment variable in the val’s sidebar. Give it a long random value. Both reading and writing require that token, so this is a small personal API you can call from a trusted script.

import { sqlite } from 'https://esm.town/v/std/sqlite/main.ts'

await sqlite.execute(`create table if not exists messages (
  id integer primary key autoincrement,
  text text not null
)`)

export default async function (req: Request): Promise<Response> {
  const token = Deno.env.get('GUESTBOOK_TOKEN')
  if (!token) {
    return Response.json({ error: 'Missing server configuration' }, { status: 500 })
  }
  if (req.headers.get('Authorization') !== `Bearer ${token}`) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  if (req.method === 'POST') {
    const body = await req.json().catch(() => null)
    const text = typeof body?.text === 'string' ? body.text.trim() : ''
    if (!text || text.length > 500) {
      return Response.json({ error: 'Send 1 to 500 characters' }, { status: 400 })
    }
    await sqlite.execute({
      sql: 'insert into messages (text) values (?)',
      args: [text],
    })
    return Response.json({ ok: true }, { status: 201 })
  }

  if (req.method !== 'GET') {
    return new Response('Method not allowed', {
      status: 405,
      headers: { Allow: 'GET, POST' },
    })
  }

  const result = await sqlite.execute(
    'select id, text from messages order by id desc limit 50'
  )
  return Response.json(result.rows)
}

For a quick test, set GUESTBOOK_URL to the HTTP URL you copied and GUESTBOOK_TOKEN to the same token in your terminal. Then send a message:

curl -i "$GUESTBOOK_URL" \
  -H "Authorization: Bearer $GUESTBOOK_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"text":"Hello from my terminal"}'

You should get an HTTP 201 response with {"ok":true}. Read the saved messages with:

curl "$GUESTBOOK_URL" -H "Authorization: Bearer $GUESTBOOK_TOKEN"
Fig 1 · 1/4Saving a guestbook message
A caller sends a message and token

Try sending malformed JSON, a blank message, and a request without the token. They should return 400, 400, and 401 respectively, without inserting anything. A DELETE request with the token should return 405.

Keep the token on the server or in your own terminal. Putting it in public browser JavaScript would let every visitor copy it. A public guestbook needs user authentication or another deliberate abuse-control strategy, plus rate limiting. If you later render messages as HTML, escape user text.

create table if not exists helps us start this example. It won’t add a new column to an existing table. Once you change the schema, use a migration. Reverting code won’t undo database writes either.

Storing files with blob storage

SQLite is for structured data. When you want to stash a chunk of something, like JSON, an image, or a text file, use blob storage.

Import it the same way:

import { blob } from 'https://esm.town/v/std/blob/main.ts'

The simplest use is reading and writing JSON:

await blob.setJSON('settings', { theme: 'dark' })
const settings = await blob.getJSON('settings')

getJSON returns undefined if the key isn’t there, so you can check for that.

You can list keys, optionally by prefix:

const keys = await blob.list('app_')

And delete one:

await blob.delete('settings')

The blob documentation distinguishes scoped storage from legacy account-wide storage. Keep /main.ts in this import too.

Use SQLite when you need filtering, sorting, or related records. Use blobs for an object you retrieve by a known key, such as a cached API response or an image. A read-modify-write cycle on a JSON blob can lose updates when two requests run together; use database operations for shared counters or other concurrent changes.

Sending email

Val Town can send email in one call. It works well for a script that needs to tell you something happened.

import { email } from 'https://esm.town/v/std/email'

await email({
  subject: 'Hello from Val Town',
  text: 'This is a test email sent from a val.',
})

By default the email goes to you, the account owner. So sending yourself a notification is as easy as a console.log.

Note: on the free plan you can only email yourself. On Pro you can email anyone, with the to, cc, and bcc fields.

Send HTML instead of text if you want:

await email({
  subject: 'Your daily report',
  html: '<h1>Today</h1><p>All systems normal.</p>',
})

The built-in sender has restrictions on the from address. For mail from your own domain, use a provider integration such as Resend and follow its domain verification process. See the email reference for supported fields.

Running code on a schedule

A cron val runs on a timer. Good for reminders, polling a feed, or syncing data.

Give a file a CRON trigger, then write the function that runs:

import { email } from 'https://esm.town/v/std/email'

export default async function () {
  await email({
    subject: 'Good morning!',
    text: 'Time to start the day.',
  })
}

When you add the trigger, you pick the schedule. Either a simple interval like “once an hour”, or a cron expression if you need more control.

This expression runs every two hours, on the first day of the month:

0 */2 1 * *

Cron expressions run in UTC. A fixed UTC hour shifts relative to local time when daylight saving starts or ends. My cron guide explains the fields and time-zone traps.

The function also gets an interval object. Its lastRunAt property holds the previous run time, or undefined on the first run. That’s handy for “what’s changed since last time” jobs:

export default async function (interval: Interval) {
  console.log('Last run was at', interval.lastRunAt)
}

A scheduled change notification

Let’s use a cron file to watch the title of the newest Hacker News story. We’ll store its ID in blob storage and email ourselves when a later check finds a different story.

Create watch.ts in a val and attach a cron trigger. Pick every 15 minutes to fit the free plan. Paste this complete file:

import { blob } from 'https://esm.town/v/std/blob/main.ts'
import { email } from 'https://esm.town/v/std/email'

export default async function () {
  const response = await fetch(
    'https://hn.algolia.com/api/v1/search_by_date?tags=story',
    { signal: AbortSignal.timeout(10000) }
  )
  if (!response.ok) {
    throw new Error(`Story lookup failed: ${response.status}`)
  }

  const data = await response.json()
  const story = data.hits?.[0]
  if (typeof story?.objectID !== 'string' || typeof story?.title !== 'string') {
    throw new Error('Story lookup returned an unexpected response')
  }

  const previous = await blob.getJSON('last-story')
  if (previous === story.objectID) return

  if (previous !== undefined) {
    await email({
      subject: 'A new Hacker News story',
      text: `${story.title}\nhttps://news.ycombinator.com/item?id=${story.objectID}`,
    })
  }

  await blob.setJSON('last-story', story.objectID)
}

On the first run, it saves a starting point without emailing. On later runs, an unchanged ID ends the function early. A different ID sends a notification before updating the stored value.

Fig 2 · 1/4Remembering what a cron already saw
First check: save story 100

This watches the newest item at each check. Stories published between checks can be missed. For a full digest, fetch a batch and save the IDs you’ve processed in SQLite.

The order of sending and saving matters. If sending fails, the stored ID remains unchanged, so a later scheduled run can try again. If sending succeeds but saving fails, that later run can send a duplicate. This example also assumes runs don’t overlap. For important notifications, use a persistent job record and the sender’s idempotency support where available.

Val Town’s cron docs describe scheduling and pausing a trigger. A cron expression determines when code starts; your application still needs a plan for failures and duplicate work.

Receiving email

This is sending in reverse. An email val gets its own address, and your code runs whenever a message lands there.

Give a file an EMAIL trigger. The function gets the email as its argument:

export default async function (message: Email) {
  console.log('From:', message.from)
  console.log('Subject:', message.subject)
  console.log('Body:', message.text)
}

Copy the exact address shown on the email trigger. You can also choose an available custom address ending in @valtown.email. Send a message there and the function runs.

The message object has what you’d expect: from, to, subject, text, html, and attachments.

Attachments are File objects, so you can read them:

export default async function (message: Email) {
  for (const file of message.attachments) {
    console.log(file.name, file.type)
    const content = await file.text()
    console.log(content)
  }
}

Put sending and receiving together to forward support mail to a team channel, or extract a task from an incoming message. The email trigger reference lists the argument fields. Subject and body fields can be missing, so handle that in code that processes them.

Treat incoming mail and attachments as untrusted input. The from string alone doesn’t authorize a sensitive action. If an AI reads that email, its contents are data, including any instructions someone placed in the message. Avoid logging complete private messages as the examples above do for demonstration.

Importing npm packages

You saw this with Hono. Val Town supports thousands of npm packages, with no install step. You just import them:

import { Hono } from 'npm:hono'
import dayjs from 'npm:dayjs'

The npm: prefix tells Deno to fetch the package. No package.json, no node_modules.

You can also import JSR packages and other people’s vals through https://esm.town/ URLs. The import guide covers these sources and the sandbox’s compatibility limits.

Split your own code with relative imports, such as import { formatMessage } from './format.ts'. Copy another val’s module URL from its editor when you want to reuse it. Review that code before giving it access to your environment variables.

For dependencies you rely on, pin an intended release or val version and review upgrades. Val Town’s version-control documentation explains import pinning and its dependency lockfile.

Keeping secrets out of your code

You’ll often need a secret, like an API key. Don’t hardcode it in your val, because vals can be public.

Set it in your environment variables (there’s a section for this in the Val Town interface), then read it in code:

const apiKey = Deno.env.get('OPENAI_API_KEY')

Check for a missing value before calling the external service. Otherwise a forgotten setting often appears as a confusing authentication error downstream.

Environment variables are hidden from people reading public source. Your handler can still leak them by returning or logging them. Organization environment groups let you share settings between vals.

Private source and private endpoints

Code visibility and HTTP access are separate settings. A val with private source can still have a publicly callable API. Unlisted source is readable by anyone who has its link.

Source privacy and endpoint access are independentCode access determines who can read the source. App access or handler authentication determines who can call the HTTP endpoint.Val settingsCode accessApp accessPublic, unlisted, orprivate sourceAnyone with the URLRestricted access oneligible plansAdd authenticationin your handler whenneeded

The permissions guide explains code visibility. Val Town Access adds a hosted sign-in gate for eligible organization plans, with allowed people or domains. For a server-to-server API, the bearer-token check in our guestbook is another approach.

A webhook usually needs the sender’s signature verification rather than an interactive login gate. Follow that provider’s verification instructions, including checking the raw request body when required. Save a unique event ID before repeating a side effect so a retried webhook doesn’t process the same event twice.

Val Town also adds permissive CORS headers by default. You can override them for browser clients, but CORS doesn’t authenticate callers. A terminal script can still call your URL.

Working from your own editor

The browser editor is great, but sometimes you want your own. Val Town has a CLI called vt for that.

It runs on Deno, so install Deno first, then the CLI:

deno install -grAf jsr:@valtown/vt

Run vt once to log in.

Now create a val as a local folder:

vt create my-api
cd my-api

Edit the files in your editor, then push to go live:

vt push

The best part is vt watch. It syncs your changes every time you save:

vt watch

So you keep the save-and-it’s-live feeling, but in VS Code, Cursor, or wherever you work.

vt feels like git. You can clone a val, make branches, switch between them, and stream logs from your terminal:

vt clone username/valName
vt branch
vt tail

History, branches, and collaboration

Every file change creates a version you can inspect in History. Compare changes there and revert a broken edit when needed.

For larger changes, create a branch, check the behavior, and merge it back into its parent. When working on someone else’s public val, create a remix and send a pull request. Organizations provide a shared home for team projects.

Treat code history and data recovery as separate tasks. Before a risky migration, arrange a database backup. Check which data and credentials a development branch uses before running tests against external services.

The CLI also has vt pull and vt status. If you edit in both the browser and your local editor, check and synchronize changes before overwriting either copy. vt watch saves to the hosted project, so it’s useful to work on a development branch when experimenting.

Townie, plugins, and MCP

Townie is Val Town’s in-browser coding agent. It can edit and run files, query storage, inspect logs, and configure triggers. Its planning mode lets you discuss a change before it starts editing.

For an external AI assistant, follow the plugin setup guide for your app. Setup differs between Claude Code, Cursor, Codex, and web chat apps. Install the integration, connect your account, then check it in a new session by asking it to list your vals without changing anything.

Val Town now recommends its plugin or MCP integration over vt for agent workflows because they expose more platform tools. The CLI remains useful for editing local files yourself.

I would start an agent with a request like this:

Create a new val with an HTTP endpoint that returns the current time as JSON.
Use no external credentials. Show me the endpoint response and any errors.
Don't change my existing vals or add a cron trigger.

Once an agent can query databases or change schedules, it can affect more than source code. Review its permissions and test with harmless data before connecting a real workflow.

There are also two different uses of MCP here. Val Town’s MCP server lets an agent manage your Val Town account. Separately, you can host your own MCP server in an HTTP val and expose tools from your application. Authenticate those tools before giving them access to private data.

Custom domains and websites

An HTTP trigger can return HTML as well as JSON. For a small form, dashboard, or prototype, you can keep the page and its API in the same val. Hono supports multiple routes, and TSX is available when you prefer components for HTML generation.

Free accounts can choose an available val.run subdomain. Paid plans support custom domains. Add a domain you own in Val Town, choose the val that handles it, and apply the DNS records shown in the dashboard.

For a large frontend with a build pipeline, asset processing, and a framework-specific deployment adapter, check the framework’s hosting requirements first. A tiny server-rendered page is a much easier starting point here.

Logs and debugging

When something fails, first identify what invoked the file. An HTTP request, a manual run, an incoming email, and a scheduled invocation don’t necessarily supply the same input.

Use logs to record useful metadata, such as the request method or an external service’s status code. Keep tokens and private message bodies out of them. Inspect the HTTP response as well: returning a 200 response with an error message hides a failure from clients that check status codes.

For the guestbook, test a successful write, an empty list in a fresh val, invalid JSON, and missing authentication. For the scheduled example, check the first-run behavior and deliberately simulate an external lookup failure before enabling the timer.

An exception in the notification example leaves the saved ID alone. That’s intentional. Catching every error and returning normally would make a failed check look successful.

Plans and execution limits

The practical differences below were checked against Val Town’s limits page in October 2026. Check the pricing page for current billing options.

CapabilityFreePro
Maximum time per run1 minute10 minutes
Shortest cron interval15 minutes1 minute
Log retention3 days10 days
Private vals02,000
Custom domains010
Built-in email recipientsYourselfOther recipients too

Business adds team features and Val Town Access. Townie’s AI usage has its own credit allowance; having hosted code doesn’t mean unlimited model usage.

Execution time is wall-clock time, so waiting on a slow external API counts. Set timeouts on those calls and divide large jobs into bounded batches. A scheduled function that takes longer than the interval between runs also needs protection against overlap.

How I would use Val Town

I would use it for small automations that otherwise need their own deployment project. A scheduled check could read an RSS feed and send me new links. An HTTP val could receive a webhook and write a few rows to SQLite. An email handler could collect forwarded messages for later processing.

For each one, I’d start with a manual run, inspect the stored data, and only then enable the trigger. I’d keep the initial version small enough to understand in one sitting.

For a larger application already running on Cloudflare Workers, I’d usually keep its background work beside the rest of that app. Adding Val Town introduces another service, another place for credentials, and another data store to back up. Its convenience is most useful when the automation can stand on its own.

I wouldn’t choose it for video processing, a permanent background process, or code that depends on local files and system binaries. Short hosted functions, with a small database and an email notification, are the place I’d start.

Want me to talk about your product? You can sponsor this site.

Related posts about services: