# Connect Resend

> Source: https://cross-deck.com/docs/connect-resend/ · Crossdeck developer docs (generated from the published page).

Setup
Resend OAuth 2.1 + PKCE · read-only events
·
~6 min read
·
Updated July 27, 2026

Connect your Resend and every email event — **sent, delivered, opened, clicked** (plus bounced and complained) — lands on the **same person's timeline** as the journey Crossdeck already captures in your app, joined by the recipient's email. Do one extra thing — stamp your email links with a single helper — and the *click* binds deterministically to that person on landing, so the session that opens after the click stitches to them without a login. Crossdeck only ever reads email events. It never sends on your behalf, and your token is held in Google Cloud Secret Manager, never logged, and revocable in one click.

## TL;DR

- **Connect in one click.** Dashboard &rarr; Developers &rarr; Integrations &rarr; Resend &rarr; **Connect**. OAuth 2.1 + PKCE; you approve in Resend's own UI, no API key to paste. Crossdeck auto-registers its own webhook — nothing to copy.

- **Email touches join the app journey.** Opens and clicks land on the *same* timeline as sign-ups, feature usage, revenue and errors — joined by the recipient's email, not stitched by hand.

- **Optional but powerful: stamp your links.** Wrap each link with `crossdeck.campaignLink({ email, url })` before you send. The returned URL carries an opaque ticket that binds the click to the person on landing — the deep stitch into the app.

- **Events you'll see:** `email.sent`, `email.delivered`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained` — each on the person's timeline and the Prism card.

- **Read-only events, held securely.** Crossdeck reads your email events; it never sends. The token lives in Secret Manager, is never logged or returned by any API, and is revoked on Resend's side the instant you disconnect.

## What you get

Resend is where the *send* lives — the transactional and marketing email your app puts in an inbox, and whether it was delivered, opened and clicked. Crossdeck already holds the other half: the anonymous first visit, the sign-up, the feature usage, the subscription, the errors they hit. On their own, an open rate is a number in a separate dashboard and a signup is a number in yours. Connecting Resend merges the two onto one timeline, keyed to one person.

Concretely, once connected:

- **Every email event** for a recipient appears on that person's Journey — you can see the welcome email they opened two days before they upgraded, and the digest they never clicked before they went quiet.

- **The person resolves to who they already are** in Crossdeck — not a second, duplicate record. One human, whether they arrived through your site or you emailed them.

