Actions
Server-only functions for external I/O, transactional work, and side effects.
Actions are Valet's server-only functions. Use them when the work is not a local-first mutation:
- Calling an external HTTP API with
fetch - Sending email
- Running privileged server logic that should never replay offline
- Doing multi-step business logic that still needs
ctx.db
If the work should replay locally and sync later, use a mutation. If the work must only happen on the server, use an action.
Define an action
// valet/users.ts
import { defineAction, v } from './_generated/valet/api'
export const sendWelcomeEmail = defineAction({
args: { userId: v.id('users') },
access: 'authenticated',
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId)
if (!user) throw new Error('User not found')
await ctx.email.enqueue({
to: user.email,
subject: 'Welcome to Valet',
html: `<h1>Hello ${user.name ?? 'there'}</h1>`,
})
return { accepted: true }
},
})defineAction looks like defineMutation, but it has a different execution model:
- It is always invoked over HTTP
- It never runs optimistically on the client
- It can use
fetch - It can enqueue email and push work
- It can run as a user, anonymously, or as a system caller
Call an action from React
import { api } from './_generated/valet/api'
import { useAction } from './_generated/valet/react'
function InviteButton({ userId }: { userId: string }) {
const sendWelcomeEmail = useAction(api.actions.sendWelcomeEmail)
return (
<button
onClick={() => sendWelcomeEmail({ userId })}
>
Send welcome email
</button>
)
}You can also supply your own idempotency key:
await sendWelcomeEmail(
{ userId },
{ idempotencyKey: `welcome-${userId}` },
)That is useful when you want retries after a reload to dedupe against the same logical user action.
If you already have the generated provider loaded, action refs also expose a direct helper:
import { api } from './_generated/valet/api'
await api.actions.sendWelcomeEmail.invoke(
{ userId },
{ idempotencyKey: `welcome-${userId}` },
)Access control
Actions declare who may call them:
export const searchDocs = defineAction({
args: { q: v.string() },
access: 'public',
handler: async (_ctx, args) => ({ q: args.q }),
})Available values:
'authenticated'(default): requires a bearer token'public': allows anonymous callers'system': only internal callers such as cron or admin triggers
Use fetch
Actions can call external HTTP services:
export const enrichProfile = defineAction({
args: { github: v.string(), userId: v.id('users') },
access: 'authenticated',
handler: async (ctx, args) => {
const response = await fetch(`https://api.github.com/users/${args.github}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'valet-app',
},
})
if (!response.ok) {
throw new Error(`GitHub returned ${response.status}`)
}
const profile = await response.json()
await ctx.db.patch(args.userId, {
bio: profile.bio ?? null,
avatarUrl: profile.avatar_url ?? null,
})
return { updated: true }
},
})Important fetch rules:
fetchis available in actions and cron-dispatched actions onlyfetchis blocked in queries and mutationsfetchto private or internal addresses is rejected before the request is made- In development mode (
VALET_DEV=1), localhost fetches are allowed so local integrations are practical
Blocked destinations include localhost in production, RFC 1918 private ranges, link-local ranges, cloud metadata addresses, and *.internal.
Transactions
Actions use auto-commit database calls by default. If several writes must succeed or fail together, wrap them in ctx.db.transaction(...):
export const chargeOrder = defineAction({
args: { orderId: v.id('orders') },
access: 'authenticated',
handler: async (ctx, args) => {
const gatewayResponse = await fetch('https://payments.example.com/charge', {
method: 'POST',
body: JSON.stringify({ orderId: args.orderId }),
})
if (!gatewayResponse.ok) {
throw new Error('Payment failed')
}
await ctx.db.transaction(async (tx) => {
const order = await tx.get(args.orderId)
if (!order) throw new Error('Order not found')
await tx.patch(order._id, { status: 'paid' })
await ctx.email.enqueue({
to: order.email,
subject: 'Payment received',
text: 'Your order is confirmed.',
})
})
return { ok: true }
},
})Transaction rules:
fetchinsidectx.db.transaction(...)throws immediatelyctx.email.enqueue(...)is allowed inside a transaction- Nested transactions are not supported
- If the transaction rolls back, any email enqueue inside it rolls back too
The right pattern is: fetch first, then open the transaction for the DB work and outbox writes.
Retry-safe actions
Use safeToRetry: true only when the whole handler is safe to rerun after an ambiguous failure or timeout:
export const ensureReceipt = defineAction({
args: { orderId: v.id('orders') },
safeToRetry: true,
access: 'authenticated',
handler: async (ctx, args) => {
await ctx.db.insertOrIgnore('receipts', {
_id: `receipt-${args.orderId}`,
orderId: args.orderId,
sent: 1,
})
await ctx.email.enqueue({
to: 'user@example.com',
subject: 'Receipt',
html: '<p>Thanks</p>',
deliveryKey: `receipt-${args.orderId}`,
})
return { ok: true }
},
})Guidelines:
safeToRetrycovers bothfailedandtimeout, not just timeouts- Use
ctx.db.insertOrIgnore(...)for idempotent insert-once rows - Give every outbox enqueue a stable
deliveryKey - If you cannot make every side effect repeat-safe, leave
safeToRetryoff and handle retries explicitly
ctx.invoker
Actions always know who invoked them:
if (ctx.invoker.type === 'user') {
console.log(ctx.invoker.userId)
}
if (ctx.invoker.type === 'anonymous') {
console.log('Guest action call')
}
if (ctx.invoker.type === 'system') {
console.log(ctx.invoker.source)
}System invokers are used by cron jobs and admin triggers. Cron-dispatched actions also receive timing metadata such as scheduledAt, dispatchedAt, and catch-up fields when applicable.
Sensitive actions
If an action returns sensitive data that should not be stored for idempotent replays, mark it as sensitive:
export const rotateSecret = defineAction({
args: {},
sensitive: true,
handler: async () => {
return { secretIssued: true }
},
})Sensitive action results are returned to the original caller, but Valet does not retain the args/result body for later duplicate responses.
Debugging action failures
Useful places to look when an action is not behaving the way you expect:
- The HTTP response from
useAction(...)orclient.invokeAction(...) _valet_action_invocationsin the project database/admin/outboxfor queued or dead-lettered email work/admin/cron/runsfor cron-dispatched actions