@dotbots-boutique/ads-sdk
v0.1.2
Published
Official SDK for showing, reporting and managing dotAds advertisements in a DotBots Boutique application.
Maintainers
Readme
@dotbots-boutique/ads-sdk
The official SDK for showing, reporting and managing dotAds advertisements in a DotBots Boutique application. dotAds is a platform service under the provider contract; this package is the consumer side of it.
You show ads that are counted and attributed correctly in under twenty lines of code, without ever having a service token, a trackingId or a dotAds endpoint in the browser, and without fetching a token yourself.
Architecture
Browser (React) Your Deno backend dotAds
┌──────────────────────┐ ┌───────────────────────────┐ ┌────────────────┐
│ @dotbots-boutique/ │ │ @dotbots-boutique/ │ │ │
│ ads-sdk/react │ │ ads-sdk │ │ /public/v1/* │
│ │ │ │ │ │
│ DotAdsProvider │ auth.fetch │ createDotAdsHandler │ service│ /ads/batch │
│ AdSlot ├───────────►│ POST {prefix}/batch │ token │ /ads │
│ useDotAdsClaims │ │ POST {prefix}/events ├───────►│ /events │
│ useRedeemClaim │ │ POST/GET {prefix}/claims│ │ /claims │
│ useAdvertiser │◄───────────┤ PUT {prefix}/claims/:id│◄───────┤ /advertisers │
│ │ JSON │ GET {prefix}/advertisers │ /setup │
└──────────────────────┘ │ GET {prefix}/status │ └────────────────┘
│ │
│ DotAdsClient │
│ dotbots.service('dotAds',│
│ { orgId, onBehalfOf })│
└───────────────────────────┘
│
@dotbots-boutique/server-sdk
holds the URL, the token and the environmentThe browser talks only to your own backend, with the fetch your application already uses. Your
backend talks to dotAds through a service handle. No token ever crosses that first line.
Install
npm install @dotbots-boutique/ads-sdkThe package has no dependencies and no peer dependencies, and ships ESM plus its own types. What each entry expects from your application:
| Entry | Expects | Why it is not a peer dependency |
| --- | --- | --- |
| server | @dotbots-boutique/server-sdk 1.0 or newer | Nothing is imported from it. Your application passes in its own initialised dotbots, and the contract is typed structurally. |
| react | react 18 or newer | Only the browser entry imports it, and a React application already has React installed. |
Declaring those as peer dependencies costs more than it buys: Deno resolves peers per dependency
path, so a package that declares them gets a separate copy per peer combination
([email protected][email protected] next to [email protected]_@[email protected]). A
deno compile of an application backend then has to read a copy directory such as 0.1.0_1 from the
Deno cache, which is not always materialised, and the build fails on a cold cache with
Building npm vfs ... No such file or directory. Without peer dependencies there are no copies, so
that failure cannot happen. Check the versions above yourself; npm will not warn you.
| Entrypoint | Runs in | Talks to |
| --- | --- | --- |
| @dotbots-boutique/ads-sdk | the Deno backend of your app | dotAds /public/v1/* through dotbots.service('dotAds') |
| @dotbots-boutique/ads-sdk/react | the browser | your own backend, through the fetch you pass in |
The server entry refuses to load where window exists.
Declare the service
dotAds is a platform service, so declare it in dotbots.boutique.json before you call it. A grant is
approved once, by the organisation, and the platform issues the service token from there on.
{
"platformServices": {
"dotAds": {
"description": "Advertisements in the application",
"scopes": ["ads.serve", "ads.report", "ads.claim", "ads.redeem", "ads.setup"],
"access": "required"
}
}
}| Function | Scope |
| --- | --- |
| serve.batch, serve.one | ads.serve |
| events.report | ads.report |
| claims.create, claims.list | ads.claim |
| claims.update (redeeming) | ads.redeem |
| catalog.* | ads.serve |
| setup.* | ads.setup |
Declare only the scopes you use. A scope you did not declare comes back as SCOPE_NOT_DECLARED; one
that was declared but not granted as SCOPE_NOT_GRANTED.
Quickstart
Backend (Deno):
import { DotAdsClient, createDotAdsHandler } from "@dotbots-boutique/ads-sdk";
const client = new DotAdsClient({ dotbots, orgId: Deno.env.get("APP_ORG_ID")! });
const ads = createDotAdsHandler({ client, resolveUser, corsHeaders });
Deno.serve(async (request) => await ads(request) ?? new Response("Not found", { status: 404 }));Browser (React):
import { AdSlot, DotAdsProvider } from "@dotbots-boutique/ads-sdk/react";
const auth = useDotBotsAuth();
<DotAdsProvider endpoint={`${BACKEND_URL}/api/ads`} fetch={auth.fetch} serviceStatus={auth.serviceStatus("dotAds")}>
<AdSlot placement="home_top" markets={["BE", "EU"]} />
</DotAdsProvider>;That is the whole integration. Slots rendered together share one request, impressions and clicks are counted once each, and nothing is stored in the browser.
orgId is the organisation of the maker
orgId is the organisation that owns the application and its dotAds inventory. It is fixed for
the life of the client and comes from your own configuration.
It is never user.orgId. A visitor from another organisation is still shown the inventory of the
organisation that built the app. Passing the visitor's organisation makes dotAds answer
ORGANISATION_MISMATCH.
The visitor travels as onBehalfOf, per call. The SDK passes it to
dotbots.service('dotAds', { orgId, onBehalfOf }), where it becomes act in the service token. It
never goes into a request body.
await client.serve.batch({ slots, language: "nl", onBehalfOf: user.userId });Handles are cached per acting person, so a screen with ten slots costs one token request, not ten. The platform caps at 600 requests per minute per application.
Serving
const result = await client.serve.batch({
slots: [
{ key: "top", placement: "home_top", markets: ["BE", "EU"], context: { page: "home" } },
{ key: "side", placement: "home_side", markets: ["BE"], count: 2 },
],
language: "nl",
onBehalfOf: user.userId,
});
result.slotsByKey.top.ads; // the ads for the 'top' slotmarkets is in order of preference. The dotAds answer is passed through unchanged
(requestId, advertiserCap, slotCount, filledCount, and per slot key, placement, format,
markets, requested, count, reason, ads); slotsByKey is added purely as an index.
An empty slot carries a reason, and a reason is a normal result, not an error:
| reason | Means |
| --- | --- |
| unknown_placement | the placement does not exist in dotAds |
| unknown_market | the market does not exist in dotAds |
| no_eligible_ads | nothing qualified right now |
serve.one covers a single placement through GET /ads. dotAds answers 404 for an unknown placement
or market there; the SDK translates that into the same reason.
Reporting
Every event carries an eventId: the idempotency key dotAds deduplicates on. dotAds deduplicates on
that key alone, never on trackingId plus type, so without an eventId a repeat counts twice. The
SDK therefore always fills one in when you do not:
| Event | Generated eventId |
| --- | --- |
| impression | {trackingId}:impression (stable: one view per served ad) |
| click | {trackingId}:click:{uuid} |
| advertiser_page_view, offer_view | {advertiserId}:{type}:{uuid} |
const outcome = await client.events.report([
{ type: "impression", trackingId: ad.trackingId },
{ type: "click", trackingId: ad.trackingId },
], { onBehalfOf: user.userId });Before anything is sent, the SDK refuses what dotAds could never store. Those come back as skipped
results rather than as an exception:
| Situation | reason |
| --- | --- |
| impression or click without a trackingId | tracking_required |
| type: 'claim' | claim_not_reportable |
| an empty eventId, or one over 200 characters | invalid_event_id |
More than 100 events are split into blocks of 100 and sent one after another, and the results are
merged into one { received, stored, skipped, results }. An empty batch is never sent. A batch fails
only when dotAds rejects it as a whole. Each result's eventKey (the application code plus your
eventId) is linked back to the eventId the SDK sent.
Claims
const claim = await client.claims.create({ promotionId, trackingId, onBehalfOf: user.userId });
const mine = await client.claims.list({ onBehalfOf: user.userId });
const redeemed = await client.claims.update(claim.id, { status: "redeemed" });create and list require onBehalfOf: a claim always belongs to a person. Without it the SDK
refuses locally with ACT_REQUIRED, before a request leaves the process. Redeeming through update
is deliberately without a person: there the application acts, not the visitor, which is why the
handler puts it behind allowRedeem and refuses it by default.
A marketplace key cannot claim. Claiming happens for a signed-in person of the application, through
the service token with act.
Never in a body
Nothing about the application, the organisation, the environment or the acting person belongs in a request body: the service token carries all of it. Passing one anyway is an error, not a silent strip:
await client.serve.batch({ slots, userId: user.id }); // DotAdsError CONFIG_INVALID
await client.serve.batch({ slots: [{ ..., context: { orgId } }] }); // DotAdsError CONFIG_INVALIDThe check runs over the whole argument, nested context included. The one exception is the document of
setup.put: that is dotAds content and is passed through exactly as authored, only the envelope is
checked.
Errors
DotBotsServiceError from the server SDK travels through unchanged, so your application can act
on the grant flow: SERVICE_GRANT_PENDING, SERVICE_NOT_GRANTED, SERVICE_GRANT_DENIED,
SERVICE_GRANT_REVOKED, SCOPE_NOT_GRANTED, SERVICE_NOT_DECLARED, SCOPE_NOT_DECLARED,
TEST_ORG_NOT_ALLOWED, TEST_USAGE_QUOTA_EXCEEDED, RATE_LIMIT_EXCEEDED, SERVICE_UNAVAILABLE.
Everything dotAds itself answers arrives as a DotAdsError:
class DotAdsError extends Error {
status: number; // HTTP status, 0 on a network failure or timeout
code: string; // INVALID_SERVICE_TOKEN, SCOPE_NOT_GRANTED, GRANT_REVOKED,
// ORGANISATION_MISMATCH, tracking_other_app, ACT_REQUIRED, CONFIG_INVALID, ...
details?: unknown; // for example { missingScopes: ['ads.serve'] }
retryable: boolean; // true on 5xx, timeout and network failure; false on every 4xx
}| Code | What your app does |
| --- | --- |
| SERVICE_GRANT_PENDING | show "waiting for approval", do not retry; an administrator approves the grant |
| SERVICE_NOT_GRANTED, SERVICE_GRANT_DENIED, SERVICE_GRANT_REVOKED | hide the ad surfaces, carry on without dotAds |
| SCOPE_NOT_GRANTED, SCOPE_NOT_DECLARED | a scope is missing; fix the declaration or ask for approval |
| ORGANISATION_MISMATCH | orgId is not the owner of the inventory; fix your configuration |
| ACT_REQUIRED | the call needs onBehalfOf; pass the signed-in person |
| CONFIG_INVALID | a forbidden field was passed; remove it |
| tracking_other_app | the trackingId came from another application; serve again |
| TEST_USAGE_QUOTA_EXCEEDED | the day quota on test is spent; wait for the next day, never retry |
| RATE_LIMIT_EXCEEDED | the SDK already retried once; back off |
| SERVICE_UNAVAILABLE | dotAds is down; show the fallback |
Retry rules: never on 403, never on TEST_USAGE_QUOTA_EXCEEDED, never on claims.create. One retry
on RATE_LIMIT_EXCEEDED after the indicated wait; one retry on a retryable failure for GET requests
and for events.report (its eventId makes a repeat safe).
The handler
const ads = createDotAdsHandler({
client,
resolveUser: (request) => sessionFor(request), // { userId, orgId, roles } | null
allowClaims: (user) => true, // default: every signed-in person
allowRedeem: (user) => user.roles.includes("staff"), // default: nobody
corsHeaders, // your platform-conforming function
prefix: "/api/ads", // default
});| Route | Does |
| --- | --- |
| OPTIONS * | 200 with body ok and your CORS headers |
| POST {prefix}/batch | validates the slots, takes onBehalfOf from resolveUser, calls serve.batch |
| POST {prefix}/events | validates, calls events.report, answers 202 with the merged result |
| POST {prefix}/claims, GET {prefix}/claims | only when allowClaims; the person comes from the session, never from the body |
| PUT {prefix}/claims/:id | redeeming; only when allowRedeem |
| GET {prefix}/advertisers/:id | the public advertiser page |
| GET {prefix}/status | { status } derived from the last platform answer |
No user is 401 { error: 'unauthorized' }. Platform errors are translated the way the developer guide
prescribes: SERVICE_GRANT_PENDING to 409 awaiting_approval, a missing grant to 403 not_available,
SCOPE_NOT_GRANTED to 403 with scopes, SERVICE_UNAVAILABLE to 503. A DotAdsError keeps its
status and code. Always JSON, always CORS, never a token in an answer.
React
<DotAdsProvider
endpoint={`${BACKEND_URL}/api/ads`} // absolute URL of your backend
fetch={auth.fetch} // required
batchWindowMs={16} // default
flushIntervalMs={2000} // default
labels={{ nl: "Advertentie" }} // default: en, nl, fr, de
serviceStatus={auth.serviceStatus("dotAds")}
onError={(error) => report(error)} // default console.warn, never a throw
/>A relative endpoint fails clearly at mount: it cannot be resolved from inside the marketplace
iframe. While the grant is pending, denied or revoked the provider requests nothing at all. When
you pass no serviceStatus, it reads GET {endpoint}/status once. The language comes from ?lang=,
defaults to en, and travels as language on the batch. Everything lives in React state and refs.
<AdSlot
placement="home_top"
markets={["BE", "EU"]}
count={2}
context={{ page: "home" }}
render={(ad) => <MyAd ad={ad} />} // optional; do not render your own anchor inside it
fallback={<HouseAd />} // shown when nothing was served
onServed={(result) => track(result)}
/>- Every slot rendered within
batchWindowMsshares onePOST {endpoint}/batch. Each slot gets its own key, derived from React'suseId()but stripped to letters, digits and underscores (slot_r1), because a key with punctuation can be dropped by dotAds. - Nothing served means
nullor yourfallback, never a placeholder larger than the format. - An impression is counted per ad after one uninterrupted second at half visibility
(
IntersectionObserver, threshold 0.5), once, also across re-renders and scrolling back. - A click is queued, flushed with
keepalive: true, and only then opened withwindow.open(url, '_blank', 'noopener,noreferrer'). A second click within a second still navigates but is not reported again. - The queue is flushed on the interval, at 100 events, on
pagehideand onvisibilitychange: hidden. One retry, capped at 500 events, oldest dropped first. - Changing
placement,markets,countorcontextrefreshes the slot: new trackingIds, so new impressions. Never a silent reload. - A failure shows the
fallbackand callsonError. Never a throw: dotAds may not break your app. - Clickable ads are at least 44 by 44 pixels and never hover-only.
- Media comes straight from the absolute URL in the ad (token-free on
GET /public/v1/media/:id), withmax-width: 100%. That URL is the capability: it comes from the ad and goes nowhere else.
Hooks:
useDotAdsStatus(); // { status, grantId?, canCurrentUserApprove? }
useDotAdsClaims(); // { claimPromotion(promotionId, trackingId), myClaims, refresh, loading, error }
useRedeemClaim(); // { redeem(claimId), loading, error } -- only useful behind allowRedeem
useAdvertiser(id); // { advertiser, loading, error }Default renderers are chosen per format kind and show every mandatory field in full, with the label
from labels and the promotion whenever ad.promotion exists. No text-overflow: ellipsis and no
line-clamp on mandatory text: an advertiser paid for the whole sentence.
Logging
The server entry writes one JSON line per event to stdout, or to the logger you pass in.
{ "level": "info", "message": "dotads ads served", "slots": 3, "filled": 2, "durationMs": 120 }
{ "level": "info", "message": "dotads events reported", "received": 40, "stored": 39, "skipped": 1 }
{ "level": "error", "message": "dotads request failed", "code": "SCOPE_NOT_GRANTED", "status": 403, "endpoint": "/ads/batch", "stacktrace": "..." }Never logged: the service token, DOTBOTS_APP_SECRET, full trackingIds (the first eight characters
only, through redactTrackingId), user ids, request bodies.
Test and production
The SDK contains no environment code: the server SDK stamps env from the deployment. What differs
on test:
@testis a separate application row, with its own inventory and its own grants.- High scopes must be switched on by hand on test.
- Everything is measured and nothing is ever charged.
- No network campaigns on test: only your own inventory.
- Test data lives 30 days, with a limit of 100,000 events.
TEST_USAGE_QUOTA_EXCEEDEDis a day quota. Wait for the next day; never retry it.
What the SDK never does
- Put a service token, a dotAds endpoint or a secret in the browser.
- Fetch or refresh a token itself: that is
dotbots.service('dotAds', ...). - Put a user id, an organisation, an application or an environment in a request body.
- Truncate advertisement text, or shrink a format to fit.
- Report a claim as an event (
type: 'claim'is skipped locally). - Retry a 403, a
TEST_USAGE_QUOTA_EXCEEDED, orclaims.create. - Write to
localStorage,sessionStorageor a cookie, or open a native dialog (lint enforces it). - Proxy media: image URLs come straight from the ad.
- Break your application. A dotAds failure is a fallback, never a crash.
Development
deno task lint # includes the no-browser-storage plugin
deno task check # type-checks both entrypoints
deno task test # the full suite, no network calls of its own
npm run build # emits dist/ for npm consumersThe tests mock DotBotsBackend.service() and dotAds itself, so the suite makes no calls to either.
The npm packages the React tests use (react, jsdom, @testing-library/react) are fetched into the
Deno cache on the first run.
