Resend
Connect your own Resend account so Pact sends transactional and sequence email through a domain you own, with consent gating, suppression, and delivery health built in.
Resend
Resend is Pact's first-party email transport. When you connect a Resend API key, every outbound message — sequence steps, journey sends, invites, the "Send test email" button — routes through your Resend account and your verified sending domain. Pact layers consent enforcement, suppression, and delivery instrumentation on top of the raw send.
Live and enforced
This is a shipped integration. The management API lives at /v1/integrations/resend/*
(api/routes/integrations_resend.py), the send path in core/integrations/resend/sender.py
runs a consent + suppression gate before every call, and inbound Resend webhooks are
signature-verified and fanned into sequence_events and suppression_entries.
Why bring your own key
Pact is multi-tenant, but email reputation is not something we pool. Trial tenants must
configure their own Resend key — they cannot borrow Pact's shared environment key to send
outbound mail. The sender checks core.trial.is_trialing and blocks with a
trial_no_resend_key reason if a trial workspace has no key of its own. This keeps your
deliverability tied to your domain, not a shared sender.
Connecting your account
- 1
Create a Resend API key
In the Resend dashboard, create an API key. Keys begin with
re_. A sandbox key (re_test_prefix) sends nothing real — useful for exercising the pipeline in a demo before you go live. - 2
Verify your sending domain in Resend
Add and verify the domain you send from (SPF/DKIM records). Pact's health panel probes Resend's
/domainsendpoint and reports whether at least one domain is verified. - 3
Store the key in Pact
Admins and owners call
POST /v1/integrations/resend/connectwith the key. Pact validates the shape (rejects anything without are_/resend_prefix so you can't paste a SendGrid or AWS key into the slot) and stores it encrypted. Token encryption requiresAUTH_TOKEN_ENCRYPTION_KEYto be configured — the connect call returns HTTP 500 if it isn't. - 4
Run the go-live test
POST /v1/integrations/resend/testis the canonical go-live check. It first probes your key against Resend's/domainsendpoint (no send), then sends a "Resend configured ✓" confirmation email to your own admin address through the real per-tenant send path — proving key → From identity → wire end to end. It is rate-limited toRESEND_TEST_SEND_CAP_PER_HOUR(default 5) per tenant; exceed it and you get an HTTP 429.
curl -X POST https://api.pact.place/v1/integrations/resend/connect \
-H "Authorization: Bearer $PACT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"api_key": "re_xxxxxxxxxxxxxxxxxxxx"}'
# → { "status": "active", "sandbox": false, "last_error": null }
We do not verify the key at connect time
Connecting stores the key but does not call Resend to validate it — verification would
require a side-effect (a send or a rate-limited domains probe). A bad key surfaces clearly on
the next real send, or immediately if you run /test. When a send returns 401/403,
Pact marks the integration with a key_invalid error that shows as "Last error" in
the admin UI.
What happens on every send
The ResendSender wrapper is the opinionated path all routes use. For each message it:
- Runs the consent + suppression gate (
core.consent.consent_service.gate) keyed on the recipient email, channelemail, and apurpose/lawful_basispair. Transactional callers (invites, password resets) passlawful_basis="contract"to bypass the marketing-grant requirement — but the suppression list always wins regardless. - Resolves the key: tenant DB row first, then environment fallback (sandbox/demo only).
- Sends via the httpx-based client (
POST https://api.resend.com/emails). Pact does not depend on theresendPyPI package. - Writes a
sequence_eventsrow for every outcome so funnel and attribution surfaces see the result:
| Outcome | event_type written |
|---|---|
| Provider acknowledged the send | delivered |
| Consent withdrawn | opted_out |
| Recipient on suppression list (bounce) | bounced |
| Recipient on suppression list (complaint) | complained |
| Provider returned 4xx/5xx | error |
Inbound webhooks: opens, clicks, bounces, complaints
Register Pact's Resend webhook URL in your Resend dashboard. The handler verifies the
signature (Svix-style svix-signature, or Pact's own t=…,v1=… test format — both in
core/integrations/resend/client.py::verify_webhook) and then, via
core/integrations/resend/webhook.py, maps Resend event types onto Pact's vocabulary:
email.sent / email.delivered → delivered
email.opened → opened
email.clicked → clicked
email.bounced → bounced
email.complained → complained
On a bounce or complaint, Pact writes a permanent suppression_entries row (severity
hard, source resend_webhook) so the next send to that address is gated automatically. This
is idempotent — webhook replays are harmless.
Delivery health
GET /v1/integrations/resend/health returns a tenant-scoped panel computed from
sequence_events plus an optional live /domains probe: domain verification status, sends in
the last 24h, 7-day bounce and complaint rates, delivered count, and 24h p95 send latency. The
panel stays useful even without a live key — the domain probe is simply skipped when no key is
available.
Sandbox mode
An empty or re_test_ key is treated as sandbox: sends are synthesized with a fake
message id and never hit the wire, so you can exercise the full send + event-write path in a
demo. The env resolver deliberately prefers RESEND_SANDBOX_KEY over
RESEND_API_KEY — a misconfigured prod env that also has a sandbox key set
downgrades to sandbox rather than sending real mail.