Custom ChatGPT Integration (End‑to‑End Example)
This guide shows how to wire the Teads AI Chatbot SDK into a Custom ChatGPT using Actions (OpenAPI), a lightweight Node/Express proxy, and prompt/tooling conventions that let your GPT fetch organic/paid content and fire measurement events safely.
What you’ll get
- A ready-to-import OpenAPI (Actions) spec for Custom ChatGPT
- A Node/Express proxy that calls Teads/Partner endpoints
- Example system & tool use instructions for your GPT
- Conversation flows for measurement, content fetch, and click tracking
- Privacy/Security tips tailored for Actions
0) Architecture at a Glance
User ↔ Custom ChatGPT ──(Actions/OpenAPI)──► Your Proxy ──► Teads/Partner
◄────────────── JSON (vjnc)
- Custom ChatGPT invokes Actions you define via an OpenAPI spec.
- Your proxy signs/forwards Teads requests, keeping keys secret.
- Teads replies in JSON (
vjnc) format; your GPT summarizes/uses it in chat. - Use
testMode=truein non-production.
1) Environment & Prereqs
- Teads/Partner provisioning:
{YOUR_MEASUREMENT_API_KEY},{YOUR_CONTENT_API_KEY}{YOUR_MEASUREMENT_WIDGET_ID},{YOUR_ORGANIC_WIDGET_ID},{YOUR_PAID_WIDGET_ID}
- Publicly reachable HTTPS proxy (e.g., Vercel, Fly.io, Cloud Run).
- Do not expose API keys to ChatGPT; the proxy injects them.
Example .env for your proxy:
TEADS_BASE_URL=https://mv.outbrain.com
TEADS_BASE_PATH=/Multivac/api/platforms
TEADS_FORMAT=vjnc
TEADS_CORS=true
TEADS_IDX=0
TEADS_MEASUREMENT_API_KEY=REPLACE_ME
TEADS_MEASUREMENT_WIDGET_ID=REPLACE_ME
TEADS_CONTENT_API_KEY=REPLACE_ME
TEADS_ORGANIC_WIDGET_ID=REPLACE_ME
TEADS_PAID_WIDGET_ID=REPLACE_ME
TEADS_TEST_MODE=true # set false in production
PORT=8080
2) Node/Express Proxy (Secure Key Injection)
Save as
server.jsand deploy. Replace env vars as needed.
import express from "express";
import fetch from "node-fetch";
const app = express();
const BASE = process.env.TEADS_BASE_URL || "https://mv.outbrain.com";
const PATH = process.env.TEADS_BASE_PATH || "/Multivac/api/platforms";
const FORMAT = process.env.TEADS_FORMAT || "vjnc";
const CORS = process.env.TEADS_CORS || "true";
const IDX = process.env.TEADS_IDX || "0";
const TEST_MODE = (process.env.TEADS_TEST_MODE || "true") === "true";
function buildUrl(params) {
const url = new URL(`${BASE}${PATH}`);
for (const [k, v] of Object.entries(params)) url.searchParams.append(k, v);
return url.toString();
}
function canonicalEncode(rawUrl) {
// strip query/fragments before encoding
const u = new URL(rawUrl);
u.search = "";
u.hash = "";
return encodeURIComponent(u.toString());
}
// Measurement endpoint
app.get("/teads/measure", async (req, res) => {
try {
const { contentUrl, extid = "chatbot_load" } = req.query;
if (!contentUrl) return res.status(400).json({ error: "contentUrl required" });
const params = {
contentUrl: canonicalEncode(contentUrl),
key: process.env.TEADS_MEASUREMENT_API_KEY,
widgetJSId: process.env.TEADS_MEASUREMENT_WIDGET_ID,
idx: IDX,
format: FORMAT,
cors: CORS,
extid,
...(TEST_MODE ? { testMode: "true" } : {})
};
const url = buildUrl(params);
const r = await fetch(url, { method: "GET" });
// Measurement flow can ignore body, but we pass-through for debugging
const text = await r.text();
res.status(r.status).send(text);
} catch (e) {
res.status(500).json({ error: "measure failed", details: e.message });
}
});
// Organic content endpoint
app.get("/teads/organic", async (req, res) => {
try {
const { contentUrl, news = "latest", newsFrom = "US" } = req.query;
if (!contentUrl) return res.status(400).json({ error: "contentUrl required" });
const params = {
contentUrl: canonicalEncode(contentUrl),
key: process.env.TEADS_CONTENT_API_KEY,
widgetJSId: process.env.TEADS_ORGANIC_WIDGET_ID,
idx: IDX,
format: FORMAT,
cors: CORS,
news,
newsFrom,
...(TEST_MODE ? { testMode: "true" } : {})
};
const url = buildUrl(params);
const r = await fetch(url, { method: "GET" });
const text = await r.text();
res.status(r.status).type("application/json").send(text);
} catch (e) {
res.status(500).json({ error: "organic failed", details: e.message });
}
});
// Paid content endpoint
app.get("/teads/paid", async (req, res) => {
try {
const { contentUrl } = req.query;
if (!contentUrl) return res.status(400).json({ error: "contentUrl required" });
const params = {
contentUrl: canonicalEncode(contentUrl),
key: process.env.TEADS_CONTENT_API_KEY,
widgetJSId: process.env.TEADS_PAID_WIDGET_ID,
idx: IDX,
format: FORMAT,
cors: CORS,
...(TEST_MODE ? { testMode: "true" } : {})
};
const url = buildUrl(params);
const r = await fetch(url, { method: "GET" });
const text = await r.text();
res.status(r.status).type("application/json").send(text);
} catch (e) {
res.status(500).json({ error: "paid failed", details: e.message });
}
});
app.listen(process.env.PORT || 8080, () => {
console.log("Teads proxy running on port", process.env.PORT || 8080);
});
Deploy (examples): Vercel (serverless functions), Cloud Run, Fly.io, Render. Ensure HTTPS.
3) OpenAPI (Actions) Spec for Custom ChatGPT
Create a Custom GPT → Actions → Add Action → Paste this OpenAPI 3.1 spec (update your server URL).
openapi: 3.1.0
info:
title: Teads Proxy API for Custom ChatGPT
version: 1.0.0
description: Secure proxy to Teads/Partner endpoints used by ChatGPT Actions.
servers:
- url: https://YOUR_PUBLIC_PROXY_HOST
paths:
/teads/measure:
get:
operationId: measureEvent
summary: Fire a Teads measurement event (e.g., chatbot_load, unit_view, unit_click)
parameters:
- in: query
name: contentUrl
required: true
schema: { type: string, format: uri }
description: Canonical page/chat URL
- in: query
name: extid
required: false
schema: { type: string, enum: [chatbot_load, unit_load, unit_view, unit_click] }
description: Measurement event ID
responses:
'200':
description: Measurement response (body may be ignored)
content:
application/json:
schema:
type: object
additionalProperties: true
/teads/organic:
get:
operationId: getOrganic
summary: Fetch organic (publisher) recommendations
parameters:
- in: query
name: contentUrl
required: true
schema: { type: string, format: uri }
- in: query
name: news
required: false
schema: { type: string }
example: latest
- in: query
name: newsFrom
required: false
schema: { type: string }
example: US
responses:
'200':
description: vjnc JSON response
content:
application/json:
schema:
type: object
additionalProperties: true
/teads/paid:
get:
operationId: getPaid
summary: Fetch marketer (paid) recommendations
parameters:
- in: query
name: contentUrl
required: true
schema: { type: string, format: uri }
responses:
'200':
description: vjnc JSON response
content:
application/json:
schema:
type: object
additionalProperties: true
Auth: None needed in Actions; keys live on your proxy only.
4) Custom GPT Instructions (Prompting Template)
Add to your Instructions (System message) in the GPT builder:
- You can call Actions to:
- Fire measurement events (
measureEvent) when sessions begin (chatbot_load), when units render (unit_load), when ≥50% is visible for 1s (unit_view), and when a user initiates a click (unit_click).- Fetch organic content (
getOrganic) using the user’s page/topic URL.- Fetch paid content (
getPaid) for sponsored recommendations.- Always pass the canonical
contentUrl(no query/fragment).- Prefer
news=latest&newsFromaligned with user region for organic.- Do not rewrite or modify click URLs in responses.
- Include disclosures when displaying sponsored/paid items.
- Use test mode environment during QA (proxy handles it).
Optional tool-use policy (developer instructions):
- Call
measureEventwithextid=chatbot_loadonce per new session. - After showing any recommendation list, call
measureEventwithextid=unit_load. - When user scrolls/asks to preview → call
measureEventwithextid=unit_view. - When user selects an item → present
doc.urlas-is (and state it will open externally).
5) Example Tool Calls from ChatGPT
Start of session:
{
"tool_name": "measureEvent",
"parameters": {
"contentUrl": "https://example.com/page-about-topic",
"extid": "chatbot_load"
}
}
Fetch organic:
{
"tool_name": "getOrganic",
"parameters": {
"contentUrl": "https://example.com/page-about-topic",
"news": "latest",
"newsFrom": "US"
}
}
Fetch paid:
{
"tool_name": "getPaid",
"parameters": {
"contentUrl": "https://example.com/page-about-topic"
}
}
Render guidance (what your GPT should display):
- Title, short description, publisher/source.
- Paid: label as Sponsored.
- Provide a “Open link” button using
doc.urlunchanged. - Optional:
doc.orig_urlfor organic if you choose split-click handling (navigate toorig_url, pingdoc.url).