Web Ad API

Serve targeted ads on your Discord-project website. Requires Discord OAuth so every visitor has a Discord user ID. Earn on every confirmed view, click, join, and stay. Two endpoints, plain JSON, no SDK.

Every request is authenticated via an Authorization: Bearer <api_key> header. Your web API key comes from the dashboard — you register it with your site domain (e.g. example.com). Web keys are separate from bot keys and are required for all /v1/web/ routes.


        

Your website uses Discord OAuth to authenticate visitors. When a visitor loads a page, your backend calls our two endpoints to request and confirm an ad. Your frontend renders the ad banner. Our API handles targeting, billing, and payout. No Discord user ID = no ad served.

Always call this API from your server-side backend. Never expose your API key in browser JavaScript.

Standard payout is 35% of what the advertiser pays per event. Send optional server enrichment data (servers[] — name, description, user roles from servers your bot is in where the visitor is also a member) and earn 45%. Payout applies to views, clicks, joins, and stays — whatever campaign type the ad uses.

Authentication

All requests must include an Authorization header with your web API key as a Bearer token. Your web API key is registered with your site domain in the dashboard — it is separate from your bot API key.

Authorization: Bearer YOUR_WEB_API_KEY
Content-Type: application/json
FieldWhereDescription
AuthorizationHeaderBearer <web_api_key>. Your web API key from the dashboard. Never expose this in browser JavaScript — call the API from your server-side backend only.
user_idBodyThe visiting user's Discord ID, obtained from your Discord OAuth session. Required on every request.
Missing or invalid Authorization header returns HTTP 401. Suspended key returns 403 with "error":"key_not_active". Using a bot key on a /v1/web/ endpoint returns 403 wrong_key_type.

The 2-step flow

1. web/ads/requestGet ad + view token
Render adShow banner on page
2. web/ads/confirmRecord delivery, earn
Only call web/ads/confirm after the ad is actually visible on screen. If rendering fails, do not confirm — no billing occurs.
House ads: Sometimes ad is present but view_token is null. This is a Cordvertise promotional ad. Render it normally — but do not call web/ads/confirm. Check view_token !== null before confirming.

Integration snippets

  • Node.js
  • TypeScript (Node.js)
  • Python
  • PHP
  • Go
  • Rust

Drop-in cvServeAd + cvConfirmAd helpers for your backend. API key and base URL are pre-filled if you arrived from the dashboard.

Backend only. Both Cordvertise API calls happen on your server. Your backend requests the ad and passes the result to your frontend (via template variables, JSON response, or session). The frontend renders the banner, then posts back to your own confirm route, which calls cvConfirmAd. Never call the Cordvertise API from browser JS.

        

The helpers above handle the Cordvertise calls. Here's how you connect them to your actual routes and frontend:

// ── 1. Page route — call cvServeAd and pass the result to your template ──────
app.get('/feed', requireDiscordAuth, async (req, res) => {
  const adData = await cvServeAd(req);

  res.render('feed', {
    adBannerUrl:  adData?.ad?.banner_url ?? null,
    adClickUrl:   adData?.ad?.join_url ?? adData?.ad?.button_url ?? null,
    adColor:      adData?.ad?.embed_color ?? null,
    adLabel:      adData?.ad?.button_name ?? null,
    viewToken:    adData?.view_token ?? null,   // null = house ad or no ad
    referralUrl:  adData?.referral_url ?? null, // for "Ads by Cordvertise" link
  });
});

// ── 2. Confirm route — your frontend posts here after the banner renders ──────
app.post('/cv-confirm', requireDiscordAuth, async (req, res) => {
  const { viewToken } = req.body;
  if (!viewToken) return res.json({ ok: true });  // house ad or no ad — skip
  await cvConfirmAd(viewToken, req.session.discordUserId);
  res.json({ ok: true });
});

