# Sweego Webhooks Guide: Events, Signature and Replay

> Receive, verify and replay Sweego webhooks: real email and SMS payloads, HMAC-SHA256 checks in Node and Python, and a safe test loop.

Source: https://webhooker.eu/blog/sweego-webhooks-guide
Last updated: 2026-09-14

Sweego webhooks are signed with HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{body}`, using a base64-decoded secret and a base64-encoded signature. To test the integration properly you need a stable URL that Sweego can reach even while your laptop is closed, a handler that checks the Sweego webhook signature against the raw body, and a way to replay email webhook events and SMS events against new code without sending another email or another paid SMS. This guide covers each of those.

A disclosure first. Webhooker’s own transactional email, the sign-up confirmations and delivery alerts, goes out through [Sweego](https://www.sweego.io/), a French email and SMS API with its datacenters in France. We picked it because it keeps our stack inside the EU end to end, and this article is written from the webhooks we consume ourselves rather than from a docs page. Their team also tests Sweego’s webhooks against Webhooker, so the relationship runs in both directions.

## What Sweego actually sends

Sweego emits one webhook per event, as a JSON POST, for three channels. Knowing the catalogue up front saves you from writing a handler for `delivered` and then discovering that bounces arrive under two different names.

**Email events.** `email_sent`, `delivered`, `soft-bounce`, `hard_bounce`, `list_unsub`, `complaint`, plus the tracking events `email_opened` and `email_clicked`. Note the inconsistency in the names: one bounce uses a hyphen and the other an underscore. Match on the exact string.

**SMS events.** `sms_sent`, `sms_undelivered`, `sms_stop` and `sms_clicked`. Most SMS payloads carry a `test_mode` boolean; read it in staging so you never treat a test send as a billable delivery.

**Inbound email.** A single event, `email_inbound`, fired when a message lands on an inbound subdomain you have pointed at Sweego with an MX record. It carries the parsed `from_`, `to`, `cc`, `subject`, `text` and `html` fields, and metadata for each attachment. Attachment contents are not in the payload; you fetch them from the API by the attachment’s `uuid`. Inbound routing is a paid-plan feature and the message size limit is 30 MB.

All three channels share four fields (`event_type`, `timestamp`, `event_id`, which is a UUID, and `channel`) plus `swg_uid`, Sweego’s own identifier for the message. The `event_id` is the one to store, and we will come back to why. Beyond those four, email and SMS are different shapes, and the difference is large enough that a single handler written against one channel will read `undefined` all over the other.

### What an email event actually looks like

Here is a `delivered` payload, trimmed and with the addresses changed, taken from Sweego’s [email payload reference](https://learn.sweego.io/docs/webhooks/payload):

```json
{
  "event_type": "delivered",
  "timestamp": "2024-09-02T08:45:08+00:00",
  "swg_uid": "01-f1491565-39b6-4160-bc45-f5b27a277ca9",
  "event_id": "7ebba9ce-3742-45fe-866a-a0699a5a8042",
  "channel": "email",
  "transaction_id": "861aad97-e4e8-4aaf-9322-1b64835760b9",
  "headers": {
    "x-mailer": "Sweego",
    "x-swg-uid": "01-f1491565-39b6-4160-bc45-f5b27a277ca9",
    "x-client-id": "0c8cc711c85e4595953862",
    "x-campaign-id": "default",
    "x-campaign-type": "default",
    "x-originating-ip": "185.255.28.207",
    "x-transaction-id": "861aad97-e4e8-4aaf-9322-1b64835760b9"
  },
  "campaign_tags": null,
  "campaign_type": "default",
  "campaign_id": "default",
  "recipient": "user@example.com",
  "domain_from": "send.sweego.io",
  "details": "Sep 02 08:45:08 info Sender/gmail/1007[14] ACCEPTED to=user@example.com mx=gmail-smtp-in.l.google.com (250 2.0.0 OK - gsmtp)"
}
```

Three fields in that payload are easy to overlook.

`headers` is the message’s own header set echoed back, and it includes any custom `x-` headers you set at send time. Those custom headers give you a join key between a Sweego event and a row in your database, more reliable than matching on the recipient address, because one address can have twenty messages in flight. Set `x-ref-1` to your order id when you send, read `headers["x-ref-1"]` when the event arrives, and you never need a lookup table keyed on email addresses.

`transaction_id` is stable for the life of one message: `email_sent`, `delivered` and a later `email_clicked` all carry the same value. It is the message identity; `event_id` is the event identity. You dedupe on one and group on the other.

`details` is the raw MTA log line, including the remote server’s SMTP response: the `(250 2.0.0 OK)` above, or the refusal text on a failure. It tells you *why* something happened, and it also carries the recipient address in plain text, which matters for the storage question further down.

### Bounces carry the SMTP status code

`soft-bounce` and `hard_bounce` payloads add two fields the others do not have: `response_code` and `status`. The code is the SMTP reply from the receiving server (`452` for a full mailbox on a soft bounce, `550` for an invalid recipient on a hard one), and it lets bounce handling do more than flip a boolean. A 452 means retry tomorrow; a 550 means suppress the address. Reading only `event_type` throws that distinction away.

### SMS payloads are a different envelope

Nothing from the email shape carries over except the four shared fields. An `sms_undelivered` event looks like this:

```json
{
  "event_type": "sms_undelivered",
  "timestamp": "2026-02-05T11:55:38",
  "swg_uid": "03-ec11d5b8-0578-49aa-9ecc-73966fb829ec",
  "event_id": "454ed966-7fda-44e4-94d0-31fb35049b50",
  "channel": "sms",
  "client_id": "73d177d7-3cc3-4286-bbdd-a87ad778e2ed",
  "transaction_id": "f5c0c329-fa69-4c6e-afee-8e95963a8e56",
  "phone_number": "00351919888456",
  "country_code": "PT",
  "mobile_country_code": 268,
  "mobile_network_code": 1,
  "sender_id": "ReservasPT",
  "sms_type": "transactional",
  "sms_price": 0.02,
  "nb_segments": 1,
  "campaign_id": "random-campaign-id",
  "send_date": "2026-02-05T10:55:31.581886",
  "status": "undelivered",
  "test_mode": false
}
```

There is no `headers` object, so the custom-header correlation trick does not exist on this channel; `transaction_id` is what you have, and on `sms_clicked` it can be `null`. In exchange you get billing data in the event itself: `sms_price` and `nb_segments` mean your webhook stream is also your cost ledger, per message, without polling an invoice endpoint. `sms_type` separates `transactional` from `market`, which is the distinction that decides whether an opt-out applies.

### Names that do not line up

The catalogue has enough small inconsistencies that guessing a field name will cost you an evening. Check these before you write the handler:

- `soft-bounce` is hyphenated, `hard_bounce` is not.
- The email format tables document `campaign_tag`; the payloads carry `campaign_tags`.
- The `sms_sent` table documents `client-id`; the payload carries `client_id`.
- `sms_stop` reports its cost as `price`, while every other SMS event uses `sms_price`.

Match on exact strings. A fixture library built from captured events instead of from the tables catches all four before they reach production.

### Opens and clicks come with a proxy flag

`email_opened` and `email_clicked` carry a nested `click` object with `ip_address`, `url`, `user_agent` and a `proxy` boolean. Apple Mail Privacy Protection and Google’s image proxy fetch tracking pixels on the recipient’s behalf, so a `proxy: true` open means an inbox received the message, not that a human read it. If you feed opens into anything that resembles engagement scoring, filter on that flag first, or proxy fetches will inflate the open rate. `sms_clicked` carries the same `click` object without the user agent.

`list_unsub` has its own detail: a `one_click` boolean that distinguishes an RFC 8058 one-click unsubscribe from a click through to a landing page. Both mean stop sending; only one of them means the recipient never saw your preference centre.

## Verifying the signature

Three headers ride along with every request:

- `webhook-id`, a unique identifier for this request
- `webhook-timestamp`, a Unix timestamp of when it was sent
- `webhook-signature`, the base64-encoded HMAC-SHA256

The signed content is the three parts joined with dots: the id, the timestamp, and the raw body exactly as received. Sweego’s [signature documentation](https://learn.sweego.io/docs/webhooks/webhook_signature) is precise about two details that catch people out. The secret you copy from the dashboard is itself base64, so decode it before using it as the HMAC key. And the digest you compute must be base64-encoded before you compare it, not hex.

If that layout looks familiar, it is the same id-dot-timestamp-dot-body shape the Standard Webhooks convention uses, minus the `v1,` prefix on the header value. Sweego’s docs ship Python, Go, PHP, Ruby, C#, Java and Node examples; here is the Node version with the two mistakes that break it most often called out in comments.

```js
const crypto = require("crypto");
const express = require("express");

