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.
What happens
- Alex clicks Unsubscribe in an onboarding email (or uses the one-click unsubscribe in their mail app).
- We stop sending Alex that type of email immediately — nothing depends on your server for that.
- We send your webhook endpoint a signed
email_preferences.updatedfor Alex's user id and the category they left (e.g.onboarding). - 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
- An HTTPS endpoint on your server that accepts
POST— public, port 443, answering directly (redirects aren't followed). - Verification of every request with YouGrow's public keys (direction
webhook) — how. Reject anything that doesn't verify with401. - A
2xxreply within 5 seconds. Do slow work afterwards. - 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
| Field | Type | Notes |
|---|---|---|
| email_preferences.updated | Someone changed an email preference from one of our emails. Mirror it in your product. | |
| email.suppressed | We stopped emailing someone: their address hard-bounced, or they reported one of our emails as spam. | |
| connection.test | Sent 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
| Field | Type | Notes |
|---|---|---|
| userId | string | Your user id, exactly as you use it in the API. Payloads never contain an email address. |
| category | string | The type of email they left — the journey's unsubscribe category, e.g. onboarding. Map it to your own setting. |
| subscribed | boolean | Always false today (an unsubscribe). |
| scope | string | category: 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. |
| source | string | list-unsubscribe (the one-click button in their mail app) or preferences-page (the link in our footer). For your records. |
email.suppressed — data
| Field | Type | Notes |
|---|---|---|
| userId | string | Your user id. |
| reason | string | hard_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. |
Your consent records
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:
| Webhook | Value | In your records |
|---|---|---|
| email_preferences.updated | scope: all | They asked to stop every email from you: record a withdrawal of marketing consent. |
| email_preferences.updated | scope: category | They 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.suppressed | reason: complaint | They reported one of our emails as spam, an objection: record a withdrawal of marketing consent. |
| email.suppressed | reason: hard_bounce | The address doesn't work. That's not a consent choice: record nothing, and fix the address if you can. |
| connection.test | Nothing 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: truecan't lift it. - Writes through the API never trigger a webhook, so the echo can't loop.
A complete example (Node)
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'sjti). 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.