// ── 3. In your page template — fire confirm once banner is 50% visible ────────
// Render the widget (see "Ad widget" section below) then observe it:
//
// const widget = document.getElementById('cv-ad');
// const token  = widget?.dataset.viewToken;
// if (widget && token) {
//   new IntersectionObserver(([e], obs) => {
//     if (!e.isIntersecting) return;
//     obs.disconnect();
//     fetch('/cv-confirm', {
//       method: 'POST',
//       headers: { 'Content-Type': 'application/json' },
//       body: JSON.stringify({ viewToken: token }),
//     });
//   }, { threshold: 0.5 }).observe(widget);
// }

Drop this into your template. Replace the <%= ... %> placeholders with your template variables. The banner is fully clickable — no separate button. Attribution link at the bottom earns you referral commission if a visitor signs up to Cordvertise.

<!-- Cordvertise ad widget — place anywhere in your page -->
<div id="cv-ad"
     data-view-token="<%= viewToken %>"
     style="
       display: inline-block;
       border-radius: 10px;
       overflow: hidden;
       border: 1px solid <%= adColor %>;
       background: #0d0d11;
       font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
       max-width: 400px;
       width: 100%;
     ">

  <!-- Clickable banner image -->
  <a href="<%= adClickUrl %>"
     target="_blank"
     rel="noopener"
     aria-label="<%= adLabel %>"
     style="display:block;line-height:0;">
    <img src="<%= adBannerUrl %>"
         alt="<%= adLabel %>"
         style="width:100%;display:block;border-radius:9px 9px 0 0;" />
  </a>

  <!-- Attribution bar -->
  <div style="
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 10px;
    background: #0d0d11;
  ">
    <span style="font-size:11px;font-weight:700;color:#7a8499;letter-spacing:.04em;">Ad</span>
    <a href="<%= referralUrl %>"
       target="_blank"
       rel="noopener"
       style="font-size:11px;color:#7a8499;text-decoration:none;"
       onmouseover="this.style.color='#00e5a0'"
       onmouseout="this.style.color='#7a8499'">
      Ads by Cordvertise
    </a>
  </div>
</div>
The widget uses inline styles so it works in any page without adding a stylesheet. If you want to customise it further, replace the inline styles with your own CSS classes — the structure stays the same.
Visitor loads pageBrowser → your server
cvServeAd()Your server → Cordvertise
Render templatead + viewToken → browser
Banner visibleIntersectionObserver fires
POST /cv-confirmBrowser → your server
cvConfirmAd()Your server → Cordvertise
POST/v1/web/ads/request

Returns an ad and a signed view token. Always check if ad is null before rendering anything.

HeaderValue
AuthorizationrequiredBearer YOUR_API_KEY
Content-Typerequiredapplication/json
FieldDescription
user_idrequiredDiscord user ID of the visitor, from your Discord OAuth flow. Must be accurate — used for targeting and fraud checks.
languagerequired2-letter ISO code for your website's preset language. e.g. "en", "de", "fr". Only ads matching this language are served — wrong value = missed revenue.
device_typerequiredDevice type inferred from the visitor's user agent. One of: "desktop", "mobile", "tablet".
iprequiredVisitor's real IP address. Used for country-level geo lookup. Pass the client IP, not your server's outbound IP.
serversoptionalArray of server context objects for servers your bot is in that the visitor is also a member of. Enables 45% payout tier. See enrichment section.
{
  "ok": true,
  "ad": {
    "ad_id": "42",
    "banner_url": "https://...",           // image — use as clickable banner
    "button_url": "https://.../c/TOKEN",   // tracking redirect (views/clicks) — wrap the banner in this
    "button_name": "Join Now",             // CTA label (use in tooltip / aria-label)
    "embed_color": "#6D5EFC",             // brand accent color — use for border/shadow
    "target_type": "views",               // "views" | "clicks" | "joins" | "stays"
    "join_url": "https://.../j/TOKEN"     // only present for joins/stays — use instead of button_url
  },
  "view_token": "eyJ...",                 // pass to web/ads/confirm
  "expires_in_seconds": 120,
  "referral_url": "https://cordvertise.com/r/YOURCODE"  // use in "Ads by Cordvertise" attribution link
}
For views and clicks campaigns, wrap the banner image in button_url. For joins and stays, use join_url instead — it is the tracked invite redirect that records server joins for billing.
{
  "ok": true,
  "ad": null,
  "view_token": null,
  "denied_reason": "user_daily_cap"   // see Errors section
}

