Documentation

Gating a Members-Only Community

Grant and revoke a role in a Discord server, forum or private space from Davi membership events, and reconcile it daily.

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

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.access to 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:

EventDo
membership.joinedGrant the role
membership.renewedNothing: they already have it
membership.leftRevoke 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_page is below total_pages.
  • Treat anything not active as no access. A membership is active, expired or cancelled, and only active grants entitlements, including member.

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.

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