- **The click can be followed into the app.** With one line at send time ([Step 2](#stamp-links)), the click deterministically binds the landing session to the person, so the pages they read and the actions they took after the click join their identity — no login required.

- **The Prism by Crossdeck card** shows the email touch next to the outcome it caused — the same moat every Crossdeck integration ships: the cross no single-layer tool can make.

Read events only. We never send as you.

Crossdeck reads your email events and manages its own webhook. It does not send email, does not touch your templates, audiences or domains. (Resend does not yet offer a granular read-only scope — see [Security & token custody](#security) for exactly what the connection can do and how it is held.)

## How the loop works — two halves

The integration has two halves. One is automatic the moment you connect; the other is one line of code you add if you want the deep stitch into the app.

**The half Crossdeck runs (automatic once you connect):** on connect, Crossdeck registers a webhook on your Resend account and starts receiving email events. Each event is **signature-verified**, then reconciled against Resend's API for the authoritative recipient, then folded onto that person's timeline — joined by their email. This half needs no code: connect and the email funnel (sent &rarr; delivered &rarr; opened &rarr; clicked, by identity) starts landing.

**The half you run (optional — the deep stitch):** knowing they *clicked* is server-side. To follow them *into the app* — to bind the landing session to the person — stamp the links in your email with one helper before you send. The click then carries an opaque, single-use ticket that the Crossdeck Web SDK reads on the landing page and resolves to the person automatically.

```
Connect Resend  (OAuth, one click)
  &rarr; Crossdeck registers its webhook & reads your email events
  &rarr; each event is signature-verified, reconciled, and folded
     onto the person's timeline — joined by recipient email
  &rarr; (optional) you stamp links with campaignLink() at send time
  &rarr; the click binds the landing session to the person
  &rarr; the post-click app journey joins their identity
  &rarr; the Prism card shows email touch next to app outcome
```

It degrades honestly.

With the Web SDK on your landing page *and* stamped links, you get a deterministic session bind and the full post-click journey. Without the stamp, the server-side funnel still lands by identity (sent &rarr; clicked, joined by email) — only the deep session stitch softens. Nothing here silently overclaims the deep bind.

## Step 1 — Connect Resend

In the Crossdeck dashboard, open **Developers &rarr; Integrations &rarr; Resend** and click **Connect**. Crossdeck redirects you to Resend's OAuth authorize screen (OAuth 2.1 with PKCE), where you approve the connection *in Resend's own UI*. There is no API key to generate and paste.

On approval, Crossdeck exchanges the code for a token, stores it in Secret Manager, and **registers its own webhook** on your Resend account for the six email lifecycle events. You do not copy a signing secret or paste an endpoint — the connector wires the webhook and captures its signing secret itself. Email events begin landing on your people's timelines within moments.

Disconnect is one click and it reaches Resend.

Remove the connection from **Developers &rarr; Integrations &rarr; Resend**. Crossdeck deletes the webhook it registered, *revokes the token on Resend's side*, and destroys its copy of the secret. Data already on the timeline stays; no new email events ingest until you reconnect.

## Step 2 — Stamp your links (optional, for the deep stitch)

This step is the &ldquo;on switch&rdquo; for following the click *into the app*. You know the recipient's email at send time; hand it to Crossdeck with the link's destination, and Crossdeck returns the same URL carrying a one-time arrival ticket. Put that URL in the email. When the recipient clicks and lands on a page running [@cross-deck/web](https://cross-deck.com/docs/quickstart-web/index.html), the SDK reads the ticket and binds the session to the person — automatically, no method to call.

```
// your backend, wherever you send email through Resend
import { CrossdeckServer } from "@cross-deck/node"
import { Resend } from "resend"

const crossdeck = new CrossdeckServer({ secretKey: process.env.CROSSDECK_SECRET_KEY })
const resend = new Resend(process.env.RESEND_API_KEY)

// 1. Stamp the link with the recipient's identity ticket (server-side).
const { url } = await crossdeck.campaignLink({
  email: "amara@acme.com",                // resolved to the person, once, here
  url: "https://app.yoursite.com/welcome",   // where they should land
})

// 2. Put the stamped URL in the email you send through Resend.
await resend.emails.send({
  from: "you@yourdomain.com",
  to: "amara@acme.com",
  subject: "Welcome — finish your setup",
  html: `<a href="${url}">Finish setup →</a>`,
})
```

Use a secret key, server-side only.

`campaignLink()` mints an identity bind for the email you name, so it must run on your server with a secret key (`cd_sk_` or a workspace key). A publishable key is refused — a browser must never be able to mint a bind for an arbitrary address. The email travels in the request body, never in a URL. If you prefer raw HTTP, this is `POST /v1/campaign/email-link` with `{ email, url }`; the response is `{ url }`.

The stamped URL carries only an opaque `cd_ref` ticket — no email, no personal data rides the link. The ticket is single-use and expires after 30 days (a campaign's useful life). If a recipient never lands on an SDK-instrumented page, nothing breaks: the server-side email funnel still lands by identity.

## What flows into the journey

Once connected, these Resend events land on the person's timeline, each joined to them by the recipient email:

- `email.sent` — the message was accepted for delivery.

- `email.delivered` — it reached the inbox provider.

- `email.opened` — the recipient opened it.

- `email.clicked` — the recipient clicked a link.

- `email.bounced` — delivery failed (hard or soft bounce).

- `email.complained` — the recipient marked it as spam.

Each event is de-duplicated server-side, so a webhook redelivery never doubles a touch. Because they share the timeline with everything else Crossdeck holds, you can finally ask the question no email tool can answer on its own: *which email touch actually moved someone to act in the app* — not just open, but sign up, upgrade, or come back.

## Identity model — joined by email

Integrations join by **email**, on purpose. When an email event arrives, Crossdeck resolves the recipient address to the person who already carries that email in your workspace, and folds the event onto their journey. If no one carries that address yet, the event still lands by email and stitches to the person the moment a later [identify](https://cross-deck.com/docs/identify-users/index.html) connects that address to a known customer.

This is the integration lane, not the bank-grade resolver.

Crossdeck's core identity graph never treats an email as a proof of identity — that is a deliberate security stance (see [How identity works](https://cross-deck.com/docs/how-identity-works/index.html)). Integrations are different: an email match between a Resend recipient and a known customer is good enough to join a *touch* to a *person*. The two systems are separate by design, so an integration match never weakens the graph.

The deep stitch in [Step 2](#stamp-links) is stronger still: the arrival ticket binds a specific click to a specific person deterministically, independent of the email match — so even a click from a forwarded email or a shared inbox resolves to the intended recipient.

## Security & token custody

This connection deserves a plain, honest account of what it can do and how it is held — because Resend does not yet offer a granular read-only scope.

- **The scope is `full_access`, because it is the only non-send scope Resend offers today.** That grants Crossdeck full API access to your Resend account — including, in principle, the ability to send from your verified domain. Crossdeck uses it only to *read email events and manage its own webhook*. Resend has told us granular scopes are planned with no breaking change; the day they ship, this connection narrows to read-only events with no work on your part.

- **The token never leaves the vault.** The OAuth access and refresh tokens are stored in Google Cloud Secret Manager — never in Firestore, never in a browser, never in a bundle. They are never logged and never returned by any Crossdeck API, MCP surface, or the dashboard.

- **Every inbound event is signature-verified.** Resend signs each webhook; Crossdeck verifies the signature against your account's per-connection signing secret (also held in Secret Manager) with a replay window, and rejects anything unsigned or forged. An unsigned webhook is not a nudge — it is treated as an attacker.

- **Disconnect revokes on Resend's side.** One click deletes the webhook Crossdeck registered, calls Resend's revoke endpoint so the token dies on *their* side too, and destroys the local secret. There is no lingering credential.

Why we tell you the scope in plain language.

A connection that can send as you is a serious grant, and we will never imply we hold a narrower permission than we do. If that custody is more than you want before granular scopes exist, don't connect — the choice is yours, and the disclosure is the same one you'll see on the connect card.

## Troubleshooting

### Connected, but email events aren't appearing on the timeline

Give it a moment after connecting for the first events to arrive, then send yourself a test email. If touches still don't appear, the recipient isn't resolving to a customer — check that a person in your workspace carries that exact recipient address. An address no one carries yet is held and stitches on a later identify, so it won't show until then.

### The click lands but the app session isn't stitched to the person

The deep stitch needs both halves of [Step 2](#stamp-links): the link must be stamped with `campaignLink()` at send time, *and* the landing page must run `@cross-deck/web`. If the link isn't stamped, or the landing page has no Web SDK, the server-side funnel still lands by email but the session bind won't happen.

### campaignLink() returns an authentication error

It requires a **secret** key and must run server-side. A publishable key (`cd_pub_`) is refused by design — minting an identity bind for a named email is not something a browser may do. Use your `cd_sk_` secret key (or a workspace key) from your backend.

### I disconnected but worry the token is still live

Disconnect revokes the token on Resend's side via their revoke endpoint, deletes the webhook, and destroys the local secret — not just a flag flip. You can confirm from your Resend dashboard's connected-apps view that the grant is gone.

## What's next

- [Identify users](https://cross-deck.com/docs/identify-users/index.html) — how a person comes to carry an email, and how the same human collapses across anonymous device, email, and rail ids.

- [How identity works](https://cross-deck.com/docs/how-identity-works/index.html) — the identity graph, and why email is an integration join but never a bank-grade anchor.

- [Web SDK quickstart](https://cross-deck.com/docs/quickstart-web/index.html) — put `@cross-deck/web` on your landing pages so the stamped click can bind.

- [Node SDK](https://cross-deck.com/docs/node-sdk/index.html) — the server-side client that carries `campaignLink()`.

- [Prism](https://cross-deck.com/docs/prism/index.html) — the card that shows email touch next to app outcome, and reading the joined portfolio from your own AI.

Read-only Resend connector — OAuth 2.1 + PKCE connect, auto-registered signature-verified webhook for sent / delivered / opened / clicked / bounced / complained, email-join onto the person's timeline, and the optional `campaignLink()` deep stitch (July 27, 2026). Token held under `full_access` in Secret Manager pending Resend's granular scopes; revoked on disconnect. Related: [Identify users](https://cross-deck.com/docs/identify-users/index.html), [How identity works](https://cross-deck.com/docs/how-identity-works/index.html).
