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/cli
primitive whoami
# 2. Scaffold a function project. Creates ./mail-to-crm/ with
# handler.ts, package.json, build.mjs, tsconfig.json.
primitive functions:init mail-to-crm
cd 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 install
npm 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.js
export 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_SECRET before 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 idempotencyKey ties 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.