← All articles

Sweego Webhooks Guide: Events, Signature and Replay

Webhooker Team 10 min read
Flat illustration of webhook replay: one envelope enters a blue pulse node, and a looping arrow fans identical copies of it back out to a laptop.

Sweego signs every webhook 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 three things: a stable URL that Sweego can reach even while your laptop is closed, a handler that verifies that signature against the raw body, and a way to replay the same events against new code without sending another email or another paid SMS. This guide covers all three.

A disclosure first. Webhooker’s own transactional email, the sign-up confirmations and delivery alerts, goes out through Sweego, 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, which is how the two products ended up in the same sentence.

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. SMS payloads carry a test_mode boolean, which is worth reading 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.

Every payload shares an envelope: event_type, timestamp, event_id (a UUID), swg_uid, channel and, for sent messages, transaction_id. The event_id is the one to store, and we will come back to why. The full field lists are in Sweego’s email payload reference; you configure the subscription itself under your account settings by naming the webhook, pasting an endpoint URL, ticking the events you want, and optionally restricting it to specific sending domains.

Verifying the signature

Three headers ride along with every request:

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

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);

The timestamp tolerance is not in Sweego’s reference implementation, and it is optional, but a valid signature on a request captured yesterday is still a valid signature. Checking the age of webhook-timestamp is what turns the signature into a defence against replay attacks as well as forgery. Five minutes is a common window.

If verification fails and you are sure of the secret, the cause is almost always one of the six usual suspects: 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

Sweego’s setup guide suggests request-inspection tools such as webhook.site or Beeceptor for a first look at the payloads, and for that job they are fine. Fire a test email, watch the JSON appear, learn the shape. They stop being fine the moment you want to develop against the events rather than look 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, so where the test payloads go is not a formality once real 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://webhooker.eu/in/<token>, and paste that into the Sweego webhook’s endpoint field. From then on every event Sweego sends is verified on the way in, 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 walks through it in four steps; there is nothing to install.

The point of this arrangement for testing is that the ingest URL never changes. You can tear your local environment down, rebuild staging, rotate a tunnel, and Sweego keeps posting to the same address. Nothing arrives at an endpoint that is not there.

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 them rather than around the docs.
  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 and then park the delivery in the dead-letter queue. Fix the handler, replay from the DLQ, confirm it drains.

Step 3 is the one that pays for itself. SMS events cost money to trigger, inbound events need a human to send an email, and bounces need an address that will actually bounce. Producing each of them once and then replaying is faster and cheaper than producing them again on every code change. It also gives you a fixture library of real payloads, which is worth more than anything you could hand-write.

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 is a reasonable window and it is also the whole window. 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 is still holding it for delivery is the difference between a replay and an apology.

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

Where the data lives

Every one of these payloads carries a recipient. Email events have the address in recipient, SMS events carry phone_number, 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.

For a stack that chose Sweego partly because its datacenters are in France, routing those events through a US-hosted inspection tool or a gateway with an EU region for the database and workers elsewhere is a quiet way to undo the choice. Webhooker keeps ingest, storage, delivery workers and backups inside the EU, with a DPA available on paid plans. Sweego in, Webhooker in the middle, your service at the end: the recipient’s address never leaves the EU on the way through.

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.