Functions
Primitive Functions run your JavaScript on inbound email without requiring you to host a webhook server.
Deploying a Function creates an inbound endpoint of kind function. 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
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 this. PRIMITIVE_API_KEY: string; PRIMITIVE_WEBHOOK_SECRET: 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 pulling in Node-only modules.
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:
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}/test GET /v1/functions/{id}/logs GET /v1/functions/{id}/invocations GET /v1/functions/{id}/invocations/{invocationId} GET /v1/functions/{id}/secrets POST /v1/functions/{id}/secrets PUT /v1/functions/{id}/secrets/{key} DELETE /v1/functions/{id}/secrets/{key}
Function responses include public Function metadata such as id, name, and deploy_status. The Function endpoint is wired into the inbound delivery loop automatically.
Routing and Endpoint Scope
Creating a Function automatically registers an inbound endpoint of kind function linked to the Function id. Primitive routes matching inbound mail to your deployed handler and preserves the signed webhook delivery headers so your handler can verify Primitive-Signature.
The auto-created endpoint starts as 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 down from the Function Routing tab, Webhooks settings, or /v1/endpoints.
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.
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
PRIMITIVE_API_KEY and PRIMITIVE_WEBHOOK_SECRET are managed reserved names.
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.
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 { skipped: 'system-sender' }; }
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 short CPU windows, 128 MiB memory, bundle size, logs, and secret count. Treat Functions as request handlers, not long-running jobs.
Keep handlers bounded. For long work, 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 CLI, OpenCode, Amp, or any compatible agent: npx skills add primitivedotdev/chat That gives you a free *.primitive.email address and the `primitive chat <email> <message>` verb. 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.