Skip to content
SSalesShiftDocs

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.

All four receivers exercisedKafka pipeline verified liveStripe path not exercised

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.

ProviderPathBody shape
SendGridPOST /webhooks/{integration_id}/sendgridA JSON array of event objects
MailgunPOST /webhooks/{integration_id}/mailgunAn object with an event-data key
Amazon SESPOST /webhooks/{integration_id}/sesAn SNS envelope
Get the integration id from GET /settings/integrations, then configure that URL in the provider's own event settings.

How an event is applied

Provider vocabularies are normalised to one map. This is the whole set:

Incoming eventTracking statusSuppresses?Reason
delivereddeliveredNo
openopenedNo
clickclickedNo
bouncebouncedYeshard_bounce
droppedbouncedYeshard_bounce
spamreportbouncedYescomplaint
complainedbouncedYescomplaint
unsubscribeunsubscribedYesunsubscribe
failedfailedNo

Beyond setting a status, a suppressing event cascades:

  • A suppression row is written with source: "webhook".
  • An unsubscribe also clears the contact’s email_subscribedflag — the second, independent gate described in Sending email.
  • Every active enrolment for that contact is stopped, becoming bounced or unsubscribed as appropriate.

Posting real events

All three receivers were exercised against the live service. A safe open event, which changes a status without suppressing anyone:

Executed — SendGrid shape
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}
Executed — Mailgun shape
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.

Executed — a bad integration id
curl -s -X POST "$API/webhooks/00000000-0000-0000-0000-000000000000/sendgrid" \
  -H 'Content-Type: application/json' -d '[]'

{"success":false,"error":"unknown integration"}
HTTP:200
Assert on success in the body, never on the status code, when testing these.

For 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

POST/webhooks/stripe/{integration_id}signature-verified

The payment receiver is the exception, and it is strict:

ConditionResponse
Valid Stripe-Signature against the stored endpoint secret200 with {"received", "handled", "duplicate"}
No endpoint secret stored on the integration503— it refuses rather than trusting the body
Bad signature400 Invalid signature
Integration id is not a payment integration404 Unknown payment integration
Executed — pointed at an email 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:404
Re-run while revising this guide and still exactly this. The verified-signature path above it has not been exercised, and currently cannot be by anyone: a payment integration is the thing this route looks up, and POST /settings/integrations rejects payment/stripe with 400 Unsupported provider, so no organisation can create one. The three rows above it are read from source, not observed.

duplicatein 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.

TopicDefaultProduced by
Email eventssalesshift.email.eventsThe API, on every send, open, click, bounce, complaint and unsubscribe
Workflow eventssalesshift.workflow.eventsThe Go workflow engine
Both names are overridable by environment variable. Messages are keyed by organisation id, so per-tenant ordering is preserved.
Payload shape
{
  "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.

GET/analytics/activity/health
Real response, taken mid-session
{
  "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 }
}
Those 23 produced-and-delivered events were generated by the commands in these guides. Lag zero on both partitions.
Producer counterMeaning
producedHanded to the Kafka client
deliveredThe broker acknowledged it
failedThe broker rejected it, or it timed out in the queue
dropped_localNever reached the client at all — local queue full, or no producer. This is real data loss.
produced minus delivered minus failed should be small and transient. A growing dropped_local means the projection is missing events.

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.

GET/analytics/activity/timeline
Shell
curl -s "$API/analytics/activity/timeline?limit=6" -H "Authorization: Bearer $TOKEN"
Real response — events generated while writing these guides
[
  { "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 'open' rows are the webhook events posted earlier on this page; 'payment_succeeded' is the invoice payment recorded in the quotes guide.

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_clicked workflow trigger, so visual workflows react to it without polling.

Setting it up

  1. 1

    Find the integration id

    Shell
    curl -s "$API/settings/integrations" -H "Authorization: Bearer $TOKEN"
    Real output
    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. 2

    Configure the URL at the provider

    Text
    https://<your-api-host>/api/v1/salesshift/webhooks/<integration_id>/sendgrid

    Enable at minimum bounce, dropped, spamreport and unsubscribe— those are the ones that protect your sending reputation. delivered, open and click enrich the timeline.

  3. 3

    Verify with a real event

    Post one yourself with curl, as above, and confirm applied: 1. Then check it landed in the projection with GET /analytics/activity/timeline. If the projection is empty but the receiver returned applied: 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