2026-09-08 · Updated 2026-09-10 · 11 min read · CanWeChat Engineering
Webhooks without trusting the checkout redirect
Billing state comes from Dodo Payments events, not a success URL. The same rule applies to Slack, Discord, and outbound webhook relays.
A customer can close the tab after paying. A mobile OS can kill the WebView before your success page runs analytics. A replayed Slack event can arrive twice. Those are not edge cases—they are the job description for any system that mutates entitlement or messages.
This post is for engineers wiring billing or chat integrations who still see “redirect to /thanks?session=…” treated as a source of truth. You will leave with a concrete pattern: verify provider webhooks, store event IDs, and treat browser redirects as UX only.
Quick answer
- Never grant Pro access from a checkout success URL query string.
- CanWeChat entitlement updates only from verified Dodo Payments webhooks (Standard Webhooks signatures).
- Slack and Discord inbound events are signature-checked with Web Crypto before message writes.
- Outbound customer webhooks are HMAC-SHA256 signed over timestamp + raw body.
- Idempotency is mandatory: provider event IDs are recorded before side effects.
Why redirects lie (even when payments succeed)
Checkout redirects optimize for human reassurance: “you paid, here is a thank-you page.” They are terrible for machine state. The browser may never hit your URL. A user may bookmark a success page and reload it. An attacker may forge a query parameter if you are careless. Even a legitimate success page race can fire before the processor’s webhook arrives—or after a refund webhook already reversed access.
Billing path: Dodo webhooks only
New workspaces get a 3-day free trial. Pro is $19/month for one site and one active reply destination. Checkout sessions and the customer portal are created server-side. When Dodo sends subscription lifecycle events, we verify the signature, dedupe on the provider event ID, then write entitlement in D1.
- Create checkout or portal session on the Worker with server credentials.
- Send the customer to Dodo’s hosted flow.
- Ignore “success” query params for access control.
- On webhook: verify signature → insert event ID → update subscription state → refresh feature gates.
- If the webhook never arrives, support investigates from processor logs—not from a guessed redirect.
Same rule for Slack and Discord callbacks
Reply destinations are first-class in CanWeChat, but they are not trusted because a request “came from Slack’s IP range.” Inbound Slack requests carry a timestamped HMAC. Discord interactions and events are verified the same way conceptually: cryptographic proof first, database write second. Replay windows and duplicate deliveries are expected; your handler must be safe to run twice.
Threat model in one page
Assume the browser is hostile and unreliable. Assume network paths replay. Assume your success URL will be crawled, bookmarked, and shared in screenshots. Do not assume “it came from our checkout domain” means the payment cleared—only the processor’s signed event means that.
- Attacker forges ?paid=1 on a thank-you page → must not unlock Pro.
- Customer pays, closes laptop → webhook still activates entitlement.
- Slack retries the same event → second write must no-op.
- Leaked webhook secret → rotate in dashboard and receiver; old signatures fail closed.
Outbound webhook relay to your backend
When you configure a customer webhook URL in the dashboard, CanWeChat posts signed events such as conversation.created and message.created. Headers include X-CanWeChat-Id, X-CanWeChat-Timestamp, and X-CanWeChat-Signature: v1=<hex>. The signed string is `${timestamp}.${rawBody}` using HMAC-SHA256 with the relay secret shown once at creation or rotation.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyCanWeChatWebhook(rawBody, timestamp, signatureHeader, secret) {
const ageMs = Math.abs(Date.now() - Number(timestamp) * 1000);
if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) {
return false; // reject stale timestamps
}
const expected =
"v1=" +
createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
// Express-style sketch
app.post("/hooks/canwechat", express.raw({ type: "*/*" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
const ok = verifyCanWeChatWebhook(
rawBody,
req.header("X-CanWeChat-Timestamp"),
req.header("X-CanWeChat-Signature"),
process.env.CANWECHAT_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).end();
const event = JSON.parse(rawBody);
await db.events.upsert({ where: { id: event.id }, create: event, update: {} });
// process side effects only after upsert succeeds
res.status(204).end();
});Example payload shape
{
"id": "evt_01J7...",
"type": "message.created",
"createdAt": "2026-09-10T09:20:00.000Z",
"conversation": {
"id": "conv_01J7...",
"status": "open",
"pageUrl": "https://example.com/pricing"
},
"visitor": {
"id": "vis_01J7...",
"name": "Ada Lovelace",
"email": "ada@example.com"
},
"message": {
"id": "msg_01J7...",
"senderType": "visitor",
"source": "widget",
"body": "Do you support idempotency keys?"
}
}Events include conversation IDs, visitor identity when provided, page context, message source, and timestamps. They intentionally omit integration tokens and auth secrets—your CRM does not need the Slack bot token to sync a note.
Failure modes and how to debug them
- Entitlement stuck on trial after payment: check Dodo webhook delivery and signature verification first; do not “force Pro” from a success page.
- Duplicate CRM rows: you processed before storing X-CanWeChat-Id / event.id.
- 401 storms on your endpoint: secret rotation without updating the receiver, or verifying a parsed JSON string instead of the raw body.
- Missed Slack replies: signing secret saved after URL verification, or timestamp skew outside tolerance.
Integration checklist
- Verify signatures with a timing-safe compare on the exact raw body bytes.
- Reject old timestamps.
- Upsert on event ID before any non-idempotent side effect.
- Return 2xx only after durable storage acknowledges the event.
- Keep redirects as UX; keep webhooks as state.
- Rotate secrets in the dashboard and receiver in the same change window.
The CanWeChat angle
We run on Cloudflare Workers with D1 for durable records and queues for delivery retries. That stack makes “wait for the webhook” practical: the visitor widget stays light, while billing and relay work happen where secrets already live. Public docs cover verification; managing the relay URL stays in the authenticated dashboard—not a public REST partner API.
Limitations
- Webhook delivery depends on your endpoint availability; we retry from a queue, but we do not invent a public uptime SLA here.
- We are not a general payments orchestration platform—only CanWeChat subscription entitlement.
- No fake compliance badges: judge the signing and domain-lock design on /security and in docs.
Next step
Continue with the most relevant CanWeChat guide for this topic.