When ad is null, skip silently — do not render anything.

House ads: Sometimes ad is present but view_token is null. This is a Cordvertise promotional ad. Render it normally — but do not call web/ads/confirm. There is nothing to confirm and no token to submit. Check view_token !== null before confirming.
POST/v1/web/ads/confirm

Records delivery and triggers billing. Call this after the ad banner is rendered and visible on screen. This is when your earnings are recorded. The view_token expires after expires_in_seconds.

FieldDescription
view_tokenrequiredToken from web/ads/request. Single-use, 120s TTL.
user_idrequiredDiscord user ID of the visitor. Used for fraud checks — must match the value sent in request.
// Billed
{ "ok": true, "billed": true, "ad_id": "uuid", "auto_paused": false, "daily_cap_reached": false }

// Not billed
{ "ok": true, "billed": false, "reason": "token_expired" }

auto_paused: true means the advertiser's balance hit zero on this confirm — the ad has been automatically paused. Informational only, no action needed. daily_cap_reached: true means the advertiser's daily spend limit was hit — same, informational only.

Campaign types

The ad.target_type field in the response tells you what kind of campaign was served. Your integration is the same for all types — request, render, confirm.

target_typeAdvertiser pays forYour action
viewsAd rendered to the userRender the banner. Confirm after it's visible. Billing happens on confirm.
clicksUser clicks the CTA buttonSame as views — render banner, confirm delivery. Billing happens when the user clicks button_url.
joinsUser joins the advertiser's Discord serverRender the banner. Use ad.join_url as the CTA link — it is a tracked invite redirect. Confirm after render. Billing happens when the user joins via that link.
staysUser joins and stays in the server for the required periodSame as joins — use ad.join_url. Billing happens automatically once the stay condition is met.
For joins and stays, always use ad.join_url (not button_url) as the CTA link. join_url is the tracked invite redirect that records server joins for billing.

Joins & stays

Join and stay campaigns pay when a visitor joins the advertiser's Discord server (and optionally stays for a set duration). Your integration only differs in which URL you use as the CTA — everything else is the same.

1. web/ads/requestGet ad + join_url
Render adCTA = join_url
2. web/ads/confirmRecord delivery
User clicksjoin_url redirect
User joins serverPayout automatic
For join/stay campaigns, ad.join_url is the tracking redirect (/j/TOKEN). Use it as your CTA button link instead of button_url. When the visitor clicks it, we record their Discord ID and redirect them to the server invite. Join and stay confirmation is handled entirely by our backend — your integration does nothing extra.

A stay campaign requires the user to remain in the server for a set duration before payout is recorded. This is tracked automatically by our backend after the join — no additional code or events needed from your side.

Server enrichment data — 45% payout

Standard payout is 35% of advertiser spend. If your bot is in servers the visitor is also a member of, you can forward that server context on each ad request and earn 45%.

Each entry in servers[] represents one server your bot has access to where the visitor is a member. You only need your own servers — you don't need to be in the visitor's other servers.

FieldTypeDescription
guild_idstringDiscord guild ID (snowflake).
guild_namestringServer name.
guild_descriptionstringServer description. Pass "" if none set.
guild_languagestringServer's Discord preset language (2-letter ISO code).
user_rolesstring[]Role names this visitor has in this server.
  • Node.js
  • TypeScript (Node.js)
  • Python
  • PHP
  • Go
  • Rust

        
