Functions
A Primitive Function is a small piece of TypeScript that runs on Primitive's edge whenever an email matches your endpoint. You write a fetch-style handler (default-export object with an async fetch(request, env, ctx) method), upload it through the API, and Primitive bundles it into the same webhook delivery loop your self-hosted endpoints already use. No infrastructure to provision; no gateway URL to paste anywhere.
How it fits together
When an email arrives at one of your domains, the same delivery loop that powers ordinary webhooks signs the event and POSTs it to the function gateway. The gateway re-verifies the signature against your org's webhook secret, then dispatches to your script on Primitive's managed edge runtime. Your handler returns a Response; Primitive relays it back to the delivery loop, exactly the way an external HTTPS endpoint would.
email -> Primitive MX
-> webhook delivery loop (signs Primitive-Signature)
-> functions gateway (verifies signature, looks up function id)
-> dispatcher
-> your script (env.PRIMITIVE_WEBHOOK_SECRET injected)
-> Response back up the chainInstallation
Write a single handler.ts, bundle it to a single ESM file, and deploy with the CLI. The CLI reads the bundle (and optional source map) from disk, handles auth, and streams deploy status back.
Inside the handler, import all runtime values (verifyWebhookSignature, WebhookVerificationError, PRIMITIVE_SIGNATURE_HEADER, createPrimitiveClient, getSendPermissions, PrimitiveApiError) from @primitivedotdev/sdk/api, and import the EmailReceivedEvent type from the package root (types are erased at bundle time, so the root import has no runtime cost). The /api subpath ships a Web Crypto-based signature verifier alongside the API client, with no node:crypto dependency, so the whole handler bundles cleanly for the Workers-style Function runtime. The package root re-exports the same verifier but pairs it with Node-only webhook helpers that drag in node:crypto and blow past the deploy size cap. The two-host split (/send-mail goes to the attachments-capable host, everything else stays on the primary host) is handled by the client; you don't have to think about it.
# Bundle handler.ts to a single ESM file. The SDK exposes# an /api subpath that ships the API client AND a Web Crypto-# based signature verifier with no node:crypto dependency, so# it bundles cleanly inside the handler. Use it like so:# import {# createPrimitiveClient,# verifyWebhookSignature,# } from '@primitivedotdev/sdk/api';## Esbuild example:# esbuild handler.ts --bundle --format=esm --target=es2022 \# --platform=neutral --conditions=worker,browser \# --external:'node:*' --sourcemap=linked \# --outdir=dist --entry-names=index## Then deploy with the CLI (reads files from disk, no jq escaping):primitive functions:deploy \--name demo-replier \--file dist/index.js \--source-map-file dist/index.js.map# Re-deploy the same function: pass --id <fn-id> instead of --name.# Delete: primitive functions:delete --id <fn-id>## Run `primitive functions:deploy --help` for every flag.
The CLI tab is the recommended path: --file and --source-map-file skip the jq -Rs escaping the raw REST call needs. Run primitive functions:deploy --help for every flag. The curl tab is the same call against the raw REST surface for environments without Node.
After creating the function, fire a real test email through the inbound MX path to confirm it deployed and runs end-to-end. Primitive sends a synthetic-recipient email from a managed sender to one of your verified inbound domains; the function fires through the same webhook delivery loop a production email would. Response shape: { inbound_domain, from, to, subject, send_id, poll_since, watch_url }. Returns immediately after the send is queued; watch the eventual invocation on the function detail page's Invocations tab.
primitive functions:test-function --id <fn-id># Run `primitive functions:test-function --help` for every flag.
The test email lands as a real emails row with a synthetic local-part on your domain, so reply / send-mail calls from inside your handler run against a real id. If you want to skip side effects during test invocations specifically, branch on event.email.smtp.mail_from === 'functions-test@agent.primitive.dev'.
Handler shape
Export a default object with a fetch method. The runtime is a managed V8-isolate edge environment with Node-compat polyfills, so node:crypto, node:buffer, and friends resolve at runtime; bundle any other npm dependencies into the output. Standard web APIs (fetch, Request, Response, crypto.subtle) are available globally.
// Import the verifier from the /api subpath so the bundle stays// Workers-clean. The package root re-exports the same symbols but// also pulls in webhook helpers that drag in node:crypto, which// bloats Workers-style bundles past their deploy size cap.import {verifyWebhookSignature,WebhookVerificationError,PRIMITIVE_SIGNATURE_HEADER,} from '@primitivedotdev/sdk/api';import type { EmailReceivedEvent } from '@primitivedotdev/sdk';interface Env {// Auto-injected by Primitive on every deploy. You do not set these.PRIMITIVE_WEBHOOK_SECRET: string;PRIMITIVE_API_KEY: string;}export default {async fetch(request: Request, env: Env): Promise<Response> {// Read raw bytes BEFORE parsing JSON. The HMAC is over the exact// bytes Primitive signed; re-serializing produces a different string.const rawBody = await request.text();const sigHeader = request.headers.get(PRIMITIVE_SIGNATURE_HEADER);try {// The /api verifier uses Web Crypto, so it is async. Await it.await verifyWebhookSignature({rawBody,signatureHeader: sigHeader ?? '',secret: env.PRIMITIVE_WEBHOOK_SECRET,});} catch (e) {if (e instanceof WebhookVerificationError) {return new Response('invalid signature', { status: 401 });}throw e;}const event = JSON.parse(rawBody) as EmailReceivedEvent;// The discriminator field is named `event` (yes, on the outer// object also named `event`). For this endpoint it is always// "email.received"; new event types in the future would branch// here. Don't reach for event.type, that field does not exist.//// Return 2xx (NOT 4xx) for unknown event types you choose to// ignore. Primitive's webhook delivery loop treats any non-2xx// response as a delivery failure and retries up to 6 times with// backoff; returning 400 here would burn all six attempts on a// payload your handler intentionally skipped.if (event.event !== 'email.received') {return Response.json({ skipped: 'unhandled event type' });}const email = event.email;// Parsed body text lives at email.parsed.body_text when parsing// succeeded. email.content is a SIBLING that carries the raw// RFC5322 bytes and the download URL, not the parsed body.const bodyText =email.parsed.status === 'complete' ? email.parsed.body_text ?? '' : '';console.log('received', email.id, 'from', email.headers.from, 'body bytes:', bodyText.length);return Response.json({ ok: true });},};
What your function receives
A POST with content-type: application/json. The body is the same EmailReceivedEvent webhook payload an external endpoint receives, signed with your org's webhook secret. The gateway also forwards X-Webhook-Event, X-Webhook-Id, and User-Agent: primitive-webhooks/1 and strips Primitive-internal headers before your code runs.
// Inline highlights of the body your function receives. Full schema:// /docs/webhook-payload (or import EmailReceivedEvent from the SDK){id: "evt_<64-hex>", // dedupe key, stable across retriesevent: "email.received",version: "2026-04-01",delivery: {endpoint_id: "<endpoint uuid>",attempt: 1,attempted_at: "2026-05-10T18:42:00Z"},email: {id: "em_<...>",received_at: "2026-05-10T18:41:58Z",smtp: {mail_from: "alice@example.com",rcpt_to: ["you@you.primitive.email"],helo: "mx.example.com"},headers: {from: "Alice <alice@example.com>",to: "you@you.primitive.email",subject: "Re: demo",message_id: "<...>",date: "Sat, 10 May 2026 18:41:55 +0000"},parsed: {status: "complete",body_text: "Hey, can you send me the demo?",body_html: "<p>...</p>",reply_to: null,cc: null,attachments: []}// ...plus content (raw + download), analysis, auth}}
Full schema: Webhook payload. The TypeScript type is EmailReceivedEvent from @primitivedotdev/sdk. The SDK type exposes a fixed set of common headers (from, to, subject, message_id, date); for thread/loop logic prefer email.smtp.mail_from and email.parsed.in_reply_to, both of which are typed.
Heads up: the body shape your function receives (the webhook payload) is NOT the same shape GET /api/v1/emails/<id> returns. The REST response is flat (subject, smtp_mail_from, body_text, replies, etc.). The list endpoint GET /api/v1/emails nulls out subject/from/to on each row; fetch by id to inspect headers. Use the webhook-payload shape inside your handler; use the REST shape when verifying round-trips from outside.
How emails reach your function
Creating a function automatically registers an endpoints row of kind="function" linked to the function id. You will not see a separate URL to paste anywhere; the wiring is implicit in the create call.
Default scope: every inbound email on every domain on your org. The auto-created endpoint defaults to domain_id = null (catch-all, all domains the org owns) and rules = {} (the empty rules object, which matches every accepted inbound). If you deploy two functions and don't scope them, every inbound fires both handlers; that's a design choice, not a bug. Scope a function down by editing the auto-created endpoint via the existing /api/v1/endpoints API.
List endpoints with GET /api/v1/endpoints and look for the row whose function_id matches your function's id (each row exposes kind, function_id, domain_id, and rules). Then PATCH /api/v1/endpoints/<id> with either domain_id (restrict to a single verified domain) or a populated rules object. The rules grammar is documented in Receiving mail: endpoint rules; it covers size limits, attachment toggles, and sender allow/block lists, all ANDed together.
DELETE /api/v1/functions/<id> tears down the auto-wired endpoint in the same transaction, so deleting a function cleanly removes its inbound wiring with no manual endpoint cleanup needed.
Verifying the signature
Your function should treat itself like any other webhook receiver: verify Primitive-Signature against the org's webhook secret before trusting the body. Primitive auto-injects env.PRIMITIVE_WEBHOOK_SECRET on every deploy, resolved from your account settings, so the SDK helper works with no extra configuration. If you rotate your webhook secret in account settings, the next deploy of any function picks up the new value.
Primitive also auto-injects env.PRIMITIVE_API_KEY, a platform-issued org-scoped API key that lets your handler call back into the Primitive API (sending replies, tagging emails, etc.) without ever managing a key yourself. Same auto-inject pattern as the webhook secret; never returned through any API after mint.
See the handler example above for the full verification snippet, or Signature verification for the wire format if you want to roll your own.
Per-function secrets
Push third-party tokens (Stripe, Slack, OpenAI, etc.) to your function's env with the secrets API. Values are encrypted at rest and never returned by the API after creation. New or changed secrets only land in the running Worker on the next deploy. The CLI's --redeploy flag writes the secret and re-pushes the code in one call so the new value is live immediately; with raw HTTP you have to chain a secrets write and a separate PUT against the function.
# Add or replace a secret AND redeploy in one call, so the new value# lands in the running handler immediately.primitive functions:set-secret \--id <fn-id> \--key STRIPE_KEY \--value sk_live_... \--redeploy# List keys (managed entries first, then user-set; values never returned)primitive functions:list-function-secrets --id <fn-id># Delete a secret (also accepts --redeploy)primitive functions:delete-function-secret --id <fn-id> --key STRIPE_KEY --redeploy# Run `primitive functions:set-secret --help` for every flag.
Run primitive functions:set-secret --help for every flag. The curl tab works in environments without Node, but remember to redeploy with PUT /api/v1/functions/<id> after any secret write so the value reaches the running handler.
- Keys must match
^[A-Z_][A-Z0-9_]*$(uppercase identifier). - Values cap at 4096 UTF-8 bytes.
- Secrets land in the running Worker only on the next deploy. PUT the function with the same code to roll a new secret value.
PRIMITIVE_WEBHOOK_SECRETandPRIMITIVE_API_KEYare managed by the platform and reserved; the API rejects writes to them. They appear inenvon every deploy with no setup on your side.
Sending mail from inside a handler
fetch. Import from @primitivedotdev/sdk/api (the /api subpath, NOT the package root). The root export pulls in Node-only webhook helpers that depend on node:crypto, which inflates the bundle and trips the deploy size cap on the Function runtime. The /api subpath ships the API client and a Web Crypto-based verifyWebhookSignature (no node:crypto dependency), so it bundles cleanly with esbuild --platform=neutral. The client also handles the two-host split (/send-mail goes to the attachments-capable host, everything else stays on the primary host), so you don't have to think about which URL to call.Inside your handler, create a client with the auto-injected env.PRIMITIVE_API_KEY and call client.send(...) for fresh sends or client.reply(email, ...) to reply to an inbound:
import {createPrimitiveClient,PrimitiveApiError,verifyWebhookSignature,WebhookVerificationError,PRIMITIVE_SIGNATURE_HEADER,} from '@primitivedotdev/sdk/api';import type { EmailReceivedEvent } from '@primitivedotdev/sdk';interface Env {PRIMITIVE_WEBHOOK_SECRET: string;PRIMITIVE_API_KEY: string;}export default {async fetch(request: Request, env: Env): Promise<Response> {const rawBody = await request.text();try {// /api verifier uses Web Crypto, so the call is async.await verifyWebhookSignature({rawBody,signatureHeader: request.headers.get(PRIMITIVE_SIGNATURE_HEADER) ?? '',secret: env.PRIMITIVE_WEBHOOK_SECRET,});} catch (e) {if (e instanceof WebhookVerificationError) {return new Response('invalid signature', { status: 401 });}throw e;}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 });try {// Fresh send addressed at the inbound's From. For a true// reply-with-threading, use client.reply(normalizeReceivedEmail(event), ...)// (the helper lives at the package root); that posts to// /emails/<id>/reply and the server fills in Re:, In-Reply-To,// and References from the stored inbound row.const result = await client.send({from: 'agent@your-domain.primitive.email',to: event.email.headers.from,subject: `Re: ${event.email.headers.subject ?? ''}`,bodyText: 'Thanks! We received your message and will follow up shortly.',},{ idempotencyKey: `${event.id}:reply` },);console.log('sent', result.id, result.deliveryStatus);} catch (err) {if (err instanceof PrimitiveApiError) {// err.code is a stable machine-readable error code, e.g.// "recipient_not_allowed". err.gates carries per-gate detail// on a 403 recipient denial. Don't string-match err.message.console.error('send failed', err.status, err.code, err.requestId);} else {throw err;}}return Response.json({ ok: true });},};
client.send({ from, to, subject, bodyText, bodyHtml? }, { idempotencyKey? }) posts to /api/v1/send-mail on the attachments- capable host; the recipient gate (see Sending mail: who you can send to) applies just like outside the function. For a threaded reply where the server fills in Re:, In-Reply-To, and References from the stored inbound row, use client.reply(email, { text, html?, from?, wait? }) with a normalized ReceivedEmail (obtain it via normalizeReceivedEmail(event) from the package root). On a gate denial, err.code is recipient_not_allowed and err.gates carries the per-gate detail; on rate-limit, err.code is rate_limit_exceeded and err.retryAfter is the suggested back-off in seconds.
Curl is still a valid fallback in environments where the SDK cannot bundle (it's a single POST to https://www.primitive.dev/api/v1/emails/<id>/reply with the Bearer key), but the run path you should reach for first is the SDK.
Logs and usage
The function detail page at /app/functions/<id> shows recent invocations, console.log/info/warn/error from your handler with parsed event fields, and an aggregate panel with invocation count and total CPU time. Logs land within seconds of the invocation completing.
There is no log retention policy yet; treat the surface as a debugging aid, not durable storage. There is no public logs API either: tailing happens in the dashboard. If you need long-term retention or programmatic access today, fetch from your handler to your own logging destination.
Validate your function end-to-end
The fastest debug loop, no browser required, is to bounce mail through a managed *.primitive.email address and inspect both sides via CLI.
Step 1: send a test email from one managed address to another on your subdomain. With both sender and recipient on the SAME managed subdomain, the recipient gate is satisfied automatically and there is no external-recipient gating to worry about.
primitive send \--from hello@<your-managed>.primitive.email \--to test@<your-managed>.primitive.email \--subject "hello" \--body-text "trigger" \--wait
--wait holds the response until the receiver returns the SMTP code, so a 250 means the message actually entered the queue, not just that we accepted it.
Step 2: inspect the inbound row. --wait only returns the SMTP code, not an email id, so the id you want comes from emails:latest: it lists recent inbound mail one row per line, including the message you just sent.
primitive emails:latest --limit 10primitive emails:get-email --id <uuid>
Step 3: inspect the function's outbound replies. sending:list-sent-emails covers BOTH sends the customer made AND sends the function made on the customer's behalf, so a successful function run shows up here.
primitive sending:list-sent-emails --limit 10primitive sending:get-sent-email --id <uuid>
If the function fired but the outbound row never appeared, pull the inbound row's detail (emails:get-email) and check its webhook_status and replies fields: the former tells you whether the function was delivered at all, the latter lists every outbound the function recorded against this inbound.
Limits today
- Bundle size: 1 MiB UTF-8 (the deploy API rejects larger).
- Source map size: 5 MiB UTF-8 (optional second field on the deploy request; uploaded as a sibling module so runtime stack traces resolve to your source).
- Modern ESM only. Bundle to a single file with
format=esm,target=es2022, andplatform=neutral. Marknode:*as external (the Workers runtime resolves these itself whennodejs_compatis on; bundling them inlines a stub that does not work). - Workers limits apply: ~50 ms CPU on the free tier of the underlying namespace, 128 MiB memory, no persistent storage in your script (use the API to read durable state).
- Per-secret value: 4096 UTF-8 bytes.
Retries and idempotency
Functions ride the same webhook delivery loop as self-hosted endpoints, so the retry policy from Receiving applies here too: a non-2xx response from your handler (or any exception that escapes fetch) is retried up to six times over ~10 hours with exponential backoff. Practically, that means your handler can be re-invoked with the same email for hours after the original delivery, against whatever code and secrets are deployed at the time the retry fires.
If your handler does anything externally observable (sends mail, calls a third-party API, charges a card, writes to a downstream system), make those side effects idempotent. The dedupe key Primitive gives you is event.id: an evt_-prefixed 64-hex string that is stable across retries of the same delivery. Two retries of one failed delivery share an event.id; two different functions receiving the same email get different event.id values, because the id is keyed on the (email, endpoint) pair, not the email alone.
event.delivery.attempt is the 1-indexed try counter. Branch on attempt > 1 if you want retries to behave differently from the first try (extra logging, a slower path, an alternate destination).
A surprise worth calling out: if you change a secret (rotate a token, point a SUMMARY_TO env var at a new recipient, swap an upstream API base URL) while retries are queued from earlier failures, the retries pick up the new secret value when they fire, not the one that was set at the time the email originally arrived. Stale emails can re-fire against your latest code and latest secrets hours later. We have seen this surface as a recipient suddenly getting a batch of older summaries when a previously failing function was fixed and a destination address was changed at the same time.
Functions don't have persistent per-script storage yet, so a handler can't reliably dedupe on its own by stashing seen event.ids. The two patterns that work today:
- Push idempotency to your downstream. Most APIs (including Primitive's own
/api/v1/send-mail) accept anIdempotency-Keyheader. With the SDK, passidempotencyKeyin the second argument ofclient.send(input, { idempotencyKey }); the client sets the header for you. Derive that key fromevent.id(plus a stable suffix if your handler emits multiple distinct side effects per email) and let the downstream service collapse duplicates.send-mailreturns the cached response withidempotentReplay: trueon a duplicate key; see Sending mail. - Use
event.delivery.attemptas a tripwire. Log loudly onattempt > 1, alert if a function is generating retries you didn't expect, and consider short-circuiting expensive work on late retries when a fresher path has already taken over.
import { createPrimitiveClient, PrimitiveApiError } from '@primitivedotdev/sdk/api';// Inside your fetch handler, after verifying the signature:const attempt = event.delivery.attempt;if (attempt > 1) {// This is a retry of a previous delivery that didn't return 2xx.// event.id is the same as on attempt 1; use it as a dedupe key.console.warn('retry', { event_id: event.id, attempt });}// Make outbound side effects idempotent on Primitive's side by passing// an idempotencyKey derived from event.id. If the same event re-fires// (retry, manual replay, your handler bug + redeploy), send-mail returns// the cached response with idempotentReplay: true instead of sending// a second copy.const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });try {const result = await client.send({from: 'summaries@you.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');}} catch (err) {if (err instanceof PrimitiveApiError) {// err.code is the stable machine-readable error code// (e.g. "recipient_not_allowed"); err.gates is the per-gate// structured detail on a 403 recipient denial.console.error('send failed', err.status, err.code, err.requestId);} else {throw err;}}
If a delivery exhausts all six retries it lands in the dashboard logs as failing and waits for a manual replay. Replays re-send the same payload with the same event.id, so the idempotency-key pattern above covers replays as well as automatic retries.
Gotchas
- 30-second delivery timeout. The webhook delivery loop waits up to 30 seconds for your handler to return a response before treating the attempt as a failure and scheduling a retry. Most handlers (signature verification, parse, one synchronous downstream call, return 2xx) finish well under that. The pattern that hits the cap is calling a slow upstream (a cold OpenAI completion, a large external fetch) synchronously inside the handler. If you do that, make the downstream side-effect idempotent (pass an
idempotencyKeyderived fromevent.idas in the example above) so that a retry triggered by the timeout doesn't produce a duplicate send; or move the slow work to a follow-up call after returning 2xx (the handler exits, the downstream work runs on your own infra). The 30s cap is a platform default today; per- function configurable timeouts are on the roadmap but not shipped. - Cold starts. Functions run on Primitive's managed edge runtime. A cold dispatcher plus a cold user script can stack to a few hundred milliseconds on first invocation; warm requests are typically under 100 ms.
- Read raw bytes before parsing JSON. The signature is computed over the exact bytes the delivery loop sent. Calling
request.json()first and then re-serializing for verification will change the bytes (key order, unicode escapes, whitespace) and the signature will fail. - Updates have a deploy window. While a new version is uploading, the gateway returns 404 for that function id. Sub-second redeploys are invisible because the delivery loop retries with backoff; longer deploys can burn one or two retry attempts on in-flight emails.
- No local simulator. The dispatcher and runtime are managed by Primitive and aren't reproducible on your laptop; emulating them locally drifts from production. Use the
POST /functions/<id>/testendpoint or send a real email through your inbox to iterate. - Reply loops. If your handler sends mail in response to a keyword, and the reply contains the same keyword, and it lands back on a domain on the same org, you will trigger yourself forever. Wire the guard in BEFORE your first deploy; the loop self-perpetuates the moment one inbound slips past it. Effective checks, in order of reliability:
- Skip when
event.email.smtp.mail_from(the SMTP envelope sender, harder to forge than the From header) matches a verified outbound address on your org. Get the canonical list by callinggetSendPermissions({ client: client.client })from@primitivedotdev/sdk/api(orGET /api/v1/send-permissionsdirectly), cache the response, and short-circuit on match. One caveat: if you verify by sending real mail fromPOST /api/v1/send-mailon the same org, this guard will silently swallow your own test traffic. For development, send from a different verified address than the one your function replies from, OR add a temporaryx-loop-bypass: 1sentinel into the test email's subject and check for it before applying the guard. - Skip when
event.email.parsed.in_reply_tochains back to a Message-ID your function emitted. Track recent outbound IDs in a per-function secret or external store; this catches threaded replies that the envelope check misses. - Layer in checks on the inbound's subject /
email.parsed.body_textto short-circuit the same keyword you trigger on. Belt-and-suspenders: an auto-reply that does not itself contain the trigger keyword cannot start a loop in the first place. - Set
Auto-Submitted: auto-replied(RFC 3834) on your own outbound replies via theheadersfield on/api/v1/emails/<id>/reply. Polite mailers (Gmail, Outlook, modern mailing lists) honor this and will not auto-reply back to you.
- Skip when
- X-Primitive-Confirmed: true (advanced, optional). If your handler durably stores the email content it cares about, returning this response header tells Primitive to drop the original raw content from its storage. Don't set it unless you have actually saved what you need.