This guide makes access to a Discord server, a forum or any space with roles follow a Davi membership: join a tier and the role appears, leave or lapse and the role goes away. You need an application that uses Sign in with Davi and an endpoint that receives webhooks.
The Davi side is the same whatever you gate, and steps 1 to 4 cover it. The platform side differs; a worked Discord example follows at the end.
Davi membership changes → webhook → your service → platform role
↕
link table: davi user ↔ platform account
1. Link the accounts
Neither side knows which platform account belongs to which Davi user, so your service keeps a link table.
Link platform first, Davi second: a member starts in your community, clicks a link, and signs in with Davi. Store the pairing when they return.
Store sub from the id_token, not the username. Usernames change and can
be reused, so a table keyed on one will eventually grant a role to the wrong
person.
Linking the other way, starting from Davi and asking for a platform handle, trusts somebody to type an identifier for an account they may not own. Do not make it the only path.
2. Decide what grants access
- Gate on the tier if you have a single paid tier and no plans to add more.
- Gate on an entitlement key otherwise. Add a key like
community.accessto every tier that should grant it and check for that key. A new tier then needs only the key, not a code change.
If the rule is "any active member", check the reserved member key. Every active
member holds it, and it is included when you
read a member's entitlements.
3. React to membership events
Subscribe your endpoint to the membership events:
| Event | Do |
|---|---|
membership.joined | Grant the role |
membership.renewed | Nothing: they already have it |
membership.left | Revoke the role |
A membership whose term runs out sends no event, including one that served out a cancellation. The daily reconcile in step 4 is what revokes a lapsed member.
Deliveries retry, so the same event can arrive twice. Keep the
handler idempotent: treating membership.renewed as a grant is also safe.
4. Reconcile daily
Webhooks are the fast path, not the source of truth. Deliveries retry, arrive out of order, and are missed entirely if your endpoint is down long enough, and a lapsed term sends none at all. Without the reconcile, somebody who stopped paying keeps the role. Build the reconcile before any polish.
Run it against GET /organizations/{organization_slug}/memberships, which lists
the organization's members and the tier each holds. Compare the list to the roles
you have granted and fix the difference in both directions.
- Page to the end. The list is paginated. A reconcile that stops after the
first page treats everybody after member 20 as having no membership, and
revokes them. Keep going while
current_pageis belowtotal_pages. - Treat anything not
activeas no access. A membership isactive,expiredorcancelled, and onlyactivegrants entitlements, includingmember.
Once a day is usually enough.
Worked example: a Discord role
This section implements the platform side for Discord. You need a bot in the guild with Manage Roles, its token, the guild id, and the id of the role you are granting.
Call Discord, with rate limits handled
Discord rate-limits per route and answers 429 with a retry_after in seconds.
A reconcile over a few thousand members will hit it, so handle it in one place:
const DISCORD = "https://discord.com/api/v10";
async function discord(path: string, init: RequestInit = {}): Promise<Response> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${DISCORD}${path}`, {
...init,
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`,
...init.headers,
},
});
if (res.status !== 429) return res;
const { retry_after } = await res.json(); // seconds, may be fractional
await new Promise((r) => setTimeout(r, retry_after * 1000));
}
throw new Error("Discord rate limit did not clear");
}
const rolePath = (userId: string) =>
`/guilds/${process.env.GUILD_ID}/members/${userId}/roles/${process.env.ROLE_ID}`;
export async function addRole(userId: string) {
const res = await discord(rolePath(userId), { method: "PUT" });
// 404: the member is not in the guild yet — grant again when they arrive.
if (!res.ok && res.status !== 404) {
throw new Error(`Discord addRole failed: ${res.status} ${await res.text()}`);
}
}
export async function removeRole(userId: string) {
const res = await discord(rolePath(userId), { method: "DELETE" });
// 404 means they are already gone, or never had it. Either way, done.
if (!res.ok && res.status !== 404) {
throw new Error(`Discord removeRole failed: ${res.status} ${await res.text()}`);
}
}
PUT and DELETE on a role are both idempotent, so retried deliveries need no
extra handling.
Handle the webhook
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
// Davi keys the HMAC with the SHA-256 hex digest of the signing secret.
const signingKey = createHash("sha256")
.update(process.env.DAVI_WEBHOOK_SECRET!)
.digest("hex");
function verify(rawBody: string, signature: string): boolean {
const expected =
"sha256=" + createHmac("sha256", signingKey).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
export async function handleDaviWebhook(rawBody: string, headers: Headers) {
if (!verify(rawBody, headers.get("x-webhook-signature") ?? "")) {
return new Response("bad signature", { status: 401 });
}
// The member is in `data`; the envelope's own user_uuid is always null.
const { event, data } = JSON.parse(rawBody);
const link = await db.findLinkByDaviSub(data.user_uuid);
// Not linked yet is a normal state, not an error — 2xx so Davi stops retrying.
if (!link) return new Response(null, { status: 204 });
switch (event) {
case "membership.joined":
await addRole(link.discordUserId);
break;
case "membership.left":
await removeRole(link.discordUserId);
break;
// membership.renewed: they already hold the role.
}
return new Response(null, { status: 204 });
}
Verify over the raw body, before any JSON parsing. Re-serializing produces different bytes and the signature will not match.
Grant on guild join too
A member can join a tier before they enter your server, and then the grant at
membership.joined finds no guild member. Catch them on arrival with the
GUILD_MEMBER_ADD gateway event:
client.on("guildMemberAdd", async (member) => {
const link = await db.findLinkByDiscordId(member.id);
if (link && (await daviMembershipIsActive(link.daviSub))) {
await addRole(member.id);
}
});
Both paths make the same idempotent PUT, so either order, or both, is safe.
Link the accounts
Send the member through both sign-ins in one flow. Discord's OAuth with the
identify scope gives their user id; Davi's gives sub:
// Step 1 — Discord, to learn who they are there.
const discordUser = await (
await fetch(`${DISCORD}/users/@me`, {
headers: { Authorization: `Bearer ${discordAccessToken}` },
})
).json(); // { id: "80351110224678912", ... }
// Step 2 — Davi, to learn who they are here. See Sign in with Davi.
const { sub } = decodedIdToken;
await db.saveLink({ discordUserId: discordUser.id, daviSub: sub });
Both identifiers are stable and neither is a display name, so the row survives a rename on either side.
Place the bot's role above the role it grants
Discord refuses to assign a role positioned above the bot's own, even with
Manage Roles, and answers 403 Missing Permissions without mentioning
hierarchy. Drag the bot's role higher in Server Settings → Roles.
Next
- Building a Membership Program: the tiers and entitlements behind this.
- Receiving Webhooks: verifying and handling deliveries.
- Sign in with Davi: linking the two accounts.
- Discord developer documentation: gateway events, permissions, and the rest of their API.