The payout tier is locked at request time — you cannot claim 45% at confirm time if you didn't send servers[] at request time.

Server name, description, and user roles are passed through an AI classifier that infers interest and demographic signals — interests, language, activity level — based on server content and role names. These signals improve targeting accuracy, raise advertiser CPMs, and increase your earnings over time. Inference only runs once per user per server. The data is never sold and is only used for targeting within Cordvertise.

Privacy: Sending server enrichment data means transmitting per-user signal data to Cordvertise. Ensure your privacy policy covers this. If your users are in the EU/EEA, GDPR may apply. See our Privacy Policy for data retention and deletion details.

Errors & denials

StatuserrorMeaning & action
400bad_requestMissing or invalid fields.
400missing_user_iduser_id missing or not a valid Discord snowflake.
401missing_auth / invalid_keyAuthorization header missing or API key invalid.
403key_not_activeKey suspended — contact support.
403wrong_key_typeYou used a bot API key on a web endpoint, or vice versa. Web keys are required for all /v1/web/ routes.
403missing_scopeAPI key does not have ads.request scope.
403bad_signatureview_token tampered or malformed.
403token_identity_mismatchToken belongs to a different key or client.
429rate_limitedRequest rate exceeded. Check the Rate limits section. Back off and retry after retry_after_seconds if present.
500server_errorInternal error — retry with exponential backoff.
denied_reasonWhat to do
user_minute_capVisitor saw an ad <1 min ago — skip. Includes retry_after_seconds.
user_daily_capVisitor hit 10 ads/day — skip until UTC midnight.
partner_minute_capYour key hit its per-minute request cap — back off. Includes retry_after_seconds.
ads_disabledAd serving disabled for your key — contact support.
no_adsNo matching ads available right now — skip silently.
reasonMeaning
token_expiredToken TTL is 120 seconds from the web/ads/request response. If it expired before confirm, do not retry — the event is closed.
token_usedToken already confirmed. Same as already_confirmed — safe to ignore on retry.
already_confirmedSame view token already confirmed — idempotent, safe to ignore.
ad_unavailableAd paused between request and confirm.
account_too_newUser's Discord account is less than 30 days old — not billed.
advertiser_insufficient_fundsAdvertiser ran out of credits between request and confirm.
daily_limit_reachedAdvertiser's daily spend cap was hit before this confirm was processed.
free_ads_limitedFree-tier advertiser impression limit reached — not billed.

Rate limits & delivery caps

TierRequests / minRequirement
Probation100Default for new keys
Standard25050+ confirmed views in 30 days
Pro500250+ confirmed views in 30 days
Enterprise1000750+ confirmed views in 30 days
CapLimitScope
User per-minute1 ad / minPer (your key + user)
User per-day10 ads / UTC dayPer (your key + user)
The web API has no per-guild cap (unlike the bot API) since web visits don't have a guild context. The per-user caps above prevent any single visitor from seeing more than 1 ad/minute or 10 ads/day.

Idempotency

Calling web/ads/confirm multiple times with the same view_token returns already_confirmed and does not double-bill. Safe to retry on network failure.

The idempotency key is the view_token itself — it is a single-use signed JWT that is tied to a specific request. Retrying confirm with the same token is safe. The token expires 120 seconds after web/ads/request — confirm before it expires.

Ad display patterns

The API always returns the same data — a banner image, a click URL, and a view token. How you present it to the visitor is entirely up to you. Below are three ready-to-paste patterns. All three use the same backend routes from the Integration snippets section — only the frontend HTML/JS differs.

Every pattern follows the same rule: call your /cv-confirm backend route once the ad is visible on screen. The IntersectionObserver handles that automatically in all three examples.

Pattern 1 — Inline banner

The ad sits in your page layout like any other content block. Best for feeds, sidebars, or between content sections. The visitor scrolls to it naturally.

