Overview
Vyro lets a visitor unlock something on your site — an article, a download, a video — by watching an ad. Three parties are involved and it is worth being clear about which does what.
- Your page calls the SDK and receives a receipt. It must not grant access on that alone.
- Your server redeems the receipt against our API with your secret key, and grants access only if redemption succeeds.
- Vyro Browser is where the ad is shown and the receipt is signed. Outside it, every protected method rejects with
not_vyroso your page can show its own fallback.
That split exists because anything the browser decides can be forged by anyone with developer tools. The receipt is signed server-side and can be redeemed exactly once.
Accounts and access
Register with an email address and a password of at least 12 characters. Passwords that appear in known public breaches are refused — length matters more than punctuation, so a passphrase is the easy answer.
Your account starts unverified and cannot sign in until the address is verified. A link is emailed when you register; if it does not arrive, request another from the verification page. Until an account is verified it may not register a website, receive SDK credentials, or earn referral credit.
Sessions last fifteen minutes and refresh automatically for thirty days. The dashboard holds credentials in memory only and never writes them to browser storage, which has one visible consequence: reloading the page signs that tab out. That is a deliberate trade — a refresh token in local storage is readable by any script that gets into the page.
Verifying a website
Register the exact host you will serve the SDK from, with no scheme and no path. Subdomains are separate claims: verifying example.com does not verify shop.example.com.
Then prove ownership by one of three methods. All three are checked from our servers.
- DNS TXT record — add a TXT record on the domain whose value is the token. DNS changes can take up to an hour to propagate.
- HTML file — serve the token as plain text at
/.well-known/vyro-<token>.txt. - Meta tag — add
<meta name="vyro-site-verification" content="<token>">to the home page.
A failed check tells you what it actually found: no record, a record that does not match, a redirect that left the domain, a private address, a timeout. One domain may be verified by only one account, though several may hold a pending claim — so a squatter cannot block the real owner by never verifying.
Verification is re-checked daily. A pending claim not verified within seven days is rejected. A verified domain that fails fourteen consecutive days is suspended and its SDK credentials are disabled.
SDK credentials
Issued only for a verified website. Each website gets three values, and they are not interchangeable.
- Public key — safe to embed in page JavaScript. It identifies the website and authorises nothing server-side.
- API key — shown once at issuance.
- Secret key — shown once, stored only as a hash, and used only from your server. If it reaches the browser, anyone can redeem receipts for your site.
Rotating the secret keeps the previous one working for sixty seconds so requests already in flight do not fail. Neither the API key nor the secret can be recovered later; rotate to get a new one.
SDK reference
Serve /vyro.js from this origin, or install @vyro/browser-sdk. Pin it with the subresource integrity value published alongside it, so a modified bundle is refused by the browser rather than executed.
Every method except configure() returns a Promise, and none of them throws synchronously.browser() and isAdAvailable() resolve outside Vyro Browser too, which makes them the pair to call before rendering a control at all.
The SDK makes no network requests of its own. It asks Vyro Browser for an ad, because an ad request has to be signed by an attested browser install and a web page cannot hold that credential. The practical consequence is worth stating plainly: you earn from visitors who open your site inside Vyro Browser. Everyone else gets no bridge, every ad call rejects with not_vyro, and your own fallback shows instead.
| Method | Browser bridge | Rejects with |
|---|---|---|
| configure({ websiteId }): { websiteId } Names the website every ad request is attributed to. Required before any ad call: the platform pays the website an impression names, so a request without one is refused rather than credited to nobody. Synchronous, and the only method that throws — a missing id is a setup typo and should fail while you are looking at it. | not required | TypeError when the website id is missing or blank. |
| browser(): Promise<BrowserResult> Reports whether the page is inside Vyro Browser and what this build supports. The only method that resolves outside Vyro Browser, which makes it the one to call before offering any control. `verified` means the bridge answered a request for browser information — it is not proof of a genuine install and must not be used as one. | not required | Never rejects. Outside Vyro Browser it resolves with present: false. |
| requestAccess({ permissions }): Promise<AccessResult> Asks the user to grant capabilities to your origin. Defaults to ['ads'], the only grant a publisher integration needs. Call it from a click handler. | required | not_vyro, invalid_arguments, access_denied, insecure_origin, not_main_frame |
| isAdAvailable({ format, placement }): Promise<boolean> Exists so you never render a control that silently does nothing. Resolves false for every kind of no — no bridge, no grant, no inventory, no foreground window — because the question is "should I draw this button". | not required | invalid_arguments, and nothing else. Every other outcome resolves false. |
| showAd({ format, placement, contentRef }): Promise<AdResult> Requests an ad. A no-fill is a resolution with a reason attached, not a rejection: you asked whether an ad could be shown and got a definite answer. A rewarded format must be requested from inside a real click handler — the browser measures the gesture itself. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| rewarded({ placement, contentRef }): Promise<AdResult> showAd with the format fixed to rewarded: the visitor watches to completion in exchange for something. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| rewardedInterstitial({ placement, contentRef }): Promise<AdResult> A rewarded interstitial. Also earns a receipt on the unlock placement. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| interstitial({ placement }): Promise<AdResult> A full-screen placement between two pieces of content. Grants nothing, so it needs no gesture. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| banner({ placement }): Promise<AdResult> A banner placement. Available on sdk_ad only — a banner cannot unlock anything. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| native({ placement }): Promise<AdResult> A native placement, laid out by your page rather than by the browser. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| unlock({ contentRef, format }): Promise<AdResult> The content unlock flow. The receipt is the only thing that grants access, and it is worth nothing until your server redeems it through POST /v1/unlocks/redeem. A completed view with no receipt resolves receipt: null — show the fallback, not the content. | required | not_vyro, not_configured, invalid_arguments, permission_denied |
| verify({ token }): Promise<VerifyResult> Checks whether this browser issued a token. Needs no grant: the caller already holds the token, and the answer only concerns who signed it. | required | not_vyro, invalid_arguments |
| token(): Promise<TokenResult> Issues a short-lived signed token for your origin. Needs the identity grant. | required | not_vyro, permission_denied, signing_unavailable |
| refreshToken(): Promise<TokenResult> Issues another. Tokens are stateless, so a refresh is a fresh issue rather than a renewal. | required | not_vyro, permission_denied, signing_unavailable |
| download({ url, fileName, mimeType }): Promise<unknown> Queues a file through the browser's own download manager. Needs the download grant. | required | not_vyro, invalid_arguments, permission_denied |
| player({ url, title, mimeType }): Promise<unknown> Hands media to the browser's own player. Needs the player grant. | required | not_vyro, invalid_arguments, permission_denied |
| closePlayer(): Promise<unknown> Closes the player again. | required | not_vyro, permission_denied |
| share({ url, title, text }): Promise<unknown> Opens the system share sheet. Needs the share grant. | required | not_vyro, invalid_arguments, permission_denied |
| openSettings(): Promise<unknown> Opens the browser's settings. Needs the settings grant. | required | not_vyro, permission_denied |
The unlock flow
Five steps, and the last is the one people skip.
// 1. Name the website every impression is attributed to. Without this the
// request is refused rather than credited to nobody.
Vyro.configure({ websiteId: '<your website id>' });
// 2. Is this Vyro Browser at all?
const { present } = await Vyro.browser();
if (!present) return renderYourFallback();
// 3. Would an ad actually serve? Never draw a control that cannot work.
if (!(await Vyro.isAdAvailable({ placement: 'unlock', format: 'rewarded' }))) {
return renderYourFallback();
}
// 4. From a real click handler: a rewarded ad needs a real gesture, and the
// browser measures it rather than trusting the page.
const { completed, receipt, reason } = await Vyro.unlock({ contentRef: 'article-42' });
if (!completed || !receipt) return showFallback(reason);
// 5. Redeem it on YOUR server, and only then reveal the content.
const response = await fetch('/api/redeem', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ receipt }),
});
if (response.ok) revealContent();Note what step 4 does not do: it does not reject when no ad was available. A no-fill resolves with shown: false and a reason, because a page that asked whether an ad could be shown and was told no has not encountered an error. Rejections are reserved for calls that could never have worked.
And on your server, where the secret lives:
POST https://api.shortyai.cloud/v1/unlocks/redeem
Content-Type: application/json
X-Website-Secret: vyro_sec_…
{"website_id": "<your website id>", "receipt": "<receipt from the browser>"}
200 {"granted": true, "content_ref": "article-42", "redeemed_at": "…"}
409 already redeemed — do not grant againRedemption is exactly once. A 409 on a retry means the first attempt succeeded, so a network failure between the two is safe to retry: you cannot accidentally grant twice, and you cannot lose a grant.
Failure modes
A rejection carries a code, so your page can respond differently to each rather than showing one generic message.
| Code | What it means and what to do |
|---|---|
| not_vyro | No bridge on this page — the visitor is not using Vyro Browser. Show your fallback. |
| not_configured | configure({ websiteId }) was never called, so the impression could not be attributed to your account. |
| invalid_arguments | The call was malformed. The message names the argument. |
| insecure_origin | The bridge is offered to https origins only. |
| not_main_frame | Called from an iframe. Only the top-level document speaks for a site. |
| origin_not_allowed | This origin holds no grant and is not allow-listed. |
| permission_denied | The capability was never granted. Call requestAccess first. |
| access_denied | The user declined the permission prompt. |
| unknown_function | This build of the browser has no such function. Check the bridge version. |
| token_expired | The token has expired. Issue another. |
| token_invalid | The token was not issued by this browser. |
| signing_unavailable | The device could not sign right now. Retry later. |
| bridge_unavailable | The shim is present but its native channel is not. Treat as not_vyro. |
| internal_error | A defect on the browser side. Not something your page can fix. |
Every REST error uses one envelope, with a correlation ID that finds the request in our logs. Quote it if you need support.
{"error": {"code": "invalid_domain", "message": "enter a valid registrable domain name", "correlation_id": "…"}}Analytics
Nine metrics per website per day: daily users, unique visitors, browser opens, SDK calls, verification requests, ad requests, ad impressions, unlocks issued and unlocks redeemed.
These are daily rollups, not live counters, and the dashboard states how far behind it is rather than implying otherwise. A unique visitor is counted per verified install per day, never per request.
Fill rate is impressions divided by requests, per placement. A placement with no requests reports unavailable rather than zero per cent, because those are different facts: never asked is not the same as never filled.
Any range up to two years can be exported to CSV. It runs as a background job and the file is kept for seven days.
Referrals
Every account gets an invite code and a referral link. Credit is per verified browser install, at most once per install, and clicks alone earn nothing.
Referrals that look automated are held for review and excluded from your figures until resolved. The dashboard shows how many are held, so a number that looks low is explicable rather than mysterious.
The leaderboard ranks by verified referrals over the period shown. You can be excluded from the public ranking; your own rank is still shown to you.
Earnings and payouts
Revenue is recorded in an append-only ledger, and your balance is derived from it rather than stored as a total. That is why the earnings page shows four figures instead of one.
| Figure | What it is |
|---|---|
| Confirmed | Revenue a network has settled. The only part that can be withdrawn. |
| Still estimated | Reported but not yet confirmed. Deliberately not withdrawable: paying out an estimate a network later reverses would mean asking you for money back. |
| Withdrawn | Requested, approved or already paid. Paid counts permanently — the ledger records what was earned, never what was disbursed. |
| Available | Confirmed minus withdrawn. This is what you can ask for. |
Withdrawals are approved and sent by a person. There is no payment provider integration and this is not a queue that clears itself: an administrator reviews the request, sends the money through whatever rail suits your country, and records the bank’s own reference against it. You see that reference, which is how “I never received it” gets answered with something other than reassurance. A refusal always carries a reason, and the reason is shown to you.
One open withdrawal at a time per currency. You can cancel it while nobody has decided; once approved you cannot, because somebody may be part-way through sending it. There is a minimum per currency — an international transfer costs a fixed fee, and below the floor the fee would be most of the transfer. The dashboard states the floor for each of your currencies before you type an amount.
Money is sent in the currency it was earned in. There is no conversion, because a rate applied on your behalf is a rate nobody recorded.
Every monetary value in the API is minor units plus its currency and exponent — 1999 with "USD" and exponent 2 is $19.99. Never a decimal number: a float is the one thing a ledger cannot tolerate, and the wire format makes it impossible rather than discouraged. The exponent matters, because 12000 JPY is twelve thousand yen and 12000 USD is a hundred and twenty dollars.
Your payout details are encrypted, and every read is recorded. Unlike every other secret here they have to be readable — a person types an IBAN into a bank — so they are AES-GCM ciphertext with the key held outside the database, bound to your account so a copied row cannot be decrypted elsewhere. An administrator decrypts them only to send a transfer, and each decryption is a separate audited event from the approval itself. Only the masked form is ever shown back to you, or to them before they open it.
An IBAN is checked against its own mod-97 checksum before it is stored, which catches every single-character typo and every transposition of adjacent characters. Local clearing codes get a shape check only: validating an IFSC, a sort code, a BSB and a routing number properly would need four national datasets we do not have and could not keep current, and claiming more would reject valid accounts.
REST API
Four credential types, and they are never interchangeable.
| Credential | Header | Used by |
|---|---|---|
| Account session | Authorization: Bearer … | The dashboard, and your own tooling |
| Website secret | X-Website-Secret | Your server, to redeem receipts |
| Install token | X-Install-Token | Vyro Browser only |
| Administrator session | X-Admin-Session, X-Admin-Token | The admin panel. Never a bearer token. |
The endpoints an integration and your own tooling need:
POST /v1/auth/register create an account
POST /v1/auth/login -> access + refresh token
POST /v1/auth/refresh rotate the pair
POST /v1/auth/logout revoke the session
GET /v1/me the account and its invite code
GET /v1/websites your domains and credential state
POST /v1/websites register a domain
POST /v1/websites/{id}/verification-token get a token for one method
POST /v1/websites/{id}/verify run the ownership check
POST /v1/websites/{id}/credentials issue SDK credentials
POST /v1/websites/{id}/credentials/rotate rotate the secret
GET /v1/websites/{id}/analytics?start=&end= the daily report
POST /v1/websites/{id}/analytics/exports queue a CSV export
GET /v1/me/exports your exports
GET /v1/me/exports/{id}/download the CSV
GET /v1/affiliate/stats referral figures and leaderboard
GET /v1/me/tokens scoped API tokens
GET /v1/me/security-log what has happened to your account
GET /v1/earnings?currency=&start=&end= balances, and a bucketed report with CPM
GET /v1/payout-methods your destinations, masked
POST /v1/payout-methods add one; validated before it is encrypted
POST /v1/payout-methods/{id}/retire supersede one; the row is kept, not deleted
GET /v1/withdrawals your requests, newest first
POST /v1/withdrawals ask for one; minor units and a currency
POST /v1/withdrawals/{id}/cancel while nobody has decided it
POST /v1/unlocks/redeem server-to-server, website secretRate limits are per IP and the tightest are on authentication. A limited response says when it resets, except on sign-in — there, saying so would tell someone guessing passwords exactly how long to wait.
Limits and honesty
Things worth knowing before you build on this, stated here rather than discovered later.
- Withdrawals are settled by a person, not by a provider. There is no payment integration, so a request waits on a human review rather than clearing on a schedule. What that buys is that every country is reachable on day one; what it costs is that we will not promise you a settlement time we do not control.
- Estimated revenue is not withdrawable. Only what a network has confirmed can be paid out. A balance smaller than your total earnings is not an error, and the earnings page shows the difference rather than one number that needs explaining.
- Analytics are daily. There is no realtime view that reflects the last minute. The lag is displayed.
- Protected methods need Vyro Browser. Outside it they reject with
not_vyro. Your page needs a fallback for ordinary browsers, which will be most of your traffic. - Two-factor authentication is not available yet. The schema supports it; there is no interface, so it is not offered.
- Reloading the dashboard signs that tab out. Credentials live in memory only. This is a choice, not an oversight.
Ready to try it?
Register a domain and the dashboard walks you through verification.
Create an account