Resend email events

Endpoint

POST /api/webhooks/resend

Dispatcher

ResendWebhookDispatcher in lib/events/resend-event-handler.ts

Handlers

lib/events/handlers/resend/

Route flow

  1. Verify the Svix signature when RESEND_WEBHOOK_SECRET is set; skipped when unset.

  2. Upsert a WebhookEvent row, idempotent on the svix-id header.

  3. Extract data.email_id as the resendEmailId correlation key.

  4. Link the event to its campaign target when resendEmailId is not saved yet: first by the target_id tag, then by data.template_id plus recipient address (resolveResendWebhookTarget, skipped for email.received). See Matching webhooks to targets.

  5. Dispatch to the handler registered for the event type.

Handler registry

Event Handler Effect

email.sent

EmailSentHandler

Increments EmailSendingDomain.sentCount when the target has a sendingDomainId, upserts the outbound Emails row, and sets target status → sent with sentAt while it is still pending or requested

email.delivered

EmailDeliveredHandler

Target status → sent with sentAt, only while it is still pending or requested, so a late delivery never downgrades opened. Deliberately does not increment sentCount — email.sent already did

email.delivery_delayed

EmailDeliveryDelayedHandler

Treated as sent. Resend fires it after email.sent, often more than once, so it only acts when the target is still pending or requested (the email.sent webhook was missed): it then marks the target sent and does the email.sent side effects. Otherwise it is a no-op, so repeated delays never bump sentCount or sentAt

email.opened

EmailOpenedHandler

First open only: sets openedAt whatever the status, increments openedCount; status → opened only from pending, requested or sent

email.clicked

EmailClickedHandler

First click only: sets clickedAt, increments clickedCount

email.bounced

EmailBouncedHandler

Target status → bounced unless it is blocked or suppressed, increments bouncedCount; stores bounce type, subtype, message (lastError), and SMTP diagnostic. A Permanent bounce also sets target_emails.isBlocked, blockReason, and blockedAt so the address is not sent again

email.complained

EmailComplainedHandler

Target status → blocked; upserts target_emails with isBlocked, blockReason (Spam complaint), blockedAt, marketingOptOut, marketingOptOutAt

email.suppressed

EmailSuppressedHandler

Resend dropped the send because the address is on its suppression list. Target status → suppressed from pending, requested or sent, with lastError Suppressed: <reason>; upsertBlockedFromBounce blocks the address with blockReason resend_suppressed:<reason>

email.unsubscribed

EmailUnsubscribedHandler

Target status → unsubscribed unless it is blocked or suppressed; upserts target_emails with newsletterOptOut, newsletterOptOutAt; increments unsubscribedCount

email.received

EmailReceivedHandler

Inbound reply — see below

Opens and clicks are deduplicated by checking whether the timestamp is already set, so counters are unique-recipient counts rather than raw event counts.

A click sets clickedAt but no status: there is no clicked value in EmailTargetStatus.

Base class

export abstract class ResendEventHandler {
  constructor(protected readonly context: ResendWebhookContext) {}
  abstract handle(repo: EmailCampaignRepository): Promise<void>
  protected async bumpSendingDomainStat(repo, sendingDomainId, stat): Promise<void>
}

bumpSendingDomainStat is backed by EmailCampaignRepository.incrementSendingDomainStat, so every handler increments counters the same way.

Inbound replies (email.received)

The data.email_id on an inbound event is the new message id, not the outbound resendEmailId stored on the target, so the reply has to be matched heuristically.

  1. Strip Re: / Fwd: prefixes from the subject.

  2. Match the cleaned subject against EmailVariation.subject among targets that share the sender’s address, with a fallback when the subject is missing.

  3. Upsert an Activity row for the reply.

  4. Increment receivedCount on the sending account.

  5. When a target and a from address were both resolved, publish to INCOMING_EMAIL_SQS_QUEUE_URL through publishIncomingEmailCategorise:

{
  "event_type": "Email.IncomingCategorise",
  "campaign_id": "uuid",
  "contact_email": "prospect@example.com",
  "inbound_subject": "Re: Quick question",
  "inbound_text": "plain text, HTML stripped"
}

Requires AWS_REGION and credentials. A downstream service classifies the reply and writes back EmailCampaignTarget.category, categorisedAt and categoryModel.

Subject matching is why Reply-To is derived per sending account (buildReplyToForSendingDomain) — it narrows the candidate set to targets that used that account.

Suppression writes

Handler Repository call

EmailUnsubscribedHandler

upsertNewsletterOptOut(subscriptionId, email, contactId?) — sets newsletterOptOut and newsletterOptOutAt, creating the target_emails row if absent

EmailComplainedHandler

upsertBlockedFromComplaint(…​) — sets isBlocked, blockReason (Spam complaint), blockedAt, marketingOptOut, marketingOptOutAt

EmailBouncedHandler

On a Permanent bounce, upsertBlockedFromBounce(…​) — sets isBlocked, blockReason, and blockedAt. Does not set opt-out flags. Transient and Undetermined bounces are stored on the campaign target only

A complaint is treated more harshly than an unsubscribe because it threatens the deliverability of the whole domain, not just one recipient’s preference. A Permanent bounce is an invalid address: the registry blocks it, and the campaign target stays bounced so bounce stats stay separate from complaints.

Downstream, emailSend’s `check-eligibility step treats isBlocked as blocked (status blocked, lastError from blockReason) and newsletter or marketing opt-out as unsubscribed. When both are set, the target is blocked. fetchAndLockPendingTargets filters on newsletterOptOut = false AND isBlocked = false at lease time. See Suppression.

CRM publishing

Resend handlers call publishCrmCustomEvent / publishCrmNewActivity only when the contact has a HubSpot contact id, so a consumer never receives an unresolvable payload. See CRM queue and HubSpot events.