Template syntax: All three patterns use <%= variable %> (EJS / Express). Replace with your framework's syntax:
{{ variable }} — Jinja2 (Python/Django/Flask), Twig (PHP), Nunjucks
{variable} — JSX (React, Next.js)
@variable — Blade (Laravel)
<?= $variable ?> — plain PHP
#{variable} — Pug / Jade
${variable} — template literals (inline JS string)
<!-- Drop anywhere in your page. Replace template vars with your backend values. -->
<div id="cv-ad"
     data-view-token="<%= viewToken %>"
     style="max-width:400px;width:100%;border-radius:10px;overflow:hidden;
            border:1px solid <%= adColor %>;background:#0d0d11;
            font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
  <a href="<%= adClickUrl %>" target="_blank" rel="noopener" aria-label="<%= adLabel %>"
     style="display:block;line-height:0;">
    <img src="<%= adBannerUrl %>" alt="<%= adLabel %>"
         style="width:100%;display:block;border-radius:9px 9px 0 0;" />
  </a>
  <div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:#0d0d11;">
    <span style="font-size:11px;font-weight:700;color:#7a8499;">Ad</span>
    <a href="<%= referralUrl %>" target="_blank" rel="noopener"
       style="font-size:11px;color:#7a8499;text-decoration:none;"
       onmouseover="this.style.color='#00e5a0'" onmouseout="this.style.color='#7a8499'">
      Ads by Cordvertise
    </a>
  </div>
</div>

<script>
(function() {
  const widget = document.getElementById('cv-ad');
  const token  = widget?.dataset.viewToken;
  if (!widget || !token) return;
  new IntersectionObserver(function(entries, obs) {
    if (!entries[0].isIntersecting) return;
    obs.disconnect();
    fetch('/cv-confirm', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ viewToken: token }),
    });
  }, { threshold: 0.5 }).observe(widget);
})();
</script>

Pattern 2 — Popup overlay

The ad appears as a centered overlay on top of the page. Best for showing an ad when a visitor first arrives, after a delay, or when they try to leave. Closes when they click outside it.

<!-- Popup backdrop + widget. Replace template vars with your backend values. -->
<!-- Add to end of <body>. Hidden by default, shown via JS below. -->
<div id="cv-popup-backdrop"
     style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);
            z-index:9999;align-items:center;justify-content:center;"
     onclick="document.getElementById('cv-popup-backdrop').style.display='none'">
  <div id="cv-ad"
       data-view-token="<%= viewToken %>"
       onclick="event.stopPropagation()"
       style="max-width:400px;width:90%;border-radius:10px;overflow:hidden;
              border:1px solid <%= adColor %>;background:#0d0d11;
              font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
    <a href="<%= adClickUrl %>" target="_blank" rel="noopener" aria-label="<%= adLabel %>"
       style="display:block;line-height:0;">
      <img src="<%= adBannerUrl %>" alt="<%= adLabel %>"
           style="width:100%;display:block;border-radius:9px 9px 0 0;" />
    </a>
    <div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:#0d0d11;">
      <span style="font-size:11px;font-weight:700;color:#7a8499;">Ad</span>
      <a href="<%= referralUrl %>" target="_blank" rel="noopener"
         style="font-size:11px;color:#7a8499;text-decoration:none;"
         onmouseover="this.style.color='#00e5a0'" onmouseout="this.style.color='#7a8499'">
        Ads by Cordvertise
      </a>
    </div>
  </div>
</div>

<script>
(function() {
  const token    = '<%= viewToken %>';
  const backdrop = document.getElementById('cv-popup-backdrop');
  if (!backdrop || !token) return;

  // Show popup after 1.5 seconds
  setTimeout(function() {
    backdrop.style.display = 'flex';

    // Confirm once the ad is visible
    const widget = document.getElementById('cv-ad');
    new IntersectionObserver(function(entries, obs) {
      if (!entries[0].isIntersecting) return;
      obs.disconnect();
      fetch('/cv-confirm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ viewToken: token }),
      });
    }, { threshold: 0.5 }).observe(widget);
  }, 1500);
})();
</script>
Change the 1500 ms delay to whatever fits your page. Set it to 0 to show instantly on load, or hook backdrop.style.display = 'flex' to any event (scroll depth, exit intent, button click, etc.).