const app = express();

// Keep the exact bytes Sweego signed. A JSON parser that runs first will
// re-serialize the body and the signature will never match.
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; },
}));

// The dashboard secret is base64. Decode it once; use the bytes as the key.
const SECRET = Buffer.from(process.env.SWEEGO_WEBHOOK_SECRET, "base64");

function isValidSweegoRequest(req, toleranceSeconds) {
  const id = req.get("webhook-id") || "";
  const timestamp = req.get("webhook-timestamp") || "";
  const received = req.get("webhook-signature") || "";

  // Refuse stale requests so a captured payload cannot be replayed later.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!id || !timestamp || age > toleranceSeconds) return false;

  const signedContent = Buffer.concat([
    Buffer.from(`${id}.${timestamp}.`),
    req.rawBody,
  ]);
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(signedContent)
    .digest("base64"); // base64, not hex

  const expectedBuffer = Buffer.from(expected);
  const receivedBuffer = Buffer.from(received);
  return (
    expectedBuffer.length === receivedBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
  );
}

app.post("/webhooks/sweego", (req, res) => {
  if (!isValidSweegoRequest(req, 300)) {
    return res.status(401).send("bad signature");
  }
  const { event_type, event_id } = req.body;
  // ... enqueue or process, keyed on event_id ...
  res.sendStatus(200);
});

