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 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 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}/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. The Function endpoint is wired into the inbound delivery loop automatically.

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 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.

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' });
}

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.

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.

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, Cursor, or any compatible agent:


    npx skills add primitivedotdev/skills


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