Get started
Quickstart
Connect your domain, send with the Resend SDK you already use, receive catch-all mail and follow every delivery.
Last reviewed
On this page (5)
In your project, open Domains, add a domain or a dedicated subdomain, and publish the records Letterpier shows: the ownership TXT record, DKIM, SPF, the return-path CNAME and DMARC. Letterpier checks them on its own, so the domain verifies soon after your DNS changes appear; select Check now to check straight away. A domain belongs to exactly one project.
mail.letterpier.com sends all mail for that name to Letterpier, so if people already receive mail at the domain, use a dedicated subdomain such as notify.yourproduct.example.- SPF: publish only one SPF record per name. If one exists, add
include:spf.letterpier.comto it, before itsallterm, instead of adding a second record. - DMARC: keep a policy you already have. Letterpier accepts any single existing DMARC policy, and for a subdomain it also accepts the policy of the registered domain above it. If there is none, start with
v=DMARC1; p=none;. - Reverse DNS: the PTR record is set once per sending IP, for
mail.letterpier.com. There is nothing to do per domain.
SPF, DKIM and DMARC help receiving providers authenticate your mail. Delivered means the recipient’s server accepted the message. It isn’t a promise of the inbox.
Once the domain is verified, create a live key with Sending only permission. Keep it in your product’s server environment and point the Resend SDK at Letterpier with baseUrl. Keys starting with lp_test_ are sandbox keys: they capture messages without sending them, which is the safe way to test.
lib/mail.ts
import { Resend } from 'resend';export const mail = new Resend(process.env.LETTERPIER_API_KEY, { baseUrl: 'https://app.letterpier.com',});send-welcome.ts
import { mail } from './lib/mail';type Customer = { id: string; email: string };export async function sendWelcome(customer: Customer) { const { data, error } = await mail.emails.send( { from: 'Your product <hello@notify.yourproduct.example>', to: customer.email, subject: 'Welcome', text: 'Your account is ready.', }, // The same key within 24 hours returns the first response. { idempotencyKey: `welcome/${customer.id}` }, ); if (error) throw new Error(`${error.name}: ${error.message}`); return data.id; // Queued, not delivered.}.env.local
LETTERPIER_API_KEY=lp_live_...Pass a stable idempotencyKey when you might retry a request: within 24 hours the same key returns the first response instead of creating a second message.
Turn receiving on for the domain and publish its MX record. Once the domain is verified and MX matches, mail to any address on the domain arrives in its project. Add a public HTTPS webhook subscribed to email.received, verify each request with the endpoint’s signing secret, and fetch the message with a full-access live key.
app/api/letterpier/route.ts
import type { WebhookEventPayload } from 'resend';import { mail } from '@/lib/mail';import { markProcessed, wasProcessed } from '@/lib/events';export async function POST(request: Request) { const payload = await request.text(); // The raw body, unchanged const id = request.headers.get('svix-id') ?? ''; let event: WebhookEventPayload; try { event = mail.webhooks.verify({ payload, headers: { id, timestamp: request.headers.get('svix-timestamp') ?? '', signature: request.headers.get('svix-signature') ?? '', }, webhookSecret: process.env.LETTERPIER_WEBHOOK_SECRET!, }); } catch { return new Response('Invalid signature', { status: 400 }); } // Retries repeat svix-id: skip events you've already processed. if (await wasProcessed(id)) return new Response(null, { status: 204 }); if (event.type === 'email.received') { const emailId = event.data.email_id; const email = await mail.emails.receiving.get(emailId); const files = await mail.emails.receiving.attachments.list({ emailId, }); // Anything but a 2xx makes Letterpier try again later. if (email.error || files.error) { return new Response('Try again', { status: 503 }); } // Received mail is untrusted: validate it before you act on it. console.log(email.data.subject, files.data.data.length); } // Record the event only once it's handled, then answer 2xx. await markProcessed(id); return new Response(null, { status: 204 });}.env.local
LETTERPIER_API_KEY=lp_live_...LETTERPIER_WEBHOOK_SECRET=whsec_...- Verify the raw request body, exactly as it arrived. Parsing and re-serialising it breaks the signature.
- Delivery is at least once. Deduplicate on the
svix-idheader, which stays the same across retries, and record it only once the event is handled. - Attachment download links expire after 15 minutes. Request the attachment’s metadata again for a fresh link.
- Treat received content and attachments as untrusted. A visible From address is not proof of who sent it.
The Emails page of your project shows every message with its body, attachments, status, events and last error. Webhook deliveries that ran out of attempts can be replayed from the dashboard.
- A permanent failure (Bounced or Failed) adds the recipient to the project’s suppression list, and later messages to that address aren’t sent. Find out why before you remove a suppression.
- If the hand-off to Postal, Letterpier’s mail server, was interrupted, the message is Unknown. Letterpier never resends it on its own. Use Check Postal acceptance to look it up without sending a duplicate.
In the project’s settings, keep mail for 1 to 90 days; the default is 30. Lowering it also applies to messages you already have. Raising it doesn’t bring back anything already deleted.
- Deleting a message removes its content, attachments and events, and the matching copy in Postal.
- Postal keeps its own copies for a shorter time: raw mail for 7 days, metadata for 30 days.
- Backups keep 7 daily recovery points, so deleted mail leaves them as they expire. One exception: a copy of the backup repository made on 29 September 2026 is kept on the operator’s own computer. It doesn’t expire automatically, so it keeps the data of that day until it is deleted or replaced.