PPactDocs
Integrations

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. 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. 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 /domains endpoint and reports whether at least one domain is verified.

  3. 3

    Store the key in Pact

    Admins and owners call POST /v1/integrations/resend/connect with the key. Pact validates the shape (rejects anything without a re_/resend_ prefix so you can't paste a SendGrid or AWS key into the slot) and stores it encrypted. Token encryption requires AUTH_TOKEN_ENCRYPTION_KEY to be configured — the connect call returns HTTP 500 if it isn't.

  4. 4

    Run the go-live test

    POST /v1/integrations/resend/test is the canonical go-live check. It first probes your key against Resend's /domains endpoint (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 to RESEND_TEST_SEND_CAP_PER_HOUR (default 5) per tenant; exceed it and you get an HTTP 429.

bash
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:

  1. Runs the consent + suppression gate (core.consent.consent_service.gate) keyed on the recipient email, channel email, and a purpose/lawful_basis pair. Transactional callers (invites, password resets) pass lawful_basis="contract" to bypass the marketing-grant requirement — but the suppression list always wins regardless.
  2. Resolves the key: tenant DB row first, then environment fallback (sandbox/demo only).
  3. Sends via the httpx-based client (POST https://api.resend.com/emails). Pact does not depend on the resend PyPI package.
  4. Writes a sequence_events row for every outcome so funnel and attribution surfaces see the result:
Outcomeevent_type written
Provider acknowledged the senddelivered
Consent withdrawnopted_out
Recipient on suppression list (bounce)bounced
Recipient on suppression list (complaint)complained
Provider returned 4xx/5xxerror

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:

code
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.