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). 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, Sending Mail, Receiving Mail, and 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. It does still import node:fs for file-based helpers such as sendAttachment, so bundle with node:* marked external (as in the build command below) and avoid the file-based helpers inside Functions.
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
Redeploy an existing Function:
primitive functions:redeploy \ --id <function-id> \ --file ./dist/handler.js \ --source-map-file ./dist/handler.js.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/{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}
Observability and read endpoints (see Observability below):
GET /v1/functions/usage GET /v1/functions/invocation-series GET /v1/functions/routing-topology GET /v1/functions/{id}/usage GET /v1/functions/{id}/deployments 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
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 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>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>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, 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
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'); }
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
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>
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 uses a valid beta signup code plus email verification.
## 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 uses a valid beta signup code plus email verification, 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: a complete scaffold-to-deploy walkthrough.
- Webhook Payload: raw event shape.
- Sending Mail: reply, forward, gates, and idempotency.