Guide
Webhooks and events
Events move in two directions. Inbound, your email and payment providers call SalesShift to report what happened to a message or an invoice. Outbound, SalesShift publishes every email lifecycle transition to Kafka, where a consumer projects it into a queryable activity timeline.
This guide covers both, and is blunt about the security model: three of the four inbound receivers do not verify signatures, and you should know that before you publish their URLs.
Inbound: email provider callbacks
Providers push delivery, bounce, complaint and open events to a URL that embeds your integration id. Hard bounces and complaints feed the suppression list automatically, which is what keeps deliverability from decaying quietly.
| Provider | Path | Body shape |
|---|---|---|
| SendGrid | POST /webhooks/{integration_id}/sendgrid | A JSON array of event objects |
| Mailgun | POST /webhooks/{integration_id}/mailgun | An object with an event-data key |
| Amazon SES | POST /webhooks/{integration_id}/ses | An SNS envelope |
How an event is applied
Provider vocabularies are normalised to one map. This is the whole set:
| Incoming event | Tracking status | Suppresses? | Reason |
|---|---|---|---|
delivered | delivered | No | — |
open | opened | No | — |
click | clicked | No | — |
bounce | bounced | Yes | hard_bounce |
dropped | bounced | Yes | hard_bounce |
spamreport | bounced | Yes | complaint |
complained | bounced | Yes | complaint |
unsubscribe | unsubscribed | Yes | unsubscribe |
failed | failed | No | — |
Beyond setting a status, a suppressing event cascades:
- A suppression row is written with
source: "webhook". - An
unsubscribealso clears the contact’semail_subscribedflag — the second, independent gate described in Sending email. - Every active enrolment for that contact is stopped, becoming
bouncedorunsubscribedas appropriate.
Posting real events
All three receivers were exercised against the live service. A safe open event, which changes a status without suppressing anyone:
curl -s -X POST "$API/webhooks/e7603b12-301a-45d1-83fc-ba232521a2be/sendgrid" \
-H 'Content-Type: application/json' \
-d '[{"email":"[email protected]","event":"open",
"timestamp":1786040000,"sg_message_id":"docs-demo"}]'
{"success":true,"applied":1}curl -s -X POST "$API/webhooks/e7603b12-…/mailgun" \
-H 'Content-Type: application/json' \
-d '{"event-data":{"event":"opened","recipient":"[email protected]"}}'
{"success":true,"applied":1}applied counts events that matched the map andcarried a usable address. An event type outside the table is accepted and ignored, so enabling every event in a provider’s console is harmless.
curl -s -X POST "$API/webhooks/00000000-0000-0000-0000-000000000000/sendgrid" \
-H 'Content-Type: application/json' -d '[]'
{"success":false,"error":"unknown integration"}
HTTP:200For SES, an SubscriptionConfirmation message is logged for an operator rather than auto-confirmed. Confirming a subscription automatically would let anyone who can reach the URL point an SNS topic at your suppression list.
Inbound: Stripe
/webhooks/stripe/{integration_id}signature-verifiedThe payment receiver is the exception, and it is strict:
| Condition | Response |
|---|---|
Valid Stripe-Signature against the stored endpoint secret | 200 with {"received", "handled", "duplicate"} |
| No endpoint secret stored on the integration | 503— it refuses rather than trusting the body |
| Bad signature | 400 Invalid signature |
| Integration id is not a payment integration | 404 Unknown payment integration |
curl -s -X POST "$API/webhooks/stripe/e7603b12-…" \
-H 'Stripe-Signature: t=1,v1=deadbeef' \
-H 'Content-Type: application/json' \
-d '{"id":"evt_1","type":"invoice.paid"}'
{"detail":"Unknown payment integration"}
HTTP:404duplicatein the success body is Stripe’s at-least-once delivery being handled for you — a redelivered event id is acknowledged without being applied twice.
Outbound: the Kafka activity stream
Every email lifecycle transition is published to a Kafka topic using SendGrid’s field vocabulary, so a downstream consumer can treat the stream exactly like a SendGrid feed.
| Topic | Default | Produced by |
|---|---|---|
| Email events | salesshift.email.events | The API, on every send, open, click, bounce, complaint and unsubscribe |
| Workflow events | salesshift.workflow.events | The Go workflow engine |
{
"email": "[email protected]",
"event": "open",
"timestamp": 1786040792,
"smtp-id": "",
"sg_message_id": "e9166271d56e4221a9f27c982d536c0b",
"category": ["salesshift"],
"org_id": "3f7a9c21-5e84-4b16-9d0a-2c6f8e1b7a40",
"provider": "smtp",
"sequence_id": "81feb53b-17c3-4d21-970a-63976f5cfb57",
"reason": "hard_bounce"
}sg_message_idcarries the SalesShift tracking id — it fills SendGrid’s slot so existing consumers need no changes. sequence_id and reason appear only when relevant.Publishing is fail-open, but never fail-silent
Email flow must never block on the event bus, so every publish is fire-and-forget. But a dropped event is still a hole in your timeline, so drops are countedrather than logged — a warning per event during a broker outage is the fastest way to fill a disk, and nothing can query a log line.
/analytics/activity/health{
"consumer": {
"group_id": "salesshift-activity-projector",
"topics": ["salesshift.email.events", "salesshift.workflow.events"],
"bootstrap": "localhost:9092",
"running": true,
"started_at": "2026-08-06T17:01:35.977080+00:00",
"last_batch_at": "2026-08-06T18:41:28.545615+00:00",
"batches": 27,
"messages_read": 37,
"rows_written": 37,
"duplicates_skipped": 0,
"pending_rows": 0,
"behind": false,
"assigned_partitions": 2,
"kafka_reachable": true,
"last_error": null
},
"group": {
"group_present": true,
"partitions": [
{ "topic": "salesshift.email.events", "partition": 0,
"committed_offset": 8306, "log_end_offset": 8306, "lag": 0 },
{ "topic": "salesshift.workflow.events", "partition": 0,
"committed_offset": 6444, "log_end_offset": 6444, "lag": 0 }
]
},
"producer": { "produced": 23, "delivered": 23, "failed": 0, "dropped_local": 0 }
}| Producer counter | Meaning |
|---|---|
| produced | Handed to the Kafka client |
| delivered | The broker acknowledged it |
| failed | The broker rejected it, or it timed out in the queue |
| dropped_local | Never reached the client at all — local queue full, or no producer. This is real data loss. |
Reading the projection
The consumer projects both topics into an activity table you can query for a contact, a workflow, a run, or the whole organisation.
/analytics/activity/timelinecurl -s "$API/analytics/activity/timeline?limit=6" -H "Authorization: Bearer $TOKEN"[
{ "id": "c6c37971-…", "source": "email", "event": "open",
"occurred_at": "2026-08-06T18:41:28+00:00",
"contact_id": "613f2aec-…", "contact_email": "[email protected]",
"workflow_id": null, "run_id": null, "sequence_id": null },
{ "id": "c45a043e-…", "source": "email", "event": "payment_succeeded",
"occurred_at": "2026-08-06T18:39:18+00:00",
"contact_id": null, "contact_email": null,
"tracking_id": "d8fda22e-e800-4ebc-bd74-1f7848314769" },
{ "id": "56203729-…", "source": "email", "event": "processed",
"occurred_at": "2026-08-06T18:38:47+00:00",
"contact_id": "613f2aec-…", "contact_email": "[email protected]" }
]The tracking endpoints are also a webhook
Worth naming explicitly: the open pixel and click redirect are unauthenticated endpoints called by recipients’ mail clients, and they emit into the same event stream. They are documented in Sending email, where all three are exercised. Two properties matter here:
- An event is emitted only on the first open and the first click. Counters keep incrementing, but the stream is not spammed by an image proxy refetching a pixel.
- A first click also fires the
email_clickedworkflow trigger, so visual workflows react to it without polling.
Setting it up
- 1
Find the integration id
curl -s "$API/settings/integrations" -H "Authorization: Bearer $TOKEN"a7c7f922-b333-434b-bc7a-5afd9c03b5e0 | email | imap | active true 442cdefe-cb55-43fd-bbb7-d44b85fb4f1a | email | smtp | active true e7603b12-301a-45d1-83fc-ba232521a2be | email | smtp | active true - 2
Configure the URL at the provider
https://<your-api-host>/api/v1/salesshift/webhooks/<integration_id>/sendgridEnable at minimum
bounce,dropped,spamreportandunsubscribe— those are the ones that protect your sending reputation.delivered,openandclickenrich the timeline. - 3
Verify with a real event
Post one yourself with curl, as above, and confirm
applied: 1. Then check it landed in the projection withGET /analytics/activity/timeline. If the projection is empty but the receiver returnedapplied: 1, check/analytics/activity/health— the consumer, not the webhook, is the problem.
Common problems
Provider reports success, but nothing changes in SalesShift
Read the response body. {"success": false, "error": "unknown integration"} arrives with HTTP 200, so the provider sees a success and you see nothing happen. Check the UUID in the URL against GET /settings/integrations.
applied: 0 on an event you know is valid
Either the event name is not in the map, or the address field is empty. SendGrid uses email, Mailgun uses event-data.recipient; posting a Mailgun body to the SendGrid path parses but finds no address. Also note that events are matched to the most recent tracked send to that address, so an event for an address you have never mailed applies to nothing.
Contacts are being suppressed unexpectedly
Check source on the suppression rows: GET /settings/suppressions shows webhook for provider-driven ones and tracking:<id> for hosted unsubscribes. If you see webhookfor people who never complained, treat the URL as compromised — it has no signature check, and that is exactly the abuse it allows. Rotate the integration.
503 Webhook secret not configured from Stripe
The endpoint secret has not been stored on the payment integration. The receiver refuses rather than processing an unverified body, which is correct. Add the signing secret from the Stripe dashboard to the integration and retry — Stripe will redeliver.
The activity timeline is behind or has gaps
Read /analytics/activity/health. lag per partition shows the consumer falling behind; kafka_reachable: false or a non-null last_error shows a broker problem; and a non-zero dropped_local on the producer is genuine loss that will never be recovered by waiting. duplicates_skippedbeing non-zero is healthy — that is at-least-once delivery being deduplicated.
Events arrive out of order and overwrite good state
They should not: richer states are never downgraded to delivered. If you are consuming the raw Kafka topic yourself rather than the projection, you own that ordering problem — messages are keyed by organisation id, so ordering is guaranteed only within a partition, and the timestampfield is the event’s own time, not the publish time.
Next
Sending email
The tracking endpoints these events describe, exercised end to end.
Deliverability
Bounce and complaint events feed health scoring and auto-pause.
Quotes and invoicing
Where the Stripe integration comes from.
REST API reference
The webhook routes, their auth classification and every error case.