---
title: "Functions"
description: "Hosted JavaScript handlers for inbound email, including handler shape, deploys, tests, logs, secrets, and limits."
canonical: "https://docs.primitive.dev/docs/functions"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Functions

Primitive Functions run your JavaScript on inbound email without requiring you to host a webhook server.

Deploying a Function installs the handler; binding a route to it creates an inbound endpoint of kind `function` (see [Routing and Endpoint Scope](#routing-and-endpoint-scope)). Once routed, when Primitive receives matching mail, it invokes your handler with the same email event payload used by webhooks.

Function runtime snippets are TypeScript/JavaScript because hosted Functions execute JavaScript. Cross-language SDK examples for applications are shown on [SDKs](/docs/sdks), [Sending Mail](/docs/sending), [Receiving Mail](/docs/receiving), and [Signature Verification](/docs/signature-verification).

## When to Use Functions

Use Functions when you want to:

- process inbound mail without operating infrastructure;

- call external HTTP APIs from an email handler;

- reply or forward from inside the handler;

- keep secrets and logs attached to the email workflow;

- give an agent a narrow, deployable mailbox automation surface.

Use a self-hosted webhook when you already have application infrastructure that should receive events directly.

## Recommended Start


```
primitive functions:init my-fn
cd my-fn
npm install
npm run build
primitive functions:deploy --name my-fn --file ./dist/handler.js
primitive functions:route-set --id <function-id> --fallback
```


The scaffold is the recommended starting point. It pins the SDK, includes a build script, and imports the runtime client surface correctly.

`primitive functions:init` is local filesystem scaffolding, so there is no REST endpoint for that step. The REST/curl equivalent starts at Function deploy, shown below.

## Handler Shape

Primitive Functions run as request handlers. Primitive signs each delivery and forwards the original `Primitive-Signature` header to your Function. Verify the raw request body before parsing JSON, then return a 2xx response when the event has been accepted.


```
import {
  createPrimitiveClient,
  normalizeReceivedEmail,
  PRIMITIVE_SIGNATURE_HEADER,
  type EmailReceivedEvent,
  verifyWebhookSignature,
  WebhookVerificationError,
} from '@primitivedotdev/sdk/api';


interface Env {
  // Auto-injected by Primitive on every deploy. You do not set these.
  PRIMITIVE_API_KEY: string;
  PRIMITIVE_WEBHOOK_SECRET: string;
  PRIMITIVE_API_BASE_URL: string;
}


export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const rawBody = await request.text();
    const signatureHeader = request.headers.get(PRIMITIVE_SIGNATURE_HEADER) ?? '';


    try {
      await verifyWebhookSignature({
        rawBody,
        signatureHeader,
        secret: env.PRIMITIVE_WEBHOOK_SECRET,
      });
    } catch (error) {
      if (error instanceof WebhookVerificationError) {
        return new Response('invalid signature', { status: 401 });
      }
      throw error;
    }


    const event = JSON.parse(rawBody) as EmailReceivedEvent;


    if (event.event !== 'email.received') {
      return Response.json({ skipped: 'unhandled event type' });
    }


    const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
    await client.reply(normalizeReceivedEmail(event), {
      text: 'Got your message.',
    });


    return Response.json({ ok: true });
  },
};
```


Use the `/api` SDK subpath inside Functions. It exposes the REST client and event normalization helpers without the `node:crypto` dependency that the root entry pulls in. File-based helpers such as `sendAttachment` load `node:fs` lazily at call time rather than importing it, so the subpath bundles cleanly for Workers-style runtimes; still avoid the file-based helpers inside Functions, where no filesystem exists.

## Build and Bundle

Bundle your TypeScript to one ESM file before deploying. A neutral esbuild target works well:


```
esbuild handler.ts \
  --bundle \
  --format=esm \
  --target=es2022 \
  --platform=neutral \
  --conditions=worker,browser \
  --external:'node:*' \
  --sourcemap=linked \
  --outfile=dist/handler.js
```


Use the scaffolded project for exact imports and build settings. The generated handler is the source of truth for the current runtime package shape.

## Deploy and Redeploy

Create a Function:


```
primitive functions:deploy \
  --name triage-agent \
  --file ./dist/handler.js \
  --source-map-file ./dist/handler.js.map
```

```
CODE=$(cat dist/handler.js | jq -Rs .)
SOURCE_MAP=$(cat dist/handler.js.map | jq -Rs .)


curl -X POST https://api.primitive.dev/v1/functions \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "content-type: application/json" \
  -d "{\"name\":\"triage-agent\",\"code\":$CODE,\"sourceMap\":$SOURCE_MAP}"
```


Redeploy an existing Function:


```
primitive functions:redeploy \
  --id <function-id> \
  --file ./dist/handler.js \
  --source-map-file ./dist/handler.js.map
```

```
CODE=$(cat dist/handler.js | jq -Rs .)
SOURCE_MAP=$(cat dist/handler.js.map | jq -Rs .)


curl -X PUT https://api.primitive.dev/v1/functions/<function-id> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"code\":$CODE,\"sourceMap\":$SOURCE_MAP}"
```


Function names are immutable after creation. Use the returned `id` for redeploy, test, logs, secrets, and delete operations.

On redeploy, include `sourceMap` when you want source-mapped logs for the new bundle. Omit it when you deploy without a map.

REST endpoints:


```
GET    /v1/functions
POST   /v1/functions
GET    /v1/functions/lookup?name={name}
GET    /v1/functions/{id}
PUT    /v1/functions/{id}
DELETE /v1/functions/{id}
POST   /v1/functions/{id}/redeploy
PATCH  /v1/functions/{id}/tags
POST   /v1/functions/{id}/test
GET    /v1/functions/{id}/logs
GET    /v1/functions/{id}/secrets
POST   /v1/functions/{id}/secrets
PUT    /v1/functions/{id}/secrets/{key}
DELETE /v1/functions/{id}/secrets/{key}
POST   /v1/functions/{id}/template-update
```


To ship new code over REST, use `PUT /v1/functions/{id}`, which updates the Function and deploys it; the CLI's `functions:redeploy` wraps it. `POST /v1/functions/{id}/redeploy` deploys the Function's stored code again without changing it (for example after changing its secrets), and refuses a body that carries code.

Routing endpoints (see [Routing and Endpoint Scope](#routing-and-endpoint-scope) below and [Routing](/docs/routing)):


```
GET    /v1/functions/routing-topology
GET    /v1/functions/fallback
GET    /v1/functions/{id}/routing
PUT    /v1/functions/{id}/route
DELETE /v1/functions/{id}/route
```


Observability and read endpoints (see [Observability](#observability) below):


```
GET    /v1/functions/usage
GET    /v1/functions/invocation-series
GET    /v1/functions/{id}/usage
GET    /v1/functions/{id}/users
GET    /v1/functions/{id}/deployments
GET    /v1/functions/{id}/deployments/{deploymentId}
GET    /v1/functions/{id}/source
GET    /v1/functions/{id}/activity
GET    /v1/functions/{id}/invocations
GET    /v1/functions/{id}/invocations/{invocationId}
GET    /v1/functions/{id}/test-runs/{run_id}/trace
```


`GET /v1/functions/{id}/invocations` lists runs of the Function. A webhook delivery that never reached it (refused before it ran) is not a run; list those with `GET /v1/webhooks/deliveries`.

Runtime key endpoints (which key a Function runs with, and suspending or reaffirming it):


```
GET    /v1/functions/runtime-grant-presets
POST   /v1/functions/runtime-grant-presets/runner-request
GET    /v1/functions/{id}/runtime-key
PUT    /v1/functions/{id}/runtime-key
POST   /v1/functions/{id}/runtime-key/suspend
POST   /v1/functions/{id}/runtime-key/reaffirm
```


Every endpoint here is in the [OpenAPI document](/docs/api) with its request and response schemas and the permission it needs, and takes an organization API key or an OAuth token.

Function responses include public Function metadata such as `id`, `name`, and `deploy_status`. A `deploy_status` of `deployed` means the script is installed, not that the Function is receiving mail; the Function endpoint joins the inbound delivery loop once you bind a route to it.

Deploying with a pre-built bundle (`code`) needs no extra entitlement. The managed-build path, where you upload a source directory and Primitive builds the bundle for you, requires the `functions_managed_build` entitlement on the org.

### Deploy from CI

For GitHub Actions workflows, use the first-party **primitive.dev - Deploy Function** Action instead of hand-rolling curl. It is idempotent (creates the Function on first run, updates the bundle on later runs) and takes inputs for `code-path`, `files-path` (managed build), `source-map-path`, `secrets` (a JSON map), and `redeploy-on-secret-change`.

Set the `expected-org-id` input in production workflows. When present, the Action checks the API key's org before any write and aborts on a mismatch, so a leaked or misconfigured key cannot deploy into the wrong organization. See [CI/CD for Functions](/docs/functions/github-actions) for an end-to-end workflow, including the CLI-based alternative.

## Routing and Endpoint Scope

Creating a Function does not register an inbound endpoint on its own; a Function with no route receives no inbound mail. Bind a route explicitly — `primitive functions:route-set --id <function-id> --fallback` (or `--domain <domain-id>`), backed by `PUT /v1/functions/{id}/route` — to create the inbound endpoint of kind `function` linked to the Function id. Once routed, Primitive routes matching inbound mail to your deployed handler and preserves the signed webhook delivery headers so your handler can verify `Primitive-Signature`.

A route bound with `--fallback` creates a fallback endpoint with `domain_id = null` and empty rules. It receives accepted inbound mail only for domains that do not have an enabled endpoint scoped to that exact domain. If a domain has a scoped endpoint, that endpoint handles the domain and fallback endpoints are suppressed. Scope a Function to a specific domain from the Function Routing tab, Webhooks settings, or the route API with a domain target.

## Test Invocation

Fire a real test email through the inbound path:


```
primitive functions:test-function --id <function-id>
```

```
curl -X POST https://api.primitive.dev/v1/functions/<function-id>/test \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


The test response includes identifiers you can use to watch the invocation and inspect logs.

## Logs

Function logs are attached to invocations. Use them for deploy verification and debugging. The logs API supports `limit` and `cursor`; pass the returned `next_cursor` as `cursor` to continue paging.


```
primitive functions:list-function-logs --id <function-id>
```

```
curl https://api.primitive.dev/v1/functions/<function-id>/logs \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


Logs are operational data. Do not print API keys, webhook secrets, or customer secrets.

## Observability

Beyond logs, a set of read endpoints expose deploy history, invocations, usage, and activity so you can monitor a Function without leaving the API.

**Deployments.** `GET /v1/functions/{id}/deployments` returns the deploy history newest-first — each entry carries its `status`, any `deploy_error`, build timing, bundle size, and an `is_current` flag marking the live deployment. `GET /v1/functions/{id}/source` returns the source files captured for a managed-build deployment (the current deployment by default, or a specific one via `deployment_id`).

**Invocations.** `GET /v1/functions/{id}/invocations` lists individual handler runs newest-first, each with its `outcome`, CPU and wall time, request method/URL, response status, and any exception name and message. It is cursor-paginated (`limit`, `cursor`) and accepts `outcome` and `since` filters. `GET /v1/functions/{id}/invocations/{invocationId}` returns one invocation in full, including the exception stack and that run's captured logs.

**Usage and series.** `GET /v1/functions/{id}/usage` returns usage for one Function; `GET /v1/functions/usage` aggregates usage across every Function on the org. `GET /v1/functions/invocation-series` returns an invocation time series (defaults to the last 30 `days`, optionally scoped to one `function_id`) for charting volume over time.

**Activity.** `GET /v1/functions/{id}/activity` lists the outbound effects a Function produced — `email.sent` and `email.replied` events — newest-first and cursor-paginated, with optional `type`, `email_id`, and `delivery_id` filters. Use it to confirm a handler actually sent or replied as expected.

**Routing topology.** `GET /v1/functions/routing-topology` returns the org's Function routing map — which Functions are wired to which inbound domains — for a whole-org view of where mail is dispatched.

**Test-run trace.** After a [test invocation](#test-invocation), `GET /v1/functions/{id}/test-runs/{run_id}/trace` returns the end-to-end trace for that run: the synthetic send, the inbound email, every webhook delivery, the Function's logs and outbound requests, and any replies — the full picture of one message moving through the pipeline.

## Secrets

Function secrets are scoped per Function. Values are never returned after creation.


```
primitive functions:set-secret \
  --id <function-id> \
  --key STRIPE_KEY \
  --value sk_live_... \
  --redeploy


primitive functions:list-function-secrets --id <function-id>
primitive functions:delete-function-secret --id <function-id> --key STRIPE_KEY
```

```
curl -X POST https://api.primitive.dev/v1/functions/<function-id>/secrets \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"STRIPE_KEY","value":"sk_live_..."}'


curl https://api.primitive.dev/v1/functions/<function-id>/secrets \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


curl -X DELETE https://api.primitive.dev/v1/functions/<function-id>/secrets/STRIPE_KEY \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


Secret keys must match `^[A-Z_][A-Z0-9_]*$` — uppercase letters, digits, and underscores, starting with a letter or underscore (for example `STRIPE_KEY`, `OPENAI_API_KEY`). Each value is at most **4096 bytes**. A key or value outside these bounds is rejected with a `validation_error`.

`PRIMITIVE_API_KEY`, `PRIMITIVE_WEBHOOK_SECRET`, and `PRIMITIVE_API_BASE_URL` are managed reserved names that Primitive injects automatically. They cannot be set as Function secrets.

Secret values are injected into the running handler environment. Add or replace a secret and redeploy so the new value reaches the active Function.

Deleting a secret does not accept `--redeploy`; redeploy separately with `primitive functions:redeploy --id <function-id> --file <bundle>` if the running handler must drop that binding immediately.

### Org-Level Secrets

Org-level secrets are scoped to the whole organization rather than a single Function. Every Function in the org receives them, merged into its environment at deploy time, so you can store a shared credential once instead of repeating it on each Function. A Function's own secret of the same key takes precedence over the org-level value.

They use the same key and value constraints as Function secrets (`^[A-Z_][A-Z0-9_]*$`, value at most 4096 bytes), and the same managed reserved names are blocked. Values are never returned after creation.


```
GET    /v1/org/secrets
POST   /v1/org/secrets
PUT    /v1/org/secrets/{key}
DELETE /v1/org/secrets/{key}
```


```
curl -X POST https://api.primitive.dev/v1/org/secrets \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"SHARED_API_KEY","value":"..."}'
```


Redeploy a Function after changing an org-level secret so the new value reaches its running environment.

## Idempotency and Retries

Primitive retries inbound delivery when your handler does not return 2xx. The retry has the same event id. Use that id for your own dedupe keys and for outbound idempotency keys.


```
const attempt = event.delivery.attempt;
if (attempt > 1) {
  console.warn('retry', { event_id: event.id, attempt });
}


const result = await client.send(
  {
    from: 'summaries@your-org.primitive.email',
    to: env.SUMMARY_TO,
    subject: `Summary: ${event.email.headers.subject ?? '(no subject)'}`,
    bodyText: summarize(event.email.parsed.body_text ?? ''),
  },
  { idempotencyKey: `${event.id}:summary` },
);


if (result.idempotentReplay) {
  console.log('replayed', result.id, 'no second copy sent');
}
```


A send from a Function without an idempotency key is still deduplicated per trigger: Primitive scopes the derived key to the Function and the inbound email that invoked it. A retry of the same delivery replays the earlier send, and a different inbound email sends again even when the message content is identical. Pass an explicit key when one invocation needs to send the same message more than once.

A reply is also covered by the [reply guard](/docs/sending#sends-without-a-key): a keyless reply from the same Function answering the same triggering email, from the same address to the same recipients, collapses onto the earlier reply even when a retried run rewrote its body, and the response says so with `deduplicated: true`.

This differs from a send made outside a Function, where a missing key is derived from the content and a fixed 5-minute window (see [Sends without a key](/docs/sending#sends-without-a-key)). A replayed Function send reports which rule applied: its response carries the `Idempotent-Replayed: true` header and an `idempotency` object with `key_source: "function_trigger"` (or `"explicit"` when you passed a key) and `window_seconds: null`.

## Loop Protection

Email handlers can accidentally send mail that triggers themselves again. Add explicit guardrails:

- ignore messages from your own system senders;

- add marker headers or local-part tags to generated mail;

- check sender and recipient domains before replying;

- use idempotency keys for outbound sends.

Example:


```
const from = event.email.headers.from?.toLowerCase() ?? '';
if (from.includes('.primitive.email') || from.includes('support@yourcompany.com')) {
  return Response.json({ skipped: 'system-sender' });
}
```


Loop protection is not sender authentication. The substring match above is fine here because a false positive only drops one reply. Do not reuse it to decide whether a sender is allowed to trigger an action; see the next section.

## Trusting the Sender

If your handler gates a privileged action on who sent the email (an allowlisted operator address, mail from your own domain), do not match on the raw `From` header. A header like `From: "ops@yourcompany.com" <attacker@evil.example>` places an allowlisted address in the display name while every authentication check runs against `evil.example`. Reply-To and the SMTP envelope sender are also fully sender-controlled.

Use `isTrustedSender` from the SDK's `/api` subpath instead. It requires a passing DMARC verdict, checks that the domain DMARC evaluated equals your expected domain, and strict-parses the `From` header:


```
import { isTrustedSender } from '@primitivedotdev/sdk/api';


const trust = isTrustedSender(event, {
  domain: 'yourcompany.com',
  sender: 'ops@yourcompany.com', // optional exact-address requirement
});


if (trust.trusted) {
  // run the privileged action
} else if (trust.retryable) {
  // transient DNS failure during DMARC evaluation; return a 5xx so
  // inbound retry redelivers this email later
  return new Response('retry', { status: 503 });
} else {
  console.warn('untrusted sender', trust.reason, trust.auth.reasons);
}
```


`trust.reason` is a stable machine-readable code (for example `dmarc-domain-mismatch`, `sender-mismatch`) naming the first check that failed. `trust.auth` is the underlying `validateEmailAuth` result (`verdict`, `confidence`, and human-readable `reasons`), included so logs can show why authentication itself passed or failed. Domains match exactly and case-insensitively; mail from a subdomain does not match unless you pass the subdomain itself.

## Validate a Deployment

Send a message that should invoke the Function:


```
primitive send \
  --from hello@<your-managed>.primitive.email \
  --to test@<your-managed>.primitive.email \
  --subject "hello" \
  --body "trigger" \
  --wait
```

```
curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"hello@<your-managed>.primitive.email","to":"test@<your-managed>.primitive.email","subject":"hello","body_text":"trigger","wait":true}'
```


Then inspect inbound mail and outbound side effects:


```
primitive emails:latest --limit 10
primitive emails:get-email --id <uuid>
primitive sending:list-sent-emails --limit 10
primitive sending:get-sent-email --id <uuid>
```

```
curl "https://api.primitive.dev/v1/emails?limit=10" \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


curl https://api.primitive.dev/v1/emails/<uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


curl "https://api.primitive.dev/v1/sent-emails?limit=10" \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"


curl https://api.primitive.dev/v1/sent-emails/<uuid> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


## Limits

Functions inherit the same email product limits as the organization. They also have runtime limits for wall-clock time, CPU time, 128 MiB memory, bundle size, logs, and secret count.

Wall-clock and CPU are separate limits:

- Wall-clock: up to 10 minutes per invocation. Time spent awaiting I/O, such as LLM calls or slow third-party APIs, counts against wall-clock only. A handler that awaits a two-minute inference call and then replies is fine.

- CPU: short windows, unchanged. Actively executing JavaScript is capped separately at the platform CPU limit. The 10-minute window does not buy more compute; it buys waiting.

An invocation that exceeds the 10-minute window is canceled and the delivery is marked failed with the `function_timeout` error code, then retried on the normal schedule. Retries redeliver with the same event id, so idempotent handlers dedupe cleanly.

Deploy bundle size limits — both the raw and the gzip ceiling are enforced (the raw limit is checked first), and for normal bundles the gzip ceiling is the one you hit:

- code bundle: 10 MB after gzip compression (64 MB raw before compression);

- source map: 15 MB after gzip compression (64 MB raw before compression).

A deploy that exceeds either ceiling is rejected with a `validation_error` naming the field (`code` or `sourceMap`) and whether the raw or gzip limit was hit.

Work that fits inside the 10-minute window can run directly in the handler, including inference-length awaits. For work that can exceed it, acknowledge the email, persist the job, and process it in your own system. For external API calls, set timeouts and handle failures explicitly so retries do not create duplicate side effects.

## Make Your Agent Discoverable

Once your Function is live, tell coding agents how to reach it. Add a snippet to your `llms.txt`, README, or contact page so any agent landing on your site can install the primitive-chat skill and email yours in one round-trip. No SMTP credentials; signup needs an email you can receive a verification code at, plus agreement to the Terms and Privacy Policy. No beta code.


```
## Talk to us


Email questions to ask@yourdomain.com. The address routes to an agent
that knows our docs end-to-end.


No email account on hand? Install the primitive-chat skill into Claude Code,
Codex, Cursor, or any compatible agent:


    npx skills add primitivedotdev/skills


That installs the Primitive skills bundle: primitive-chat provides the
`primitive chat <email> <message>` verb, and primitive-inbox provides a
free *.primitive.email address. No SMTP creds. Signup takes an email, the
verification code sent to it, and agreement to the terms. No beta code,
and no leaving your terminal.
```


The `/app/functions` page exposes the same snippet as a one-click "Get llms.txt snippet" copy button.

## Related Pages

- [First Function](/docs/functions/first-function): a complete scaffold-to-deploy walkthrough.

- [Webhook Payload](/docs/webhook-payload): raw event shape.

- [Sending Mail](/docs/sending): reply, forward, gates, and idempotency.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
