# Guiru — developer documentation The integration API for products listed on Guiru: identity (Sign in with Shin ID), entitlements, usage evidence and webhooks. Base URL: https://onguiru.com Authentication: `Authorization: Bearer ` Three things agents get wrong from prior knowledge of other platforms: there is no refresh token; no endpoint takes a product id (the API key is the product); the webhook signature covers the raw request body, not a re-serialised object. Rendered version: https://onguiru.com/developers/docs ## Start here ### Quickstart From an API key to a verified entitlement in four calls. Source: https://onguiru.com/developers/docs/quickstart Guiru does not host your product and does not want to know what people do inside it. The integration answers three questions and reports one fact: **who is this**, **may they use it**, **did they actually use it**. Install the SDK and the parts that are easy to get wrong — webhook signatures, retries, PKCE — are already right. It is six endpoints underneath, so plain HTTP stays a first-class way to call them; see [the SDK](/developers/docs/sdk). #### 1. Get a key Open your product's console, **Integrate**, and create a key. You will see it once — it is stored hashed and cannot be shown again. Start with a test key (`gk_test_…`): it reads real data but writes no usage evidence. #### 2. Ask whether somebody may use your product entitlement check: ```bash curl -s "$GUIRU/api/v1/entitlements?user_id=$SHIN_USER_ID" \ -H "Authorization: Bearer $GUIRU_API_KEY" ``` response: ```json { "product_id": "8f14e45f-ceea-467a-9f1c-1a0b1a2b3c4d", "user_id": "3c1a…", "active": true, "type": "purchase", "source": "stripe:pi_3P…", "ends_at": null, "test_mode": false } ``` #### 3. Report that the session was real usage evidence: ```bash curl -s -X POST "$GUIRU/api/v1/usage" \ -H "Authorization: Bearer $GUIRU_API_KEY" \ -H "content-type: application/json" \ -d '{"user_id":"'"$SHIN_USER_ID"'","kind":"activity"}' ``` This is what earns from the [Creator Pool](/legal/creator-pool) and what a [GSP](/gsp) seal is computed from. It is deduplicated per person per product per day, so calling it often is safe and pointless in equal measure. #### 4. Know when things change without polling Register a webhook endpoint in the same console and you stop asking. A purchase, a refund, a cancelled subscription and an expiry all arrive as signed POSTs. See [Webhooks](/developers/docs/webhooks). > Note: Where does `user_id` come from? From [Sign in with Shin ID](/developers/docs/sign-in), which is the only way your app learns a person's Guiru identity. If your product already has its own accounts, link the Shin ID to your user once and keep it. ### SDK One package for identity, entitlements, usage and webhook verification. No dependencies. Source: https://onguiru.com/developers/docs/sdk `@guiru/sdk` is the official client. It is a thin wrapper over the same six endpoints — nothing is hidden from you — but it carries the decisions an integration should not have to make twice. ```bash npm install @guiru/sdk # pnpm add @guiru/sdk · yarn add @guiru/sdk ``` #### Gate a feature entitlements: ```typescript import { Guiru } from "@guiru/sdk"; const guiru = new Guiru({ apiKey: process.env.GUIRU_API_KEY! }); if (await guiru.entitlements.isActive({ userId })) { // let them in } else { // your paywall, your words } // the whole answer, when you want to know why const { active, type, ends_at } = await guiru.entitlements.check({ userId }); ``` #### Report that the session was real usage: ```typescript await guiru.usage.activity({ userId }); // they did the thing await guiru.usage.heartbeat({ userId }); // still working await guiru.usage.session({ userId }); // the app opened ``` #### Verify a webhook webhook handler: ```typescript // rawBody must be the bytes that arrived, not a re-serialised object const event = await guiru.webhooks.constructEvent({ secret: process.env.GUIRU_WEBHOOK_SECRET!, rawBody: await req.text(), signature: req.headers.get("guiru-signature")!, previousSignature: req.headers.get("guiru-signature-previous"), }); switch (event.event) { case "purchase.completed": /* … */ break; case "entitlement.refunded": /* close the door */ break; } ``` #### Sign in with Shin ID oauth + pkce: ```typescript const { verifier, challenge } = await guiru.oauth.createPkce(); // keep `verifier` for the exchange; send them here const url = guiru.oauth.authorizeUrl({ clientId, redirectUri, state, codeChallenge: challenge, }); // on your callback const token = await guiru.oauth.exchangeCode({ code, redirectUri, clientId, codeVerifier: verifier }); const me = await guiru.oauth.me({ accessToken: token.access_token }); ``` #### What it decides for you - Signatures (webhooks): HMAC over the raw body, compared in constant time, inside the five-minute window, with the rotation header accepted while you redeploy. - Retries (transport): A 429 waits exactly the `retry-after` the server asked for; a 5xx backs off exponentially; a 4xx is never retried, because it would fail identically. - Errors (types): `GuiruAuthError`, `GuiruRateLimitError` (with `retryAfter`), `GuiruRequestError`, `GuiruServerError` — branch on the class instead of parsing a string. - PKCE (oauth): Verifier from a real CSPRNG and the S256 challenge, rather than whatever `Math.random` gives you. - Mistakes (guards): Passing your own user id instead of the Shin ID, or an API key where a user token belongs, fails before it costs a request. > Note: No dependencies, `fetch` and WebCrypto only: it runs on Node 18+, Deno, Bun and edge runtimes — which is where a lot of webhook handlers live. > Careful: The SDK never hides an endpoint. Everything it does is in [the reference](/developers/docs/reference), and plain HTTP remains supported for any language — a Go or Python service is not a second-class integration. ### Authentication Two credentials: a product API key for server-to-server, an OAuth token for acting as a person. Source: https://onguiru.com/developers/docs/authentication Two things authenticate against Guiru, and they are not interchangeable. Credentials: - `gk_live_…` / `gk_test_…` (product API key): Server to server. Scoped to one product — the key *is* the product, which is why no endpoint takes a product id. Never ship it in a client. - `gat_…` (OAuth access token): Acts for one person who consented. Obtained through Sign in with Shin ID. Valid 30 days, revocable by that person at any time. Both travel the same way — and [the SDK](/developers/docs/sdk) takes whichever one an endpoint needs, so the wrong credential in the wrong place fails before it becomes a request: ```http Authorization: Bearer gk_live_… ``` #### Storage Keys and tokens are stored hashed on our side; a key is displayed exactly once, at creation. There is no endpoint that reads a credential back, for you or for us. Lost key means new key. #### Revocation Revoke a key in the product console; it stops working on the next request. A person revokes your access under **Settings → Apps**, which kills every `gat_` token for that client — treat a sudden `401` as consent withdrawn, not as an outage, and send them back through sign-in. > Careful: A `gk_` key answers for **its own product only**. Passing another product's id is not a permission error, it is impossible: the id is never a parameter. ## Identity ### Sign in with Shin ID OAuth 2.0 authorization code with PKCE, two scopes, and a consent screen that names exactly what you receive. Source: https://onguiru.com/developers/docs/sign-in Authorization code flow. PKCE for anything that cannot keep a secret (desktop, mobile, SPA); a client secret for a server that can. `response_type=code` is the only response type, and `S256` the only challenge method — a request for anything else is refused rather than downgraded. #### 1. Send them to the consent screen `GET /oauth/authorize` — auth: the person's browser session. Names your product, lists what it receives, and nothing more. If they are not signed in they land on sign-in first and come back here. Query parameters: - client_id (required): Issued with your OAuth client. - redirect_uri (required): Must match one registered for the client, exactly. An unregistered URI is refused before consent, not after. - scope (optional): Space separated. `identity` and `entitlements` are the only values; anything else is dropped. Default: `identity entitlements`. - state (recommended): Echoed back untouched. Use it, and check it. - code_challenge (PKCE): Base64url SHA-256 of your verifier. - code_challenge_method (PKCE): `S256`. No plain. - response_type (optional): `code`, if sent. authorize: ```text GET /oauth/authorize ?client_id=gcl_… &redirect_uri=https://yourapp.com/callback &scope=identity%20entitlements &state= &code_challenge= &code_challenge_method=S256 ``` #### 2. Exchange the code The code comes back on your redirect as `?code=gac_…&state=…`. It lives **five minutes** and is single use — two concurrent exchanges of the same code cannot both succeed, by construction, so a retry that races itself gets `invalid_grant` rather than a second token. `POST /api/oauth/token` — auth: client credentials or PKCE verifier. Accepts form-encoded or JSON, and HTTP Basic for `client_id:client_secret`. Only `grant_type=authorization_code`. token exchange: ```bash curl -s -X POST "$GUIRU/api/oauth/token" \ -d grant_type=authorization_code \ -d code=gac_… \ -d redirect_uri=https://yourapp.com/callback \ -d client_id=gcl_… \ -d code_verifier= ``` response: ```json { "access_token": "gat_…", "token_type": "Bearer", "expires_in": 2592000, "scope": "identity entitlements" } ``` > Careful: There is no refresh token. A token is good for 30 days; when it expires or is revoked, send the person through the flow again. An integration that treats sign-in as a once-per-install event will break on day 31. #### 3. Read who they are `GET /api/v1/me` — auth: `gat_…`. Identity, plus the entitlement for **your** product when the token carries the `entitlements` scope. response: ```json { "id": "3c1a…", // the Shin ID — this is your user_id everywhere else "handle": "mario", "display_name": "Mário", "image": "https://…", "entitlement": { // only with the entitlements scope "product_id": "8f14…", "active": true, "type": "member", "source": "member", "ends_at": "2026-10-20T00:00:00.000Z" } } ``` #### What the consent screen promises It says, in these words: your Shin ID, handle and display name; whether you have access to this product; **nothing else — no e-mail, no library, no taste**. That is a promise the API keeps, so do not build a flow that needs an address you were never given. ## Access ### Entitlements One call that answers whether this person may use your product, and why. Source: https://onguiru.com/developers/docs/entitlements `GET /api/v1/entitlements?user_id=` — auth: `gk_live_…`. Server to server. The product is the key's product; `user_id` is the Shin ID. Response: - active (boolean): The only field your gate should branch on. - type (string | null): `purchase`, `subscription`, `member`, `complimentary`. Why they have it — useful for your own analytics, not for the gate. - source (string | null): Where it came from: the payment reference, `member`, or an operator grant. - ends_at (ISO 8601 | null): When it stops. `null` means it does not expire on its own. A cancelled subscription keeps `active: true` until this date. - test_mode (boolean): True when a `gk_test_` key answered. > Note: Do not compare `ends_at` yourself to decide access. `active` already accounts for it. Two clocks are one clock too many. #### It also counts as evidence A live entitlement check is recorded as usage for your analytics. Made with your API key it is your server vouching for itself, so it never counts toward a Qualified Active Day; the same check made from your app with the person's own access token (`/api/v1/me`) does. A product that only ever checks access at launch is still visible in analytics — it just says less than one that reports [usage](/developers/docs/usage). #### Member When a product is **Included with Member**, every subscriber gets `active: true` with `type: "member"` and no purchase behind it. Your gate should not care which: the whole point of the entitlement call is that access has one shape regardless of how it was paid for. #### Caching Responses are `cache-control: no-store`. Cache in your own process for minutes, not hours, and let a [webhook](/developers/docs/webhooks) invalidate it — a refund that takes an hour to close the door is a refund your support inbox hears about. ### Usage & Qualified Active Days Three generic signals. No feature events, no payloads about what happened inside. Source: https://onguiru.com/developers/docs/usage `POST /api/v1/usage` — auth: `gat_…` or `gk_live_…`. `{ "kind": "session" | "heartbeat" | "activity" }` with the person's access token, or `{ "user_id": "", "kind": … }` with your API key > Careful: Only usage sent with the person's own access token — from your app, not your server — counts toward Qualified Active Days. Sent with your API key it is kept for your analytics and answered `qualifying: false`: a user id is not a secret, so a server naming one is not evidence that the person was there. People on your own team never count on your own product. kind: - session (the app opened): Cheap to send, and on its own it never qualifies a day. Opening something is not using it. - heartbeat (still in use): Send while the person is actually working, not on a timer that runs against an idle window. - activity (they did the thing): Whatever counts as real use in your product: a render, a save, a message sent. You decide what the bar is; we never ask what it was. Deduplicated per person × product × day downstream. Calling every thirty seconds is safe; it is also worth nothing more than calling once. #### What it feeds Qualified Active Days are the unit behind two things that pay or promote: the [Creator Pool](/legal/creator-pool) split, which divides Member revenue by verified use rather than by negotiation, and the [GSP](/gsp) seal, which a product cannot earn without them. A product that never reports usage can still be listed, sold and included — it just cannot be shown to have succeeded. > Careful: We do not accept custom event names or properties, and there is no endpoint that would take them. If you want product analytics, use a product analytics tool — this is evidence, not telemetry. ## Money ### Purchases & refunds What Buy with Guiru does on your behalf, and the two events you actually handle. Source: https://onguiru.com/developers/docs/purchases With **Buy with Guiru**, checkout happens on the product page and Guiru handles payment, receipt, refund and the entitlement that follows. Your product is told about the result; it never sees a card. The sequence, for one sale: ```text buyer pays on /p/ → entitlement granted → purchase.completed (webhook) → entitlement.created (webhook) your app: GET /api/v1/entitlements → active: true ``` And for a refund or a chargeback, in the same shape — `purchase.refunded` (with `chargeback: true` when the bank decided it) and `entitlement.refunded`. Close the door when you receive it. #### External and affiliate offers A product sold on your own store or through an affiliate link carries no take rate, because Guiru is not in that transaction — and equally, none of this applies to it: no entitlement, no webhook, no Member reach, no pool. The API has nothing to say about a sale it never saw. See [pricing](/pricing). #### Payouts Money is reconciled on Guiru's side and settled to the builder; there is no payouts endpoint, and the ledger is visible in your product console rather than through the API. ### Webhooks Six events, one envelope, at-least-once delivery with exponential backoff. Source: https://onguiru.com/developers/docs/webhooks Register an endpoint in your product console and subscribe it to the events you want. Only public URLs are accepted — a localhost or private-range target is refused at delivery time, so use a tunnel while developing. #### The envelope ```json { "id": "0a4b…", // unique per delivery attempt group: your idempotency key "event": "purchase.completed", "created_at": "2026-09-20T22:14:03.114Z", "data": { "purchase_id": "…", "user_id": "…", "product_id": "…", "gross_minor": 4990, "currency": "BRL" } } ``` Headers: - guiru-event (string): The event name, so you can route before parsing. - guiru-signature (`t=…,v1=…`): HMAC-SHA256 over `timestamp.body`. See [verifying](/developers/docs/signatures). - guiru-signature-previous (`t=…,v1=…`): Present only during a secret rotation window — signed with the old secret so a receiver that has not redeployed yet still verifies. - user-agent (`Guiru-Webhooks/1.0`): Not an authentication mechanism. Never treat it as one. #### Events - `entitlement.created`: Someone gained access: a purchase cleared, a subscription started, Member opened the product, or an operator granted it. - `entitlement.canceled`: Access was cancelled. `ends_at` may be in the future — a cancelled subscription usually runs to the end of its period. - `entitlement.expired`: Access ran out on its own. - `entitlement.refunded`: Access was withdrawn because the money went back. - `purchase.completed`: A Buy with Guiru payment cleared. Carries `gross_minor` and `currency` — the currency the buyer actually paid in. - `purchase.refunded`: A purchase was refunded, or lost a chargeback (`chargeback: true`). - `webhook.test`: Sent when you press *Send test* in the console. Same signature, same envelope; `data.message` says it is a test. Handle it by acknowledging with 2xx and doing nothing. #### Delivery Delivery is **at-least-once**. We wait up to ten seconds for a response; any 2xx is success, anything else is a failure and is retried with exponential backoff: **1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours**, then the delivery is marked dead and stops. Recent deliveries, their status codes and their errors are listed in the product console. > Careful: Answer 2xx as soon as you have durably accepted the event, and do the work after. A handler that runs a slow job before responding turns a 200 into a timeout, and a timeout into four more copies of the same event. #### Idempotency Key on `id` from the envelope and ignore an event you have already applied. Order is not guaranteed either — an `entitlement.created` can land after the `entitlement.canceled` that followed it — so make each handler state-setting rather than state-advancing, or re-read the entitlement and trust that instead. ### Verifying signatures HMAC-SHA256 over timestamp and raw body, compared in constant time, inside a five-minute window. Source: https://onguiru.com/developers/docs/signatures The `guiru-signature` header is `t=,v1=`. The signed message is the timestamp, a dot, and the **raw request body** — the exact bytes we sent, before any JSON parsing or re-serialisation by your framework. Node / TypeScript: ```typescript import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret: string, rawBody: string, header: string, toleranceSec = 300): boolean { const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("="))); const ts = Number(parts.t); if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec || !parts.v1) return false; const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(parts.v1, "hex"); return a.length === b.length && timingSafeEqual(a, b); } ``` Python: ```python import hmac, hashlib, time def verify(secret: str, raw_body: bytes, header: str, tolerance_sec: int = 300) -> bool: parts = dict(kv.split("=", 1) for kv in header.split(",")) try: ts = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - ts) > tolerance_sec: return False expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` > Careful: Read the body as text or bytes before your framework parses it. `JSON.stringify(req.body)` is a different string from what we signed — key order and whitespace survive the wire and do not survive a round trip. #### The five-minute window Reject a signature whose timestamp is more than five minutes away from now, in either direction. That is what makes a captured delivery useless to replay, and it is why an endpoint with a badly wrong clock fails every verification while looking perfectly healthy. #### Rotating the secret During a rotation window, deliveries carry both `guiru-signature` (new secret) and `guiru-signature-previous` (old). Accept either while you deploy, then drop the old one. Nothing breaks if you roll slowly; everything breaks if you roll a secret with no window and one instance still holding the old value. ## Selling ### Importing a product Paste a URL; Guiru reads what is already public and you confirm what becomes the listing. Source: https://onguiru.com/developers/docs/importing You already described your product once — on its website, in its repository, in a store. Guiru reads that instead of asking you to type it again. Open **Builder → Submit**, paste the address, press **Import product**. #### What gets read - Any website (Open Graph, title, description, icons, images, canonical URL): JSON-LD (`SoftwareApplication`, `Product`) when the page publishes it: name, description, operating system, a public price. - GitHub repository (name, description, homepage, topics, licence, owner, releases): The README's first screenshot and first paragraph. When the repository declares a homepage, that page is read too and wins for logo, screenshots and the store link. - Any other public page (title, description, images, platform): Read by the generic importer. There is no store-specific integration: a Steam or itch.io address is read as a web page, and what it gives up is what its Open Graph tags say. - Amazon (title, cover, authors): Only through the Product Advertising API when the instance has it. Never scraped. #### What happens next Nothing is published. You see **what Guiru found**, drawn like a product card, with *Check this* next to anything the source was vague about. You fix what needs fixing, answer one question — *how do people get it?* — and submit. It enters the same review as every other product. > Note: A found price is a suggestion. Nothing is put on sale until you confirm *Sell for … on Guiru*. The importer never creates an offer, a claim or a listing on its own. #### Already on Guiru? Before anything is created, the address, its host, the repository and known external ids are checked against the catalogue. A match shows *This may already be on Guiru* with the right door: **claim it** if it is yours and unclaimed, **edit it** if you already own it, or tick *it's a different product* and continue. The importer never claims on your behalf — proving control of a domain or a repository is still yours to do. #### When the import fails The reason is said in words — *this looks like a private or local address*, *the site answered HTTP 503*, *that address is a PDF, not a web page*, *GitHub is rate-limiting us* — and **Start manually** is always one click away with the address prefilled. An import that fails is a reason to type, not a wall. #### Safety Every fetch goes through the same guard as every other outbound request Guiru makes: public hosts only, loopback and private ranges refused, redirects checked hop by hop, an eight-second timeout, a 600 KB body limit, and the content type must be what was asked for. Pictures are referenced, not copied. ### GitHub import What a repository gives the listing, what it does not, and how a repository stays connected. Source: https://onguiru.com/developers/docs/github-import Paste `https://github.com/you/thing`. The repository is read through GitHub's public API — no sign-in, no permission asked of you, public repositories only. #### Repository ≠ product A repository is where code lives; a product is what somebody uses. The importer keeps them apart: the repository becomes the **Repository** link and an identity Guiru remembers (so a second import of the same repository is recognised), while the **homepage** the repository declares becomes the product's address when there is one. Without a homepage the repository page is the address, and the owner's avatar stands in for a logo until you replace it. Filled in: - Name (repository name, humanised): Marked *Check this* — `my-cool-tool` is rarely the product's name. - Description · tagline (the repository description, or the README's first paragraph): Marked *Check this* when it came from the README. - Tags (topics): up to twelve - Screenshots (images in the README): badges and CI shields are skipped - Platforms (from release assets (`.dmg`, `.exe`, `.AppImage`, `.apk`) and topics (`cli`, `extension`)): Marked *Check this* when nothing said - Licence (SPDX id): shown on the preview - Releases (the last five, with assets): Kept with the import so a future release sync has somewhere to start. Not shown on the listing yet; nothing is hosted or mirrored. #### Connected repositories Listing your GitHub repositories and picking three is deferred: it needs a broader GitHub permission than sign-in asks for today, and asking for it is a decision about your account, not a feature flag. URL import covers the same ground one repository at a time. > Careful: Rate limits: unauthenticated, GitHub allows 60 requests an hour per address, and one import uses up to three. On a shared instance that can run out; the error says so and the manual form is right there. Operators can set `GITHUB_TOKEN` to raise the limit — it changes nothing else. ### Selling without the SDK Buy with Guiru works before any integration exists. What each delivery method needs from you. Source: https://onguiru.com/developers/docs/selling-without-sdk The SDK, the entitlement API, OAuth and webhooks make a product *more integrated*. They are not what makes it *exist*, and they are not what lets it take money. A price and a payout account do that. #### How buyers get in - Send them to a URL (no integration): After paying, the buyer is sent to your URL. Most web apps start here. Check who they are later with Sign in with Shin ID if you want to. - Download (no integration): After paying, the buyer is sent to your download page. - License key (no integration): You deliver keys through your own channel; the purchase is recorded here and visible in your console. - My app checks with Guiru (API key or OAuth): Your app asks whether a user is entitled and gets the truth: purchase, subscription, Member, or nothing. This is the path for anything that must not be shareable. Pick one when you submit; change it any time under **Store page → Offers**. The console says what the next necessary step is for the method you chose, and nothing else. #### What is always required to sell A listed product, a price in a currency, a **commercial operator** — the product's team owner, or its finance role, or a verified builder where there is no team yet — and a Stripe payout account that can charge on *that* account. Public creator credit and commercial authority are separate on purpose: a product whose founder is still credited but whose team has moved on pays the team, not the founder. The console shows these four as **Selling**, ticked or not, and the first moment all four are true is recorded as *checkout-ready*. > Careful: Guarantees are not weakened for products that need them. A product delivered through the entitlement API still requires a live key or OAuth before the console calls it ready — a buyer who cannot get in has not been sold anything. #### Growing into the integration Start with a URL. When you want the app itself to know who paid, add [an entitlement check](/developers/docs/entitlements) — one call. When you want to be told about purchases as they happen, add [a webhook](/developers/docs/webhooks). When you want Member revenue, [send usage](/developers/docs/usage). Each step is independent, and none of them is a prerequisite for the previous one. ### Currencies and pricing Price in USD, BRL, EUR or GBP; add explicit prices in other currencies; nothing is ever converted for you. Source: https://onguiru.com/developers/docs/currencies Every amount on Guiru is an integer number of minor units **with its currency**. A price is `1999 USD`, not `19.99`. Nothing is stored as a float and nothing is converted at page load. #### Choosing the offer currency A new Buy with Guiru price defaults to **USD**. Pick another from the list — BRL, EUR, GBP on a standard instance; the code accepts any ISO 4217 code an operator enables. Type the price the way you write it: `19.99`, `19,99` and `1.999,00` all mean what you meant, parsed by the currency's rules. #### Prices in other currencies This is **multi-currency pricing with explicit prices**, not automatic regional pricing: Guiru does not look at where a buyer is, does not convert, and does not adjust for purchasing power. Under *Also sell in other currencies* you can give an explicit price per currency. Explicit means typed by you: Guiru never derives one from another, and never adjusts one for purchasing power. A buyer who opens checkout in a currency you priced pays that; one who asks for a currency you did not price is **refused**, not quietly charged in another — the page and the checkout always agree on the currency. Asking for nothing is not asking for a currency, and lands on the base price. Where a buyer's currency comes from: the `currency` parameter on [the buy link](/developers/docs/buy-link), or the switch on the product page when several are available. #### Stripe One Stripe Price exists per (offer, currency). It is created the first time that combination checks out and cached. Changing the amount drops the cached Price, so the next checkout creates the right one — a Price is never reused for a different amount or a different currency. #### Existing prices A product priced in BRL before this existed is still priced in BRL: `R$ 49,90` is `4990 BRL` and checks out in BRL. Nothing was converted when the default changed. The card, the product page, checkout, the receipt and your dashboard all format an amount from its own currency, so a BRL sale and a USD sale sit next to each other as two numbers, not one wrong sum. #### Member and Pro Guiru's own subscriptions have a **price book**: an explicit monthly amount per currency, set by the operator, each with its own Stripe Price. The plan page shows the default currency and lets a viewer switch to any the book has; a subscriber keeps renewing in the currency they joined in. Member revenue funds a Creator Pool **per currency**: USD memberships pay USD to the builders whose products they used. what a builder sees: ```text Buy with Guiru $19.99 base: USD Also sell in R$ 99,90 explicit, typed by you €18.99 explicit, typed by you GBP — not priced: a GBP buyer pays $19.99 ``` ### Buy with Guiru from your own site One link. The price, the checkout, the receipt and the entitlement are Guiru's; the click starts on your page. Source: https://onguiru.com/developers/docs/buy-link You do not need a script, a widget or a framework to sell through Guiru from your own site. You need a link. `GET /buy/` — auth: none — the buyer signs in if they have to. `?src=` names where the click came from. It is normalized into a campaign slug of at most 24 characters and the raw value is kept separately, so your attribution stays a dimension you can group by. `?currency=EUR` opens checkout in a currency you priced; a currency you did **not** price, or a code that is not ISO 4217, is refused and the buyer lands on the product page with the reason — never on a checkout in a different currency from the one they asked for. No `currency` at all means the base price. the whole integration: ```html Buy on Guiru ``` with the On Guiru badge: ```html On Guiru ``` #### What happens on the click Guiru checks, server-side, that the product can be bought right now: listed, an active price, a commercial operator, a payout account that can charge. If any of those is false the buyer lands on the product page with the reason, never on a checkout that would fail. If the buyer is not signed in they sign in and come back. Then the same `Buy with Guiru` checkout the store uses opens, in the offer's currency, and the same entitlement is granted when it clears. #### Analytics The click is recorded as an offer click with source `buy-link:` before anything else happens, signed in or not, so your dashboard shows where sales start. On the Stripe side the purchase carries `guiru_source` (a closed set: `buy_link`, `website`, `docs`, `github`, `launch`, `email`, `social`, `api`, `store`, `other`), `guiru_campaign` (your normalized slug) and `guiru_campaign_raw` (what you actually sent). The dimension is bounded so one campaign cannot shard a revenue report into thousands of rows. Nothing about the visitor is read from your page; the referrer is not used. > Note: The badge only renders *On Guiru* while the product is listed. If a listing is ever suspended, every copy of the badge on the internet says so — a badge that keeps making a claim after it stopped being true is worse than none. #### What this is not It is not a checkout on your domain, not an embedded card form, not a JavaScript SDK. Those exist elsewhere and are how a checkout ends up with a different price than the store. The link keeps one price, one receipt and one refund path. ## Operating ### Integration diagnostics What the console can tell you about your integration, and what you can fix from it without asking anyone. Source: https://onguiru.com/developers/docs/diagnostics Open your product's **Integration** tab. The first panel is Integration health: one line per surface, from data Guiru already has. No inference, no model, no score. - API keys (live / test, last request): *Healthy — last request 12 min ago*. *Live key created, never used*. *Test key only* is a warning only when the product actually gates access with the API. - Sign in with Shin ID (configured, last sign-in): *Configured — no sign-in yet* is not a problem. - Entitlements (last check): *No successful entitlement request has been received with the live API key* — exactly that, when it is the case. *Not needed for this delivery method* when buyers are sent to a URL. - Usage (signals in 30 days, last one): *No activity received yet* stays neutral unless the product is Included with Member, where usage is what pays you. - Webhooks (last success, failures): *Last delivery returned HTTP 401* rather than *webhook error*. Retrying and dead deliveries are counted. - Payouts (Stripe Connect): Only with a paid offer: *No payout account*, *onboarding not finished*, *ready to receive money*. #### What you can do from there - Retry (a dead delivery of your product): The same payload, back in the queue; delivered within a minute. Safe by contract: delivery is at-least-once. - Send test (an endpoint of your product): A real `webhook.test` delivery, signed with your secret, sent by the same worker, visible in the same list with its status code. Five per ten minutes. - Rotate (an API key): A new key shown once; the old one keeps working for 48 hours so a deploy has time to land. - Refresh Stripe status (your payout account): Asks Stripe again whether charges are enabled. Every action writes the same audit row an operator's would. Who may: a verified builder of the product, or a team member with the *developer*, *admin* or *owner* role. Nothing here reaches another product or an operator capability. #### When it is still wrong *Something wrong? Tell us* on the health panel files a report with your route, build, viewport and a snapshot of this panel attached, so the person who reads it does not start by asking what you see. That is the door for integration problems; product reports (spam, fraud) go through Trust & safety. ### Errors & rate limits What each status means, and the budgets that produce a 429. Source: https://onguiru.com/developers/docs/errors Errors are `{ "error": "" }` with a meaningful status. The OAuth token endpoint uses the OAuth 2.0 codes instead — `invalid_grant`, `invalid_client`, `unsupported_grant_type` — because a client library expects them. Statuses: - 400 (bad request): A missing or malformed parameter: `user_id` that is not a UUID, a `kind` outside the three. Retrying unchanged will fail identically. - 401 (not authenticated): Missing, unknown, revoked or expired credential. For a `gat_` token, treat it as consent withdrawn and re-run sign-in. - 404 (not found): The token resolves to a user or client that no longer exists. - 429 (rate limited): Carries `retry-after` in seconds. Wait that long — not less. - 5xx (our fault): Retry with backoff. Usage and entitlement calls are safe to repeat; deduplication is on our side. #### Budgets Fixed windows, shared across all instances: - `/api/v1/*` reads (600 / minute): Counted per credential, so a noisy deployment cannot spend another product's budget. - `/api/oauth/token` (60 / minute): Counted per `client_id`, which is what keeps a misbehaving integration from brute-forcing codes on everyone's behalf. - search (mobile API) (120 / minute): Per caller. Windows are fixed, not sliding: the counter resets at the boundary and `retry-after` tells you when that is. There is no burst allowance to discover by probing. > Note: 600 reads a minute is a lot for an integration that checks an entitlement at session start and caches it. If you are anywhere near the limit, you are probably calling per request instead of per session. ### Testing Test keys read real data, write no evidence, and let you force either answer. Source: https://onguiru.com/developers/docs/testing A `gk_test_…` key is not a sandbox with fake data. It hits the same database and returns the same truth — it simply refuses to write usage evidence, so wiring up an integration never pollutes anyone's Qualified Active Days or the pool. #### Forcing an answer Both branches of your gate need testing, and buying your own product to test the happy path is a poor use of an afternoon: ```bash # force an active entitlement curl -s "$GUIRU/api/v1/entitlements?user_id=$ANY_UUID" \ -H "Authorization: Bearer gk_test_…" \ -H "X-Guiru-Test-Entitlement: active" # force the locked path curl -s "$GUIRU/api/v1/entitlements?user_id=$ANY_UUID" \ -H "Authorization: Bearer gk_test_…" \ -H "X-Guiru-Test-Entitlement: none" ``` The header is honoured **only** for test keys; on a live key it is ignored, not an error. Every response carries `test_mode`, so an integration can refuse to run in production against a test key rather than quietly serving a forced answer to real people. #### Webhooks, locally Deliveries only go to public URLs, so point the endpoint at a tunnel while developing. The console lists the last deliveries with status code and error, which is usually faster than reading your own logs to find out whether the problem is you or us. ### Mobile API The first-party surface the Guiru app talks to. Documented so it is not a mystery, not so you build on it. Source: https://onguiru.com/developers/docs/mobile > Careful: This is a **first-party** surface for the Guiru app, on the `mobile` scope. It is documented because an undocumented API that anyone can observe is worse than a documented one — not because it is a stable contract for third parties. It changes with the app. `GET /api/v1/mobile/home` — auth: `gat_…` (mobile scope). The store home, resolved server-side: shelves, capsules and the call to action already decided, so a client never guesses where a transaction happens. `GET /api/v1/mobile/search?q=&category=&platform=&page=` — auth: `gat_…`. Capped page sizes. Rate limited at 120/minute. `GET /api/v1/mobile/library?group=using|want_to_try|tried|stopped_using|entitled` — auth: `gat_…`. What the person has, in the groups the app shows. `GET /api/v1/mobile/products/` — auth: `gat_…`. One product, with its offers and seals resolved. `GET /api/v1/mobile/notifications` — auth: `gat_…`. `POST` to the same path marks read. `POST /api/v1/mobile/devices` — auth: `gat_…`. Register for push. `DELETE` stops it. Revoking the token under Settings → Apps signs that device out. Also here: `/relationship` and `/interest` (what somebody is using, wants, has stopped using) and `/account`. Payloads are bounded by design — there is no endpoint that returns the catalogue. ## Reference ### Reference Every public endpoint, its credential and its shape, on one page. Source: https://onguiru.com/developers/docs/reference #### Identity `GET /oauth/authorize` — auth: browser session. Consent screen. `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `code_challenge_method=S256`. `POST /api/oauth/token` — auth: client secret or PKCE verifier. `grant_type=authorization_code` only → `{ access_token, token_type, expires_in, scope }`. Code lives 5 minutes, single use. `GET /api/v1/me` — auth: `gat_…`. → `{ id, handle, display_name, image, entitlement? }`. #### Access `GET /api/v1/entitlements?user_id=` — auth: `gk_…`. → `{ product_id, user_id, active, type, source, ends_at, test_mode }`. `POST /api/v1/usage` — auth: `gk_…`. `{ user_id, kind: session|heartbeat|activity }` → `{ ok, counted, qualifying, test_mode }`. With `gk_…`, usage is recorded only for someone currently signed in to your product with Shin ID or holding an entitlement to it (otherwise `counted: false` with a `reason`), and is never `qualifying`. With the person's `gat_…` token, `user_id` is not sent and the report qualifies. #### Selling `GET /buy/?src=¤cy=` — auth: none (buyer signs in). Buy with Guiru from your own site. Availability is checked server-side; the checkout is the store's. See [the buy link](/developers/docs/buy-link). `GET /p//badge` — auth: none. The On Guiru badge as SVG; says *Not listed* the moment that becomes true. #### Events `POST your endpoint` — auth: `guiru-signature`. `{ id, event, created_at, data }`. Seven events (six real, plus `webhook.test`), at-least-once, 1m/5m/30m/2h/12h backoff. #### Health `GET /api/health` — auth: none. Liveness. `/api/ready` additionally checks the database. #### Scopes - identity (OAuth): Shin ID, handle, display name, avatar. - entitlements (OAuth): Adds `entitlement` to `/api/v1/me` — for **your** product only. - mobile (first party): The Guiru app's surface. Not granted to third-party clients. ### Versioning & changes One version, additive changes, and what counts as breaking. Source: https://onguiru.com/developers/docs/versioning `/api/v1` is the only version. There is no date header, no version parameter and no per-client pinning — the surface is small enough that a second scheme would cost more than it protects. #### Additive, and what that allows us New fields appear in responses without notice, and new events appear in the webhook list without notice. Both are safe **if** you ignore what you do not recognise: parse permissively, switch on the event names you handle, and let the rest fall through. An integration that throws on an unknown field has made an additive change into an outage. #### What we treat as breaking Removing or renaming a field, narrowing an accepted value, changing what a status code means, or changing the signature scheme. Those would ship as `/api/v2` with both live during a migration window — they have not happened, and `v1` is the only version that has ever existed. #### Deprecations Announced on this page and in the product console before anything stops answering. Credentials are the exception in the other direction: a key you revoke stops working immediately, on purpose. ### For agents An MCP server and a plain-text build, so the agent writing the integration reads the current docs. Source: https://onguiru.com/developers/docs/agents Most integrations are now written by a person and an agent together, and the agent is reading a copy of an API reference pasted into a prompt — which is how an integration gets written against last quarter's shape. There are two ways to stop that here, and both are generated from the same source these pages render. #### The MCP server A Model Context Protocol server over Streamable HTTP. It lets an agent **ask** rather than swallow the whole document: find the page about signatures, read that page, list the endpoints. claude_desktop_config.json / .mcp.json: ```json { "mcpServers": { "guiru": { "type": "http", "url": "https://guiru.app/api/mcp" } } } ``` Claude Code: ```bash claude mcp add --transport http guiru https://guiru.app/api/mcp ``` Tools: - search_docs (query, limit?): The pages that answer a question, with an excerpt and the slug to read in full. - get_doc (slug): One page, whole, with its code examples. - list_docs (—): Every page in reading order, with summaries. - list_endpoints (—): Every documented endpoint: method, path, credential, purpose. - get_schemas (name?): The exact shape of every request and response: entitlement, usage, identity, token, webhook envelope. - list_events (—): Every webhook event with the delivery guarantees a handler must respect. - list_errors (—): Every status, what causes it, and whether a retry can help. - review_integration (code): Checks code or a description against the mistakes we see most: re-serialised bodies, `==` on signatures, missing timestamp window, comparing `ends_at`, ignoring `retry-after`. - get_full_docs (—): Everything at once, the same text as llms.txt. > Note: No key and no account: it serves exactly what `/developers/docs` already serves to anybody with a browser. It reads no session, touches no database and has no write tool — there is nothing in it that could act on your products. A server that *can* act on an account is a different thing, and it would need consent before it existed. #### Or just the text If your tool does not speak MCP, the whole documentation is one plain-text document: ```text GET /developers/docs/llms.txt ``` No key, no HTML to parse. The page you are reading and the text an agent fetches cannot disagree, because there is only one source. #### What an agent should be told Three things are worth saying explicitly in a prompt, because they are the mistakes an agent makes from prior knowledge of other platforms: there is **no refresh token**, there is **no product id parameter** (the key is the product), and the webhook signature covers the **raw body**, not a re-serialised object. The MCP server says all three in its own instructions, so a client that reads them starts from the right place — and `review_integration` catches them in code afterwards, which is the version that survives a long session. The shortest useful prompt: *install `@guiru/sdk`, call `review_integration` on the handler when you are done*. The SDK removes the mistakes by construction; the tool catches them when somebody writes the HTTP by hand.