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:

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}/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