Webhooks

When someone unsubscribes from one of our emails, or we stop emailing them because their address bounced or they reported spam, YouGrow tells your server — so your product's own records say the same thing, and your other emails respect it too.

Compliance. Without a webhook, our own unsubscribe links still work — we stop sending straight away. But your product won't know, so its settings page shows the person as subscribed, and emails you send yourself keep going to someone who opted out.

What happens

  1. Alex clicks Unsubscribe in an onboarding email (or uses the one-click unsubscribe in their mail app).
  2. We stop sending Alex that type of email immediately — nothing depends on your server for that.
  3. We send your webhook endpoint a signed email_preferences.updated for Alex's user id and the category they left (e.g. onboarding).
  4. Your server turns off the matching setting on Alex's account, and replies 200.

It works the other way too: when someone opts out in your product, send "subscribed": false in their state.

What you need in place

  1. An HTTPS endpoint on your server that accepts POST — public, port 443, answering directly (redirects aren't followed).
  2. Verification of every request with YouGrow's public keys (direction webhook) — how. Reject anything that doesn't verify with 401.
  3. A 2xx reply within 5 seconds. Do slow work afterwards.
  4. The URL saved in your connection's Settings → Webhook endpoint, then press Test webhook.

The request

POST https://api.your-app.com/yougrow/webhook
Content-Type: application/json
Authorization: Bearer <JWT signed by YouGrow>
X-YouGrow-Key-Id: <your key id>

{
  "id": "wh_7c1d…",
  "type": "email_preferences.updated",
  "createdAt": "2026-09-23T10:00:00Z",
  "data": {
    "userId": "user_123",
    "category": "onboarding",
    "subscribed": false,
    "scope": "category",
    "source": "list-unsubscribe"
  }
}

Types

FieldTypeNotes
email_preferences.updatedSomeone changed an email preference from one of our emails. Mirror it in your product.
email.suppressedWe stopped emailing someone: their address hard-bounced, or they reported one of our emails as spam.
connection.testSent by Test webhook in YouGrow. Verify it and reply 2xx; there's nothing to change.

More types may be added. Reply 2xx to any type you don't handle, so it isn't retried.

email_preferences.updated — data

FieldTypeNotes
userIdstringYour user id, exactly as you use it in the API. Payloads never contain an email address.
categorystringThe type of email they left — the journey's unsubscribe category, e.g. onboarding. Map it to your own setting.
subscribedbooleanAlways false today (an unsubscribe).
scopestringcategory: just this type of email. all: they chose to stop every email from you on our preferences page — turn off all your marketing email for them.
sourcestringlist-unsubscribe (the one-click button in their mail app) or preferences-page (the link in our footer). For your records.

email.suppressed — data

FieldTypeNotes
userIdstringYour user id.
reasonstringhard_bounce: the address doesn't exist or refuses mail. complaint: they reported one of our emails as spam — treat it as an objection to marketing email.

Record what we send in your own consent records. If your product keeps one marketing consent rather than a setting per type of email, this is what each webhook means for it:

WebhookValueIn your records
email_preferences.updatedscope: allThey asked to stop every email from you: record a withdrawal of marketing consent.
email_preferences.updatedscope: categoryThey left one type of email: turn off that setting if you have one. Don't record it as a withdrawal of all marketing — they didn't make that choice, and we keep enforcing this one.
email.suppressedreason: complaintThey reported one of our emails as spam, an objection: record a withdrawal of marketing consent.
email.suppressedreason: hard_bounceThe address doesn't work. That's not a consent choice: record nothing, and fix the address if you can.
connection.testNothing to record.

Date it with the webhook's createdAt. If the person changed their consent in your product after that, keep their newer choice. Then let your usual sync send the user's state back to us. That echo is harmless:

  • An opt-out made in one of our emails holds whatever the API sends: subscribed: true can't lift it.
  • Writes through the API never trigger a webhook, so the echo can't loop.

A complete example (Node)

Express
import express from "express";
import { createVerifier, type WebhookEvent } from "@yougrowai/node/server";

const verifier = createVerifier({ keyId: process.env.YOUGROW_KEY_ID! });
const app = express();

// Map our categories to your own email settings.
const SETTING = { onboarding: "productTips" } as const;

// Verify against the RAW body — parse only after it checks out.
app.post("/yougrow/webhook", express.raw({ type: "application/json", limit: "16kb" }), async (req, res) => {
  const rawBody = req.body.toString("utf8");
  const v = await verifier.verify({ headers: req.headers, rawBody, direction: "webhook" });
  if (!v.ok) return res.status(401).json({ error: v.reason });

  const hook = JSON.parse(rawBody) as WebhookEvent;
  if (await db.webhooksSeen.has(hook.id)) return res.status(200).end();    // a retry we already handled

  if (hook.type === "email_preferences.updated") {
    const { userId, category, subscribed, scope } = hook.data;
    if (scope === "all") {
      await db.users.update(userId, { marketingEmail: false });
    } else if (category in SETTING) {
      await db.users.update(userId, { [SETTING[category as keyof typeof SETTING]]: subscribed });
    }
  } else if (hook.type === "email.suppressed" && hook.data.reason === "complaint") {
    await db.users.update(hook.data.userId, { marketingEmail: false });      // a spam report is an objection
  }
  await db.webhooksSeen.add(hook.id);                                        // keep ids for a few days
  res.status(200).end();                                                     // any other type: 2xx, nothing to do
});

Not using Node? Any JWT library works — the checks are in verifying our requests.

Delivery and retries

  • A reply that isn't 2xx, or no reply within 5 seconds, is retried: after 1 minute, then 2, 4, 8 … up to every 6 hours, for 24 hours. After that it's dropped — the unsubscribe itself still stands on our side.
  • A retry carries the same id (also the token's jti). If your server did the work but the reply got lost, you'll see it again — skip ids you've handled. Setting a preference to the same value twice is harmless anyway.
  • A serverless endpoint that's slow to start is fine: the retry a minute later finds it running. Your handler may then see an id it already handled, if it finished the first time after we stopped waiting.
  • Test webhook in your connection's Settings sends one straight away and shows what your server replied. If the first try gets no reply within 5 seconds, it tries once more, as a delivery would.
Your connection secret isn't involved: requests are signed with YouGrow's key and verified with its public keys, so nothing you store can be used to forge them.