Pattern 3 — Triggered ad with countdown

The ad appears when the user performs an action — clicking a button, completing a step, submitting a form. A countdown timer shows before they can dismiss it. Best for rewarded flows: "watch this ad to unlock your result".

<!-- Trigger button — replace with whatever action makes sense on your page -->
<button id="cv-trigger-btn">See your result</button>

<!-- Popup backdrop + widget -->
<div id="cv-popup-backdrop"
     style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);
            z-index:9999;align-items:center;justify-content:center;">
  <div id="cv-ad"
       data-view-token="<%= viewToken %>"
       style="max-width:400px;width:90%;border-radius:10px;overflow:hidden;
              border:1px solid <%= adColor %>;background:#0d0d11;
              font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
              position:relative;">

    <!-- Countdown dismiss button — hidden until timer finishes -->
    <button id="cv-close-btn"
            style="display:none;position:absolute;top:8px;right:8px;
                   background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);
                   color:#fff;font-size:11px;padding:3px 9px;border-radius:5px;
                   cursor:pointer;z-index:10;">
      Close
    </button>
    <div id="cv-countdown"
         style="position:absolute;top:8px;right:8px;
                background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);
                color:#fff;font-size:11px;padding:3px 9px;border-radius:5px;">
      5
    </div>

    <a href="<%= adClickUrl %>" target="_blank" rel="noopener" aria-label="<%= adLabel %>"
       style="display:block;line-height:0;">
      <img src="<%= adBannerUrl %>" alt="<%= adLabel %>"
           style="width:100%;display:block;border-radius:9px 9px 0 0;" />
    </a>
    <div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:#0d0d11;">
      <span style="font-size:11px;font-weight:700;color:#7a8499;">Ad</span>
      <a href="<%= referralUrl %>" target="_blank" rel="noopener"
         style="font-size:11px;color:#7a8499;text-decoration:none;"
         onmouseover="this.style.color='#00e5a0'" onmouseout="this.style.color='#7a8499'">
        Ads by Cordvertise
      </a>
    </div>
  </div>
</div>

<script>
(function() {
  const token      = '<%= viewToken %>';
  const backdrop   = document.getElementById('cv-popup-backdrop');
  const widget     = document.getElementById('cv-ad');
  const closeBtn   = document.getElementById('cv-close-btn');
  const countdown  = document.getElementById('cv-countdown');
  const triggerBtn = document.getElementById('cv-trigger-btn');

  if (!backdrop || !token) return;

  function openAd() {
    backdrop.style.display = 'flex';

    // Confirm once visible
    new IntersectionObserver(function(entries, obs) {
      if (!entries[0].isIntersecting) return;
      obs.disconnect();
      fetch('/cv-confirm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ viewToken: token }),
      });
    }, { threshold: 0.5 }).observe(widget);

    // Countdown — 5 seconds before dismiss button appears
    var secs = 5;
    var interval = setInterval(function() {
      secs--;
      if (secs > 0) {
        countdown.textContent = secs;
      } else {
        clearInterval(interval);
        countdown.style.display = 'none';
        closeBtn.style.display  = 'block';
      }
    }, 1000);
  }

  function closeAd() {
    backdrop.style.display = 'none';
  }

  triggerBtn.addEventListener('click', openAd);
  closeBtn.addEventListener('click', closeAd);
})();
</script>
Change var secs = 5 to any duration. Hook openAd() to any event — form submit, Discord OAuth callback, quiz result reveal, etc. The user cannot dismiss the ad until the timer runs out.