app.listen(3000);
```

Sweego’s reference implementation has no timestamp tolerance, and the check is optional, but a request captured yesterday still carries a valid signature. Checking the age of `webhook-timestamp` is what turns the signature into a defence against [replay attacks](https://webhooker.eu/blog/webhook-replay-attacks-timestamp-tolerance) as well as forgery. Five minutes is a common window.

### The same check in Python

Sweego ships a Python example too, with one difference from the version below. Its version signs an already-decoded string, which works until a payload contains a byte sequence your framework re-encodes on the way through. Signing the bytes you received avoids the whole question:

```python
import base64
import hashlib
import hmac
import time

def is_valid_sweego_request(headers, raw_body, secret_b64, tolerance_seconds=300):
    webhook_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    received = headers.get("webhook-signature", "")
    if not webhook_id or not timestamp:
        return False
    if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
        return False

    signed_content = f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(
        hmac.new(
            base64.b64decode(secret_b64), signed_content, hashlib.sha256
        ).digest()
    ).decode("utf-8")
    return hmac.compare_digest(expected, received)
```

In Flask that means `request.get_data()` rather than `request.get_data(as_text=True)`, and in Django `request.body` rather than anything that has been through a parser. The rule is the same in every language: the signature covers bytes, so hold on to the bytes.

If verification fails and you are sure of the secret, the cause is almost always one of the [six usual suspects](https://webhooker.eu/blog/webhook-signature-verification-failed): a parsed-and-re-encoded body, a hex digest where base64 was expected, the secret used as a string instead of decoded bytes, or a proxy that rewrote the body on the way in.

## Getting a URL Sweego can reach

The subscription form itself is short. In the Sweego dashboard you create a webhook, give it a name, paste the endpoint URL, tick the events you want it to fire on, and optionally restrict it to specific sending domains. The secret is generated alongside it and shown on the same screen. Nothing else about that URL is configurable. Whatever you paste there is where every event goes until you go back and change it, and Sweego will keep posting to it whether or not anything is listening.

Sweego’s setup guide suggests request-inspection tools such as webhook.site or Beeceptor for a first look at the payloads, and they work for that. Send a test email and the JSON shows up in a browser tab. The problems start once you develop against the events instead of just looking at them: the events live in a browser tab, retention is short, nothing verifies the signature, and every payload, recipient address included, is sitting on a third party’s server you have not vetted. Recipient email addresses and phone numbers are [personal data under GDPR](https://webhooker.eu/blog/are-webhooks-personal-data-gdpr), so where the test payloads go becomes a compliance question once production traffic starts flowing.

The alternative is to give Sweego a permanent address that stores what it receives. With Webhooker that is a source: create one in the dashboard, copy its ingest URL of the form `https://app.webhooker.eu/in/<token>`, and paste that into the Sweego webhook’s endpoint field. From then on every event Sweego sends is written to durable storage in the EU, and delivered to whichever destinations you attach to the source, with retries when a destination is down. The [quick start](https://docs.webhooker.eu/guides/quickstart/) walks through it in four steps; there is nothing to install. Webhooker won’t check the Sweego signature for you, though. Its generic HMAC method can’t handle a signed id plus a base64 secret ([inbound verification](https://docs.webhooker.eu/receive/verification/) lists what it supports), so keep the check from the section above in your own handler.

