Test with your own app
The best test of the partner API is a real app of your own, connected exactly the way any business connects: a sign-in, a business record, a key from the Builder Console, the public endpoints, and nothing else. No database link, no shared password, no private door.
This page walks through ScentSell, an Australian perfume marketplace, as the worked example. ScentSell is owned by Sniffopotamus's owner and connects exactly like any other business. It wants the Sniffopotamus card on each listing page: the notes with their pictures under a Notes / Accords heading, in place of its own plainer notes list, and its own list whenever a listing has no card.
The same pattern fits any shop, marketplace or app that has its own products and wants a card beside each one.
The shape of it
shopper's browser
│ POST { listingId } (ScentSell's own listing id; never a fragrance id, never the key)
▼
ScentSell's edge function "sniff-card" ── holds SNIFF_PARTNER_KEY
│ 0. refuse other websites, and more than 60 requests a minute from one address
│ 1. ScentSell's own database: which Sniffopotamus perfume is this listing linked to?
│ no link ───────────────────────────────► { ok: false, reason: "not_linked" }
│ 2. the stored copy of that perfume's card
│ under 24 hours old ────────────────────► the card (no call to Sniffopotamus)
│ 3. ask Sniffopotamus for the card
│ card ──► store it ─────────────────────► the card
│ in_review / not_found ──► drop the copy ► { ok: false, reason }
│ any other error, or no answer ─────────► the copy if under 7 days old, else { ok: false }
▼
listing page: the card under "Notes / Accords", or ScentSell's own notes
Three things make this safe:
- The browser sends ScentSell's own listing id, never a fragrance id. The function looks up the link itself, in ScentSell's database. A request that names a fragrance id is refused. So nobody can use the function, and ScentSell's allowance, to walk through the whole catalogue: the key only ever reaches perfumes ScentSell has linked to a listing.
- Only the function holds the key. The browser never sees it, and the partner door would refuse a browser anyway.
- The stored copy. Every listing of the same perfume shares one stored card, so a perfume costs at most about one call a day however many shoppers open it, and a slow moment at Sniffopotamus never slows the page.
Step 1: the business record and the key
-
The owner signs in at sniffopotamus.com, and Sniffopotamus sets up the business record (Getting started).
-
The owner opens the Builder Console at www.sniffopotamus.com/dev/console, and under Your business connection, Keys, creates a key labelled after where it will live, such as "ScentSell production".
-
The key goes straight into the function's secrets as
SNIFF_PARTNER_KEY, most simply in the Supabase dashboard under Edge Functions, Secrets. From a terminal, load it first so it never lands in your shell history:read -rs SNIFF_PARTNER_KEYsupabase secrets set SNIFF_PARTNER_KEY="$SNIFF_PARTNER_KEY" --project-ref <your project>
Step 2: link listings to perfumes
ScentSell keeps the link on its own records: each listing points at a perfume in
ScentSell's own catalogue, and that perfume carries a sniff_fragrance_id.
Today a person finds each id on www.sniffopotamus.com/explore: open the perfume, check the strength, and take the last part of its address. An Eau de Parfum and an Extrait of the same name are different perfumes with different ids. Matching a whole product list at once is planned; until then, a listing without a link simply shows ScentSell's own notes.
Step 3: a table for stored copies
One row per Sniffopotamus perfume, in ScentSell's own database, readable and writable only by the function:
create table sniff_card_copies (
sniff_fragrance_id uuid primary key,
card jsonb, -- the card exactly as sent; null once dropped
card_fetched_at timestamptz, -- when Sniffopotamus last served it
answer text not null -- what Sniffopotamus last said
check (answer in ('public', 'in_review', 'not_found', 'error')),
answered_at timestamptz not null default now()
);
-- Only the function (the service role) reads or writes it. No browser access at all.
alter table sniff_card_copies enable row level security;
revoke all on sniff_card_copies from public, anon, authenticated;
Recording what Sniffopotamus last said, not only the card, lets the function remember a "no card" answer for an hour and back off for ten minutes after an error, instead of asking again on every page view.
Step 4: the function
A Supabase Edge Function in TypeScript, condensed from ScentSell's own: the same rules, fewer lines. The same logic works in a Next.js route, a Node server or PHP.
// supabase/functions/sniff-card/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
const SNIFF = "https://www.sniffopotamus.com/api/partner/v1";
const MIN = 60 * 1000;
const FRESH = 24 * 60 * MIN; // serve a stored copy without asking for 24 hours
const FALLBACK = 7 * 24 * 60 * MIN; // on an error or a used-up month, keep serving it up to 7 days
const NO_CARD_MEMORY = 60 * MIN; // after in_review or not_found, do not ask again for an hour
const ERROR_BACKOFF = 10 * MIN; // after an error, do not ask again for ten minutes
const ORIGINS = ["https://www.scentsell.com.au", "https://scentsell.com.au"]; // your own site only
const db = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
Deno.serve(async (req) => {
const origin = req.headers.get("origin");
const cors: Record<string, string> = {
"Access-Control-Allow-Headers": "authorization, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
Vary: "Origin",
...(origin && ORIGINS.includes(origin) ? { "Access-Control-Allow-Origin": origin } : {}),
};
const reply = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { ...cors, "content-type": "application/json", "cache-control": "no-store" },
});
// Another website's page always sends its own Origin: refuse it before doing any work.
if (origin && !ORIGINS.includes(origin)) return reply({ ok: false, reason: "origin_refused" }, 403);
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
if (req.method !== "POST") return reply({ ok: false, reason: "post_only" }, 405);
// Add a per-address limit here (ScentSell allows 60 a minute), before any database work.
const raw = await req.json().catch(() => null);
const body = raw && typeof raw === "object" ? raw : {};
if ("fragranceId" in body || "id" in body) return reply({ ok: false, reason: "listing_only" }, 400);
if (typeof body.listingId !== "string") return reply({ ok: false, reason: "listing_id_required" }, 400);
// 1. Your own link, from your own database. Never a fragrance id from the browser.
const sniffId = await linkedFragranceId(body.listingId);
if (!sniffId) return reply({ ok: false, reason: "not_linked" });
// 2. What we already hold for this perfume.
const { data: row } = await db.from("sniff_card_copies").select("*").eq("sniff_fragrance_id", sniffId).maybeSingle();
const since = (at?: string | null) => (at ? Date.now() - Date.parse(at) : Infinity);
const fallback = () =>
row?.card && since(row.card_fetched_at) < FALLBACK ? reply({ ok: true, card: row.card }) : reply({ ok: false, reason: "unavailable" });
if (row?.answer === "public" && row.card && since(row.card_fetched_at) < FRESH) return reply({ ok: true, card: row.card });
if ((row?.answer === "in_review" || row?.answer === "not_found") && since(row.answered_at) < NO_CARD_MEMORY) {
return reply({ ok: false, reason: row.answer });
}
if (row?.answer === "error" && since(row.answered_at) < ERROR_BACKOFF) return fallback();
// 3. Ask Sniffopotamus.
let answer: any = null;
try {
const res = await fetch(`${SNIFF}/fragrances/${sniffId}/card`, {
headers: { Authorization: `Bearer ${Deno.env.get("SNIFF_PARTNER_KEY")}` },
signal: AbortSignal.timeout(6000),
});
answer = await res.json().catch(() => null);
} catch {
answer = null; // no answer at all: treated like an error
}
const now = new Date().toISOString();
if (answer?.ok === true && answer.card?.id === sniffId) {
await db.from("sniff_card_copies").upsert({
sniff_fragrance_id: sniffId, card: answer.card, card_fetched_at: now, answer: "public", answered_at: now,
});
return reply({ ok: true, card: answer.card });
}
const code = typeof answer?.error === "object" ? answer.error.code : answer?.status ?? "unavailable";
// Held back or gone: drop the copy at once. A perfume is held back because something in it was wrong.
if (code === "in_review" || code === "not_found") {
await db.from("sniff_card_copies").upsert({
sniff_fragrance_id: sniffId, card: null, card_fetched_at: null, answer: code, answered_at: now,
});
return reply({ ok: false, reason: code });
}
// Anything else (a ceiling, a used-up month, the key, Sniffopotamus itself): keep the copy, log it.
await db.from("sniff_card_copies").upsert({
sniff_fragrance_id: sniffId, card: row?.card ?? null, card_fetched_at: row?.card_fetched_at ?? null,
answer: "error", answered_at: now,
});
console.error("[sniff-card] card not refreshed", sniffId, code); // never log the key or the card
return fallback();
});
// ScentSell's link: listing -> its own catalogue perfume -> sniff_fragrance_id. Replace with your own.
async function linkedFragranceId(listingId: string): Promise<string | null> {
const { data: listing } = await db.from("listings").select("catalog_fragrance_id").eq("id", listingId).maybeSingle();
if (!listing?.catalog_fragrance_id) return null;
const { data: master } = await db
.from("fragrance_master").select("sniff_fragrance_id").eq("id", listing.catalog_fragrance_id).maybeSingle();
return master?.sniff_fragrance_id ?? null;
}
ScentSell's own function also hands the page only what it draws (the tiers,
each note's name and picture, and the description when brandWords is true),
so a shopper's browser never receives anything it does not need. Worth
copying.
Before you go live:
- A per-address limit on the function (ScentSell's is 60 a minute), so nobody can loop over your listings to spend your allowance.
- An alert on the
console.errorline, so a revoked key or a used-up month reaches a person rather than a log nobody reads. - Signed-out shoppers. ScentSell's listing pages are public, so its function
does not require a signed-in shopper (
verify_jwtis off for it). The controls that matter are the ones above: listing ids only, your own site only, a per-address limit, and the key only in the function's secrets.
Step 5: show it on the page
The page asks the function, shows the card when there is one, and shows ScentSell's own notes when there is not. A React example:
function NotesAccords({ listingId, ownNotes }: { listingId: string; ownNotes: React.ReactNode }) {
const { data } = useQuery({
queryKey: ["sniff-card", listingId],
queryFn: () => supabase.functions.invoke("sniff-card", { body: { listingId } }).then((r) => r.data),
staleTime: 5 * 60_000,
});
const card = data?.ok ? data.card : null;
// No card (not linked, held back, or not loaded yet): your own notes, as before.
if (!card || card.tiers.length === 0) return <>{ownNotes}</>;
return (
<section>
<h2>Notes / Accords</h2>
{card.tiers.map((tier) => (
<div key={tier.tier}>
<h3>{tier.heading}</h3>
<ul className="notes">
{tier.notes.map((note, i) => (
<li key={`${tier.tier}-${i}`}>
{note.picture && (
<img src={note.picture} alt={note.name} width={96} height={96} loading="lazy" />
)}
<span>{note.name}</span>
</li>
))}
</ul>
</div>
))}
{card.description?.brandWords && <p>{card.description.text}</p>}
</section>
);
}
What this does on purpose:
- One set of notes on the page. The card replaces ScentSell's own notes list rather than sitting beside it, so a shopper never sees two lists that disagree.
- No label under the description, and the description only when it is the
brand's own words (
brandWords). - Pictures as they are, from the address in the card, on white tiles when the page is coloured. The page's fonts and colours go around them.
- Nothing invented. A listing whose perfume has no notes shows ScentSell's own notes; a card with no description shows no description.
Step 6: test it like any business would
Keep the key out of your shell history while you test:
read -rs SNIFF_PARTNER_KEY
Then check each of these and write down what you saw:
| Check | Expected |
|---|---|
whoami | Your business's name, and today's counts. |
capabilities | The card endpoint's status is live, and your allowance and this month's use. |
The Casafutura card with curl | A card, as on The card answer. |
| A listing linked to a perfume | The card, under Notes / Accords, on a phone and on a desktop. |
| The same listing again, within 24 hours | The card, and whoami's today.calls does not go up. |
| Two listings of the same perfume | One call between them. |
| A listing with no link | Your own notes, no error, no empty box. |
A listing linked to 00000000-0000-4000-8000-000000000000 (on a test listing) | Your own notes, and no stored copy left for that id. |
| The function called from another website, or with a fragrance id | Refused, and nothing asked of Sniffopotamus. |
| Revoke the key in the Builder Console | Listings with a copy under 7 days old keep their card; others show your own notes; your log shows revoked. |
| Create a new key and put it in your secrets | Cards refresh again. |
No perfume answers in_review today (every live perfume is public, checked 29
September 2026), so test that path with a stub; see
Errors and refusals.
If a check fails, Errors and refusals says what each code means.
Other apps testing the same way
Any business connects the same way: its own business record, its own key, the same endpoints. For example:
- A social media agency (NotRealSmart, which runs Mixpost and Zernio for its clients) can today fetch cards from its own server and build posts from the facts and the ingredient pictures itself. Sending a post from Sniffopotamus to Mixpost or Zernio as a draft is planned, not built.
- A perfume brand (Underground Parfums) can today fetch the cards of its own published perfumes for its own website. Building a new perfume's pyramid and words inside Sniffopotamus is planned, not built.
What the partner API offers lists everything that is coming.