Once a broadcast leaves AppEngine, the providers (SES, SendGrid, Mailgun, Resend, Twilio, FCM, APNs) take over. Each one POSTs status updates back via webhooks, and AppEngine maps them onto the broadcast_delivery rows so the campaign report is always current.
What gets tracked
Per recipient, per channel, the pipeline writes one broadcast_delivery row at send time and updates it as the provider reports back:
{
broadcastId,
recipient: '[email protected]',
contactId: 'contact-...',
channel: 'email' | 'sms' | 'whatsapp' | 'push',
status: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked'
| 'bounced' | 'complained' | 'failed' | 'unsubscribed',
providerMessageId: 'sg-msg-...',
events: [
{ type: 'sent', at, providerEvent },
{ type: 'delivered', at },
{ type: 'opened', at, ip, userAgent },
{ type: 'clicked', at, url },
...
],
errorReason?: string,
cost?: { value: number, unit: string }
}
status is the latest milestone; events[] is the full chronology. Reports usually filter by status; analytics drill into events.
Email webhooks
Each provider has its own POST endpoint at /connect/webhook/{vendor}/{serviceId?}:
| Provider | Path |
|---|---|
| SendGrid | /connect/webhook/sendgrid |
| Mailgun | /connect/webhook/mailgun |
| AWS SES | /connect/webhook/ses/{orgId} |
| Resend | /connect/webhook/resend |
Configure the URL in the provider dashboard once; AppEngine handles signature verification and event mapping.
Event types
The mapping into AppEngine's normalized events[] array:
| Provider event | Normalized type |
|---|---|
processed, sent, accepted | sent |
delivered, delivery | delivered |
open | opened |
click | clicked |
bounce (hard) | bounced |
bounce (soft) | event recorded, status not changed |
dropped, failed, rejected | failed |
spamreport, complaint | complained |
unsubscribe, group_unsubscribe | unsubscribed |
SMS / WhatsApp webhooks
Twilio POSTs status callbacks to:
/connect/webhook/twilio/statusNo authStatuses: queued | sending | sent | delivered | undelivered | failed. The handler resolves the message via MessageSid, finds the matching broadcast_delivery row, and updates it.
For WhatsApp, additional read-receipt events (read, received) are mapped into the same row's events[] so you can see when the recipient opened the message.
Push webhooks
FCM and APNs don't have rich webhooks. Status is read at send time:
- Successful sends →
status: 'sent' messaging/registration-token-not-registered→status: 'bounced', push token marked inactive- Other errors →
status: 'failed'witherrorReason
Open and click tracking for push relies on the app reporting back through POST /repository/create/analytics_event when the user taps the notification.
Bounce handling
When a row goes bounced, the pipeline:
- Marks the recipient contact's email (or phone) as
bouncedso future broadcasts skip it - Increments the sender account's
bounceRate - Records a CRM activity ("Email bounced", with reason)
- If
bounceRate > 3%, flags the sender account so the rotation strategy avoids it
Hard bounces are permanent (mailbox doesn't exist, blocked). Soft bounces (mailbox full, rate limited) record an event but don't change status — the next send to the same recipient is allowed.
Complaint handling
A complained row is the hardest signal a recipient sends — they hit "Spam" in their mail client. The pipeline:
- Marks the contact as
optedOut: trueon every channel - Records a CRM activity ("Spam complaint")
- Increments the sender account's
complaintRate - Adds the contact to the org's suppression list — no future broadcasts can be sent to them on any channel without an explicit override
Unsubscribe handling
Email broadcasts auto-include {{unsubscribeUrl}}. Clicking it:
- Records a
broadcast_deliveryevent of typeunsubscribed - Removes the contact from the broadcast's recipient list
- Sets
optedOut: trueon the contact for the relevant channel - Surfaces an unsubscribe activity in CRM
For SMS, Twilio handles STOP / UNSUBSCRIBE / CANCEL natively. AppEngine syncs the opt-out into the contact record so subsequent automations skip the contact too.
Reading status
/broadcast/{id}/reportJWTPer-recipient log with optional filter:
curl "https://appengine.appmint.io/broadcast/{id}/report?status=bounced&pageSize=200" \
-H "orgid: my-org" -H "Authorization: Bearer <jwt>"
/broadcast/activityJWTCross-broadcast activity feed — useful for an admin dashboard. Filters: startDate, endDate, event, recipient, limit.
/broadcast/activity/{messageId}JWTDetail view for a single send — the full provider event log.
/broadcast/statsJWTAggregated counters across the org. Supports aggregatedBy: 'day' | 'week' | 'month' for time-series charts.
/broadcast/stats/summaryJWTThe 30-day summary card for the dashboard.
Re-sending failed
There is no built-in "resend failed" action. Pull the bounced or failed rows via /broadcast/{id}/report?status=failed, generate a manual recipient list, and create a new broadcast targeting that list. Build it once as an automation if you need it routinely.
A typical mistake: re-importing a contact CSV that re-introduces previously-bounced addresses. The contact-level bounced flag stops them from getting messaged, but only if you preserve it on import. Match by email and skip rather than overwrite.