For testing, the useful property is that the ingest URL never changes. You can tear your local environment down, rebuild staging or rotate a tunnel while Sweego keeps posting to the same address, and events sent in the meantime are already stored instead of being posted to an endpoint that no longer exists.

## A local workflow that does not resend anything

Here is the loop we use. It assumes one Webhooker source per environment, which keeps local, staging and production events from mixing, and assigning a colour to each source makes the dashboard readable at a glance when the three are side by side.

1. **Trigger each event type once.** Send a real email through Sweego to an address you control, click the tracking link, reply to it if you are testing inbound routing, and send one SMS. Every event lands in the source and shows up in the live tail with its full payload and headers.
2. **Write the handler against the stored events.** You now have a real `hard_bounce`, a real `email_inbound` with an attachment, a real `sms_undelivered`. Read them from the dashboard and shape your parsing around what arrived, using the docs as a cross-check.
3. **Attach a destination and replay.** When the handler is ready, add a destination pointing at your local endpoint through a tunnel, or straight at staging, and replay the stored events into it. Every replay carries the same payload and the same `X-Webhooker-Event-Id`, so you can iterate on the handler as many times as you like without sending another message.
4. **Break it on purpose.** Return a 500 from the handler and watch Webhooker [retry with backoff](https://docs.webhooker.eu/deliver/retries-and-replay/) and then park the delivery in the dead-letter queue. Fix the handler, replay from the DLQ, confirm it drains.

Step 3 saves the most. SMS events cost money to trigger, inbound events need a human to send an email, and bounces need an address that will bounce. Producing each of them once and then replaying is faster and cheaper than producing them again on every code change. It also leaves you with a fixture library of captured payloads, including field-name quirks a hand-written fixture would miss.

## Retries, ordering and idempotency

Sweego documents its inbound-routing retry policy explicitly: if your endpoint is unavailable it retries every five minutes, up to twenty times, which covers roughly an hour and forty minutes of downtime. That window is reasonable, but nothing extends it. A deploy that goes wrong on a Friday evening and is fixed on Saturday morning has outlived it, and the inbound email is gone from Sweego’s side. A gateway that accepted the event during those hours and still holds it for delivery lets you replay it once the fix ships.

Three habits keep the handler correct whichever way events reach it:

- **Dedupe on `event_id`.** Sweego’s `event_id` is a UUID per event and Webhooker’s `X-Webhooker-Event-Id` is stable across every attempt for the same delivery. Either is a fine [idempotency key](https://webhooker.eu/blog/webhook-idempotency-keys). Retries, replays and the occasional duplicate are all normal at-least-once behaviour, and a handler that keys on the id turns all of them into no-ops.
- **Acknowledge fast, process later.** Return 200 as soon as the event is persisted on your side, then do the processing from a queue. Slow handlers are the first thing to time out during a retry storm.
- **Do not assume order.** `delivered` can plausibly arrive before `email_sent` has been processed if the first attempt of one was retried. Treat each event as a fact about the message keyed on `transaction_id`, and let your state logic accept them in any order. If you do need FIFO, that is a [separate design decision](https://webhooker.eu/blog/ordered-webhook-delivery) with its own costs.

## Where the data lives

Every one of these payloads carries a recipient. Email events have the address in `recipient`, SMS events carry `phone_number` alongside the network codes that identify the carrier, inbound events carry the sender’s name and address plus the message body. Your webhook layer, whatever it is, sees all of it before your database does, which makes that layer part of your data-processing footprint.

If you plan to redact, note that on email events the address appears twice. Once in `recipient`, and again inside the `details` log line, along with the sending IP and the receiving MX host. A scrubber that clears `recipient` and calls it done leaves the address sitting in a string field, where nothing will flag it and everything will keep it.

A stack that chose Sweego partly because its datacenters are in France loses that property if the events pass through a US-hosted inspection tool, or through a gateway whose database sits in an EU region while its workers run elsewhere. Check where ingest, storage, delivery and backups each run, since any one of them outside the EU puts the recipient’s address outside it too. Webhooker runs all four in the EU, with a DPA available on paid plans. The next read is [why EU-only ingest, storage and delivery matter under GDPR](https://webhooker.eu/blog/eu-hosted-webhook-infrastructure), which covers what to check in every hop of that path.

## Frequently asked questions

### What secret do I use to verify a Sweego webhook?

The webhook secret shown in the Sweego dashboard for that specific webhook, a 64-character base64 string. Decode it from base64 and use the resulting bytes as the HMAC-SHA256 key. Using the string itself as the key is the most common reason a correct implementation still reports an invalid signature. The signed content is `webhook-id`, `webhook-timestamp` and the raw body joined with dots, and the header value to compare against is base64.

### Does Sweego retry failed webhooks?

For inbound email routing, yes: every five minutes, up to twenty attempts, roughly an hour and forty minutes of coverage. Check Sweego’s current documentation for the policy on sending events, since retry windows are the provider’s terms and can change. Whatever the window is, it ends, and an endpoint that was down for longer than that has lost the event. Placing a gateway in front that accepts immediately and retries on its own schedule removes the dependency on the provider’s window.

### Can I test SMS webhooks without paying for SMS?

Not from Sweego’s side, since an `sms_sent` event needs a sent SMS, and Sweego’s SMS payloads carry a `test_mode` flag precisely so you can tell test sends apart. What you can avoid is paying for the second, third and tenth test. Send one SMS, capture the resulting events in a Webhooker source, and replay them against your handler as often as you need. The payload, headers and event id are identical on every replay, so the handler cannot tell the difference and you never trigger another send.

### Where do I set the webhook URL in Sweego?

In the Sweego dashboard, under the webhooks section of your account settings. Create a webhook, name it, paste the endpoint URL, tick the events it should fire on, and optionally limit it to specific sending domains. The signing secret is generated with it and shown on the same screen. There is one URL per webhook and no fallback address, so the URL you paste needs to outlive your local environment. That is the argument for pointing it at a permanent ingest URL rather than at a tunnel that changes.

### How do I tell a soft bounce from a hard bounce?

Read `response_code`, not just `event_type`. Both `soft-bounce` and `hard_bounce` payloads carry the receiving server’s SMTP reply code, and the `details` field carries the full log line with the refusal text. A 452 is a full mailbox and is worth retrying later; a 550 is an invalid recipient and the address should go on your suppression list. Handling both events identically is the most common way a bounce pipeline goes wrong: either invalid addresses keep getting mail and sender reputation drops, or recoverable addresses get suppressed for good.

### Do Sweego email and SMS webhooks use the same payload format?

No. They share `event_type`, `timestamp`, `event_id`, `channel` and `swg_uid`, and diverge after that. Email events carry `recipient`, `domain_from`, `details` and a `headers` object echoing the message headers you sent. SMS events carry `phone_number`, `country_code`, `sender_id`, `sms_type`, `sms_price`, `nb_segments` and the mobile network codes, with no `headers` object at all. Write the two handlers separately and branch on `channel`.

### Why did an email_opened event fire when nobody opened the email?

Because a proxy fetched the tracking pixel. Apple Mail Privacy Protection and Google’s image proxy load remote images before the recipient sees the message, and Sweego marks it: the `click` object on tracking events carries a `proxy` boolean. A `proxy: true` open still confirms the message reached an inbox, but it says nothing about a human. Filter on that flag before an open count feeds anything that makes decisions.
