Partner-Branded Checkout
Let your users top up their Agnic balance without leaving your brand — one deep link, no PCI scope, no payment infrastructure.
When a user in your app runs out of credit, you need to get them funded and back to generating revenue in under 30 seconds. Partner-Branded Checkout is that path — and every top-up driven through your integration is attributed to your Partner ID and accrues commission.
It's a hosted page that:
- Wears your wordmark and accent color
- Opens as a desktop popup or mobile full-page redirect
- Handles card collection, 3DS, and fraud scoring for you
- Signals your app the moment the payment clears, so your UI updates without a reload
You write ~40 lines of frontend code. Agnic owns payments, PCI scope, and reconciliation.
Why this matters for your earnings. Every top-up driven through your client_id is tagged to your Partner ID. All downstream API usage the user funds from that credit accrues commission to you at your Partner tier. Wiring up Checkout is the fastest way to turn a user's "I'm out of credit" moment into attributed revenue.
The flow
┌────────────────┐ ┌────────────────┐
│ │ 1. user clicks "Add Funds" │ │
│ Your app │─────────────────────────────────────────▶│ popup / │
│ │ │ redirect │
└────────┬───────┘ └────────┬───────┘
│ │
│ 2. Agnic Checkout loads
│ (your brand, preset chips)
│ │
│ 3. user pays securely
│ │
│ 4. 'agnic:topup_complete' postMessage / redirect │
│◀──────────────────────────────────────────────────────────┘
│
│ 5. your app refetches balance → UI updates
▼
user keeps using your productResponsibilities:
| Party | Owns |
|---|---|
| You | Open the deep link; listen for completion; refetch balance |
| Agnic | Renders the branded page; collects payment; credits the user |
You never touch card data. You never run webhook handlers.
Step 1 — Brand your Checkout page
Two fields drive the user-facing surface:
| Field | What it controls | Example |
|---|---|---|
| Display name | Wordmark in the Checkout header ("Add credit to your ___") | PixelAI |
| Accent color | Primary CTA + selected preset ring (6-digit hex) | #7c3aed |
Configure both in the dashboard:
- Go to app.agnic.ai → OAuth Clients
- Click your client → Checkout settings
- Set Display name and Accent color → Save
Changes are live on the next page load.
Step 2 — Open the Checkout deep link
URL format
https://app.agnic.ai/topup
?client_id={YOUR_CLIENT_ID}
&return_url={URL_ENCODED_RETURN}
&amount={CENTS} # optional| Param | Required | Notes |
|---|---|---|
client_id | ✓ | Your app_… client id. Must be approved and not revoked. |
return_url | ✓ | Absolute URL, URL-encoded. Must match one of the client's registered redirect_uris. |
amount | ✗ | Preset (in cents) to pre-select. If matches a package exactly, that chip is highlighted. |
Reference implementation
const handleTopUp = () => {
const returnUrl = `${window.location.origin}/your-page`;
const base = process.env.NEXT_PUBLIC_AGNIC_TOPUP_URL || "https://app.agnic.ai/topup";
const clientId = process.env.NEXT_PUBLIC_AGNIC_OAUTH_CLIENT_ID!;
const url = `${base}?client_id=${encodeURIComponent(clientId)}&return_url=${encodeURIComponent(returnUrl)}`;
// Popup on desktop, full redirect on mobile (narrow viewport).
const isNarrow = typeof window !== "undefined" && window.innerWidth < 640;
if (isNarrow) {
window.location.href = url;
return;
}
const width = 480;
const height = 720;
const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2));
const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2));
window.open(url, "agnic-topup", `width=${width},height=${height},left=${left},top=${top},popup=yes`);
};The 640px breakpoint matters: mobile browsers commonly block window.open. Anything narrower than a laptop gets a full-page redirect instead of a popup.
Step 3 — Handle the return
Two callbacks fire when the user finishes. Listen for both — desktop uses the first, mobile uses the second.
Desktop: postMessage
The popup emits a window.postMessage to window.opener the moment the payment clears. Install a listener once:
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
// Origin check: only trust messages from Agnic.
if (ev.origin !== "https://app.agnic.ai") return;
if (!ev.data || typeof ev.data !== "object") return;
if (ev.data.type === "agnic:topup_complete") {
fetchBalance();
toast.success("Top-up successful — your balance is updated.");
}
if (ev.data.type === "agnic:topup_cancelled") {
// user closed the popup without paying — no-op, or show a prompt
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);The event types Agnic emits:
type | When |
|---|---|
agnic:topup_complete | Payment succeeded; credit is applied |
agnic:topup_cancelled | User closed the popup before paying |
The popup closes itself ~2 seconds after a successful payment, so your postMessage handler is the fast path — refetch the balance there, don't wait for the window to close.
Always check ev.origin. It should be https://app.agnic.ai. This prevents a malicious tab in the same browser from spoofing a completion event.
Mobile: ?topup=success on the return URL
When Checkout ran as a full-page redirect, there's no opener to post to. Instead, it bounces the user back to your return_url with ?topup=success appended:
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
if (params.get("topup") === "success") {
fetchBalance();
toast.success("Top-up successful — your balance is updated.");
// Clean the URL so a refresh doesn't re-toast.
params.delete("topup");
params.delete("session_id");
const qs = params.toString();
window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
}
}, []);Query parameters you may receive on return_url:
| Param | Values | Notes |
|---|---|---|
topup | success | cancelled | Always present on mobile return; never present on desktop popup close |
session_id | opaque string | Only on success. Useful if you want to correlate with your own logs |
Complete reference component
Everything in one place — three effects + one handler. Drop this into any React app you already have Agnic auth in.
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
export default function YourPage() {
const [balance, setBalance] = useState<number>();
const fetchBalance = useCallback(async () => {
const res = await fetch("https://api.agnic.ai/api/balance", {
headers: { Authorization: `Bearer ${userAccessToken}` },
});
const data = await res.json();
if (data.balance !== undefined) setBalance(data.balance);
}, []);
useEffect(() => { fetchBalance(); }, [fetchBalance]);
// 1. Listen for the desktop popup's success message.
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
if (ev.origin !== "https://app.agnic.ai") return;
if (ev.data?.type === "agnic:topup_complete") {
fetchBalance();
toast.success("Top-up successful.");
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [fetchBalance]);
// 2. Handle the mobile redirect return.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("topup") === "success") {
fetchBalance();
toast.success("Top-up successful.");
params.delete("topup"); params.delete("session_id");
const qs = params.toString();
window.history.replaceState({}, "", window.location.pathname + (qs ? `?${qs}` : ""));
}
}, [fetchBalance]);
// 3. Open Checkout.
const handleTopUp = () => {
const returnUrl = `${window.location.origin}/your-page`;
const base = process.env.NEXT_PUBLIC_AGNIC_TOPUP_URL!;
const clientId = process.env.NEXT_PUBLIC_AGNIC_OAUTH_CLIENT_ID!;
const url = `${base}?client_id=${encodeURIComponent(clientId)}&return_url=${encodeURIComponent(returnUrl)}`;
if (window.innerWidth < 640) {
window.location.href = url;
return;
}
const w = 480, h = 720;
const left = Math.round(window.screenX + (window.outerWidth - w) / 2);
const top = Math.round(window.screenY + (window.outerHeight - h) / 2);
window.open(url, "agnic-topup", `width=${w},height=${h},left=${left},top=${top},popup=yes`);
};
return (
<button onClick={handleTopUp}>
Add funds {balance !== undefined && `(balance: $${balance.toFixed(2)})`}
</button>
);
}That's the whole integration. ~50 lines, zero backend changes on your side.
Security model — what this means for you
| Concern | How Checkout handles it |
|---|---|
| Arbitrary redirect (open-redirect abuse) | return_url must match a redirect_uri on your OAuth client. Rejected otherwise. |
| PCI scope for your app | Card data never touches your domain or ours at the page level. You are out of PCI scope. |
| 3DS / Strong Customer Authentication | Handled end-to-end by Checkout. If a full-page challenge is required, the user returns via return_url. |
postMessage spoofing from another tab | You validate ev.origin === "https://app.agnic.ai" in your listener. (See the code above.) |
| Replay / double-credit | Handled by Agnic; you can trust that a topup_complete corresponds to a real captured payment. |
Testing
Manual smoke-test checklist
- Desktop Chrome: popup opens, centered, 480×720
- Desktop Safari: popup opens (Safari ≥ 14 allows popups in response to a click)
- iPhone Safari: falls through to
window.location.href, full-page redirect - Android Chrome: same full-page behavior under 640 px
return_urlnot registered → error page renders, no payment session created- Click ✕ in header → popup closes,
agnic:topup_cancelledfires - Successful payment on desktop →
agnic:topup_completefires, popup closes ~2 s later - Successful payment on mobile → redirect to
return_url?topup=success&session_id=…
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "This checkout link is invalid." | client_id doesn't exist or is mistyped | Confirm the env var; check the client at app.agnic.ai |
| "This app isn't approved yet." | OAuth client is still in review | Wait for approval (typically 1 business day) |
| "The return URL isn't registered for this app." | return_url doesn't prefix-match any redirect_uri on the client | Add the origin to redirect_uris in the dashboard |
| "This app's access has been revoked." | OAuth client has been revoked by Agnic | Contact support |
postMessage never fires | Listener not installed, or ev.origin check too strict | Confirm origin is exactly https://app.agnic.ai; log all messages |
Mobile redirect returns without ?topup=… | User hit back button before completion | Treat absence as "not paid"; fetchBalance is still safe to call |
| Accent color doesn't apply | Hex isn't 6 digits (e.g. #fff) | Use 6-digit form (#ffffff); 3-digit is rejected |
| Popup blocked on desktop | window.open called outside a user gesture | Only call it from a click handler, never from setTimeout or async |
FAQ
Can I host my own Checkout UI? Not today. Hosting Checkout on Agnic is what keeps you out of PCI scope and out of payment regulatory obligations. If you have a use case the hosted page can't cover, talk to us.
Can I pre-fill an amount?
Yes — pass ?amount={CENTS}. It must match a package amount exactly, otherwise the "Popular" preset is auto-selected.
Can I change the top-up packages for my users? Not partner-by-partner. Packages are global today. If your product needs custom denominations, talk to us.
Does this work for recurring billing? No — this is one-time credit. Recurring is on the roadmap. For now, design around credits.
What happens if my OAuth client is revoked mid-flow? In-flight payments already started complete normally and users still receive credit. Only new Checkout sessions are blocked — the page begins rendering an access-revoked error.