Build your first Primitive Function
A flat, copy-pasteable transcript that takes you from zero to a deployed Function that turns inbound mail into tickets in your CRM and sends an acknowledgement back to the sender. Run the blocks in order; the handler source is just below the transcript and pastes over the scaffolded handler.ts in step 3.
Prereqs: a Primitive account with a managed *.primitive.email subdomain (auto-issued at signup), PRIMITIVE_API_KEY in your env (Settings → API keys), and a CRM_API_KEY in your env that authenticates against your ticketing endpoint. On Node 22+ behind a proxy, set NODE_USE_ENV_PROXY=1.
Transcript
# 1. Install the CLI and confirm it sees your account.npm install -g @primitivedotdev/cliprimitive whoami# 2. Scaffold a function project. Creates ./mail-to-crm/ with# handler.ts, package.json, build.mjs, tsconfig.json.primitive functions:init mail-to-crmcd mail-to-crm# 3. Edit handler.ts. Paste the handler source from below over the# scaffolded body. It POSTs the inbound email to your CRM and# sends an acknowledgement back to the sender.# 4. Install deps and build the single-file ESM bundle to ./dist/handler.js.npm installnpm run build# 5. Deploy. The response includes the function id; export it as FN_ID.primitive functions:deploy --name mail-to-crm --file ./dist/handler.jsexport FN_ID=<id-from-response># 6. Set CRM_API_KEY and redeploy in one call so the new secret lands# in the running handler immediately. PRIMITIVE_WEBHOOK_SECRET and# PRIMITIVE_API_KEY are managed for you by the runtime; you only# set the third-party secrets your handler needs.primitive functions:set-secret --id $FN_ID --key CRM_API_KEY --value $CRM_API_KEY --redeploy# 7. Send a test email through the real inbound MX path. --local-part# routes the test through a specific local-part if your handler# branches on it; omit for a random synthetic local-part.primitive functions:test-function --id $FN_ID --local-part support# 8. Inspect the inbound row your function received.primitive emails:latest --limit 5# 9. Inspect the outbound acknowledgement the function sent in response.primitive sending:list-sent-emails --limit 5# 10. Tail logs from the function to confirm the CRM call returned and# the reply was sent.primitive functions:logs --id $FN_ID --tail
Handler source
Paste this over the body of the scaffolded handler.ts in step 3, then continue with npm run build. Replace api.your-crm.example.com with your ticketing endpoint and support@your-domain.primitive.email with a local-part on your managed domain.
import {createPrimitiveClient,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;CRM_API_KEY: string;}export default {async fetch(request: Request, env: Env): Promise<Response> {const rawBody = await request.text();try {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 email = event.email;const bodyText =email.parsed.status === 'complete' ? email.parsed.body_text ?? '' : '';// POST the inbound to your CRM. Replace the host with your CRM's// real ticketing endpoint and adjust the body to match its schema.const crmRes = await fetch('https://api.your-crm.example.com/tickets', {method: 'POST',headers: {Authorization: `Bearer ${env.CRM_API_KEY}`,'content-type': 'application/json',},body: JSON.stringify({from: email.headers.from,subject: email.headers.subject ?? '(no subject)',body: bodyText,}),});if (!crmRes.ok) {const detail = await crmRes.text();console.error('crm error', crmRes.status, detail);return new Response('crm error', { status: 502 });}const { id: ticketId } = (await crmRes.json()) as { id: string };// Reply to the sender with a templated acknowledgement. The// idempotency key prevents duplicate replies if Primitive retries// the webhook delivery.const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });await client.send({from: 'support@your-domain.primitive.email',to: email.headers.from,subject: `Re: ${email.headers.subject ?? '(no subject)'}`,bodyText:`Thanks for reaching out. We've opened ticket ${ticketId} ` +'and someone will follow up shortly.',},{ idempotencyKey: `${event.id}:ack` },);return Response.json({ ok: true, ticketId });},};
What it does
- Verifies the webhook signature against
PRIMITIVE_WEBHOOK_SECRETbefore doing anything else. Unverified requests get a 401. - POSTs the inbound email metadata to your CRM with
CRM_API_KEY. A non-2xx response returns 502 so Primitive retries the delivery. - Sends a templated acknowledgement back to the sender via the Primitive SDK client. The
idempotencyKeyties the send to the inbound event id so a retry never produces a duplicate reply.
See Functions for the handler contract, secrets API, retry semantics, and limits. See Sending for the outbound API the SDK client wraps.