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
| Field | Where | Description |
|---|---|---|
| Authorization | Header | Bearer <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_id | Body | The visiting user's Discord ID, obtained from your Discord OAuth session. Required on every request. |
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
web/ads/confirm after the ad is actually visible on screen. If rendering fails, do not confirm — no billing occurs.
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.
cvConfirmAd. Never call the Cordvertise API from browser JS.
How to wire it into your routes (Express example)
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>
Returns an ad and a signed view token. Always check if ad is null before rendering anything.
| Header | Value | |
|---|---|---|
| Authorization | required | Bearer YOUR_API_KEY |
| Content-Type | required | application/json |
| Field | Description | |
|---|---|---|
| user_id | required | Discord user ID of the visitor, from your Discord OAuth flow. Must be accurate — used for targeting and fraud checks. |
| language | required | 2-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_type | required | Device type inferred from the visitor's user agent. One of: "desktop", "mobile", "tablet". |
| ip | required | Visitor's real IP address. Used for country-level geo lookup. Pass the client IP, not your server's outbound IP. |
| servers | optional | Array 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
}
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.
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.
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.
| Field | Description | |
|---|---|---|
| view_token | required | Token from web/ads/request. Single-use, 120s TTL. |
| user_id | required | Discord 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_type | Advertiser pays for | Your action |
|---|---|---|
| views | Ad rendered to the user | Render the banner. Confirm after it's visible. Billing happens on confirm. |
| clicks | User clicks the CTA button | Same as views — render banner, confirm delivery. Billing happens when the user clicks button_url. |
| joins | User joins the advertiser's Discord server | Render 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. |
| stays | User joins and stays in the server for the required period | Same as joins — use ad.join_url. Billing happens automatically once the stay condition is met. |
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.
How it worksad.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.
Errors & denials
HTTP errors| Status | error | Meaning & action |
|---|---|---|
| 400 | bad_request | Missing or invalid fields. |
| 400 | missing_user_id | user_id missing or not a valid Discord snowflake. |
| 401 | missing_auth / invalid_key | Authorization header missing or API key invalid. |
| 403 | key_not_active | Key suspended — contact support. |
| 403 | wrong_key_type | You used a bot API key on a web endpoint, or vice versa. Web keys are required for all /v1/web/ routes. |
| 403 | missing_scope | API key does not have ads.request scope. |
| 403 | bad_signature | view_token tampered or malformed. |
| 403 | token_identity_mismatch | Token belongs to a different key or client. |
| 429 | rate_limited | Request rate exceeded. Check the Rate limits section. Back off and retry after retry_after_seconds if present. |
| 500 | server_error | Internal error — retry with exponential backoff. |
denied_reason)
| denied_reason | What to do |
|---|---|
| user_minute_cap | Visitor saw an ad <1 min ago — skip. Includes retry_after_seconds. |
| user_daily_cap | Visitor hit 10 ads/day — skip until UTC midnight. |
| partner_minute_cap | Your key hit its per-minute request cap — back off. Includes retry_after_seconds. |
| ads_disabled | Ad serving disabled for your key — contact support. |
| no_ads | No matching ads available right now — skip silently. |
reason)
| reason | Meaning |
|---|---|
| token_expired | Token TTL is 120 seconds from the web/ads/request response. If it expired before confirm, do not retry — the event is closed. |
| token_used | Token already confirmed. Same as already_confirmed — safe to ignore on retry. |
| already_confirmed | Same view token already confirmed — idempotent, safe to ignore. |
| ad_unavailable | Ad paused between request and confirm. |
| account_too_new | User's Discord account is less than 30 days old — not billed. |
| advertiser_insufficient_funds | Advertiser ran out of credits between request and confirm. |
| daily_limit_reached | Advertiser's daily spend cap was hit before this confirm was processed. |
| free_ads_limited | Free-tier advertiser impression limit reached — not billed. |
Rate limits & delivery caps
Request rate limits (per web API key)| Tier | Requests / min | Requirement |
|---|---|---|
| Probation | 100 | Default for new keys |
| Standard | 250 | 50+ confirmed views in 30 days |
| Pro | 500 | 250+ confirmed views in 30 days |
| Enterprise | 1000 | 750+ confirmed views in 30 days |
ad: null, not HTTP errors)
| Cap | Limit | Scope |
|---|---|---|
| User per-minute | 1 ad / min | Per (your key + user) |
| User per-day | 10 ads / UTC day | Per (your key + user) |
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.
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.
<%= 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>
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>
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.