Resend email events
- Endpoint
-
POST /api/webhooks/resend - Dispatcher
-
ResendWebhookDispatcherinlib/events/resend-event-handler.ts - Handlers
-
lib/events/handlers/resend/
Route flow
-
Verify the Svix signature when
RESEND_WEBHOOK_SECRETis set; skipped when unset. -
Upsert a
WebhookEventrow, idempotent on thesvix-idheader. -
Extract
data.email_idas theresendEmailIdcorrelation key. -
Link the event to its campaign target when
resendEmailIdis not saved yet: first by thetarget_idtag, then bydata.template_idplus recipient address (resolveResendWebhookTarget, skipped foremail.received). See Matching webhooks to targets. -
Dispatch to the handler registered for the event type.
Handler registry
| Event | Handler | Effect |
|---|---|---|
|
|
Increments |
|
|
Target status → |
|
|
Treated as sent. Resend fires it after |
|
|
First open only: sets |
|
|
First click only: sets |
|
|
Target status → |
|
|
Target status → |
|
|
Resend dropped the send because the address is on its suppression list. Target status → |
|
|
Target status → |
|
|
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.
-
Strip
Re:/Fwd:prefixes from the subject. -
Match the cleaned subject against
EmailVariation.subjectamong targets that share the sender’s address, with a fallback when the subject is missing. -
Upsert an
Activityrow for the reply. -
Increment
receivedCounton the sending account. -
When a target and a
fromaddress were both resolved, publish toINCOMING_EMAIL_SQS_QUEUE_URLthroughpublishIncomingEmailCategorise:
{
"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 |
|---|---|
|
|
|
|
|
On a Permanent bounce, |
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.