Integration guide
Three handlers, three environment variables, about an hour. This page explains what each one does and why it works that way — read the first two sections before writing anything.
Start from working code
The starter app implements all three handlers, a privacy page and nothing else — deploy it, paste its two URLs into your dashboard, and the integration test passes. Read this page to understand what it is doing, or clone it and start deleting.
What actually happens
Follow one customer all the way through. Say Sarah buys your app, Acme Notes.
- 1
Sarah buys on Yakura
She pays us. We record that she owns Acme Notes. Your server is told nothing, and doesn't need to be — this is the part that feels wrong at first.
- 2
She clicks Launch
Her browser goes to our launch endpoint. We check our own database: may Sarah open Acme Notes? She owns it, so yes.
- 3
We mint a ticket
A JWT valid for 60 seconds, signed with our private key. It carries her entitlement and a subject id — a pseudonym unique to (Sarah × Acme Notes).
- 4
We POST it to your server
An auto-submitting form sends yk_token to your launch URL. This is the first your app hears of Sarah.
- 5
Your server asks us who this is
You POST the ticket back to us, authenticated with your client secret. We verify it, burn it so it can't be reused, and reply with her subject and her CURRENT entitlement.
- 6
You create or find your user
Look up that subject in your own users table. Not there? Create a row. Already there? That's Sarah returning. The subject is your primary key for her.
- 7
You set your own session. She's in.
She never typed a password. You never saw her email. The launch IS the login.
- 8
Later, she refunds
We revoke her ownership and POST entitlement.revoked to your webhook. Even if that never arrives, your next entitlement check returns false.
The alternative — “purchase happens, we notify you, you mark the user paid” — has a nasty failure mode: one missed webhook and a paying customer is locked out of something they just bought, with no way for either of you to notice. Here a missed webhook is harmless. That’s the whole reason it works this way.
What you build (and don't)
You don’t build
- Signup, login, or password reset
- Payment or checkout of any kind
- A Stripe integration
- Sales tax calculation or filing
- Refunds or chargeback handling
- A billing page or invoices
You still own
- Hosting, deploys and uptime
- Your product, data model and sessions
- Three small HTTP handlers
- Product support for your users
- Your own privacy policy
Closest comparison: the Shopify App Store. The merchant installs your app, Shopify owns the billing relationship, and your app runs on your own infrastructure. We are your storefront, your login, and your licence server — not your host.
Handler 1 — accept the launch
We POST a form to your launch URL with one field, yk_token. Redeem it immediately, then create your session.
app.post("/yakura/launch", express.urlencoded({ extended: false }), async (req, res) => {
const r = await fetch("https://yakuraapp.com/api/v1/launch/redeem", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Basic " + Buffer.from(
`${process.env.YK_CLIENT_ID}:${process.env.YK_CLIENT_SECRET}`
).toString("base64"),
},
body: JSON.stringify({ token: req.body.yk_token }),
});
if (!r.ok) return res.status(401).send("invalid launch");
const { subject, entitled, tier } = await r.json();
if (!entitled) return res.redirect("https://yakuraapp.com/store/your-app-key");
// The subject IS your user key. No email, no password, no signup form.
let user = await db.users.findOne({ yakura_subject: subject });
if (!user) user = await db.users.create({ yakura_subject: subject, tier });
req.session.userId = user.id;
res.redirect("/");
});Referer header of every request your page makes afterwards. The form POST keeps it out of all three.The redeem call returns current entitlement, re-read at that moment — not the snapshot inside the ticket, which can be up to a minute old. It also burns the ticket, so a copy captured from a log is useless.
Handler 2 — check entitlement
Call this whenever access matters: on session start, before a paid action, or on a schedule. Never cached on our side, so a refund takes effect on your very next call.
| state | What it means | expires_at |
|---|---|---|
| subscribed | They pay you monthly and the subscription is live. This is the common case. | End of the period they have paid for. |
| owned | They bought it once. Access does not lapse. | Usually null. |
| free | Your app is free to enter and they have not upgraded. Entitled, at tier "free". | null |
| none | Not entitled — never bought, refunded, or the subscription lapsed. | null |
Treat entitled as the answer and state as colour. Don’t branch your access control on state — if you sell monthly today and add a one-time price later, code that checks state === "subscribed" locks out every buyer on the new plan.
Free apps with a Pro tier
If you list as Free or Free + Pro, everyone who launches from Yakura arrives entitled: true — that is the point, the app is free. On Free + Pro, what separates a paying user from a free one is tier, not entitled:
// Two different questions. Ask the right one.
if (!result.entitled) return deny(); // may they open the app at all?
const pro = result.tier === "pro"; // may they use the paid features?Gate paid features on tier === "pro" and nothing else. If you gate them on entitled, every free user gets your Pro features; if you gate basic access on tier, nobody can use your free app. When a Pro subscription lapses the same subject keeps entitled: true and drops back to tier: "free", so your app should degrade to its free experience rather than lock the user out.
const BASIC = "Basic " + Buffer.from(
`${process.env.YK_CLIENT_ID}:${process.env.YK_CLIENT_SECRET}`
).toString("base64");
async function checkEntitlement(subject) {
try {
const r = await fetch("https://yakuraapp.com/api/v1/entitlement", {
method: "POST",
headers: { "content-type": "application/json", authorization: BASIC },
body: JSON.stringify({ subject }),
signal: AbortSignal.timeout(5000),
});
if (!r.ok) return null; // unknown — see below
// { entitled, state, tier, expires_at, reason }
return await r.json();
} catch {
return null;
}
}
// Use it as middleware on anything that requires a paid account.
async function requirePaid(req, res, next) {
const result = await checkEntitlement(req.user.yakura_subject);
if (result === null) {
// We're unreachable. Fail CLOSED for privileged actions, but don't log
// everyone out — a brief outage shouldn't look like a mass revocation.
return res.status(503).send("Can't verify access right now, try again shortly.");
}
if (!result.entitled) return res.redirect("https://yakuraapp.com/store/your-app-key");
req.tier = result.tier;
next();
}| reason | What to show |
|---|---|
| not_entitled | No live entitlement — never bought, refunded, or their subscription lapsed. Send them to your Yakura store page to start or restart it. |
| unknown_subject | You've never seen this person. Ask them to launch from Yakura. |
| app_suspended | Your listing is switched off. Say the app is temporarily unavailable — not that they lack access. |
| app_draft | Not published yet. Only you should be seeing this. |
| unknown_app | Wrong credentials for this deployment. |
expires_at: on a monthly plan that timestamp is the moment their access ends unless they renew. Renewals move it forward automatically, so re-checking at (or shortly before) that time is enough — you do not need to track billing yourself.Handler 3 — receive webhooks
Handler 2 tells you what’s true when you ask. This tells you the moment it changes — which matters most for revocation, so a refunded customer doesn’t linger until your next check.
| Event | When |
|---|---|
| test.ping | You pressed Test integration. |
| entitlement.granted | This subject can use your app. Sent on purchase, on a subscription going live — and on a Pro tier changing on a free app, where access never stopped. |
| entitlement.revoked | This subject can no longer use your app at all: a refund, a chargeback, an admin revoke, or a lapsed subscription on a PAID app. |
| app.suspended | Your listing was taken off sale. |
The event name is about access; the tier field is about features. Every entitlement event carries { subject, entitled, tier, reason }, and entitled is read from the same oracle handler 2 calls — so a webhook can never disagree with an entitlement check. On a free app, a canceled Pro subscription arrives as entitlement.granted with tier: "free" and a subscription_* reason: nothing was revoked, because they can still use the free app. Downgrade on tier, and only lock out on entitled: false.
import crypto from "crypto";
app.post("/yakura/webhook",
express.raw({ type: "application/json" }), // RAW — express.json() breaks the HMAC
(req, res) => {
const header = req.get("yakura-signature") ?? "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const body = req.body.toString("utf8");
// Reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.YK_WEBHOOK_SECRET)
.update(parts.t + "." + body)
.digest("hex");
if (expected.length !== (parts.v1 ?? "").length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
res.sendStatus(200); // ACK FIRST — you have 8 seconds
if (event.type === "entitlement.revoked") lockAccount(event.data.subject);
if (event.type === "entitlement.granted") unlockAccount(event.data.subject);
});Credentials and secrets
Three values, all from your dashboard, all server-side only.
YK_CLIENT_ID=yk_live_... # identifies your app
YK_CLIENT_SECRET=... # shown ONCE when issued
YK_WEBHOOK_SECRET=whsec_... # shown ONCE when you set the webhook URL- Both secrets are shown once and stored only as a hash on our side. Lost one? Rotate it — there is no “show me again”.
- Rotation is instant. The old secret starts returning 401 the moment you rotate, so deploy the new one first.
- Your credential resolves to exactly one app. There is no way to ask us about an app that isn’t yours, which is also why no app id is needed in the request.
- Never ship either to the browser. They authenticate as your entire app.
Getting paid
Most publishers keep 80% of every sale, before any affiliate commission you choose to offer. The remaining 20% covers Stripe’s fees, sales tax compliance, the storefront, and carrying the chargeback risk. Your own rate is agreed when we approve you and is shown at the top of your dashboard — it can be higher, and it is per-app if we agree that.
You pick one of these four per app, in your dashboard under “How you charge”. Changing it later is a normal release — approval switches the shape and clears the old price, so your store page can never show two things for sale at once.
| Shape | The customer pays | You are paid your share of |
|---|---|---|
| Monthly subscription | Every month until they cancel. | The first payment and every renewal, for as long as they stay. |
| One-time purchase | Once. Access never lapses. | That sale. |
| Free + Pro | Nothing to enter. Monthly, only if they upgrade. | Every Pro payment and renewal. A free user costs you nothing and costs us nothing. |
| Free | Nothing, ever. | Nothing to pay out — you don't even need to connect payouts. |
Free + Pro is a real option, not a loophole. You host your own app, so a free user costs Yakura nothing and brings an audience to the store. Describe what Pro unlocks when you submit — that copy is what buyers read before they upgrade — and gate those features on tier === "pro", as covered under entitlement.
- In your dashboard, press Connect payouts.
- Stripe collects your identity, bank and tax details. We never see them.
- You come back. That’s the entire setup.
Payouts run weekly in USD with a $25 minimum (below that it rolls forward so fees don’t eat a small cheque). Two things are held back, both on purpose:
- The hold — 90 days from the sale, or 120 for your first six months. Longer than the 60 days a customer has to request a refund, so nothing is paid out and then clawed back.
- A 15% rolling reserve, released after 120 days. Card disputes can arrive months later. It stays yours and releases automatically — you can watch it in the dashboard.
A refund reverses the matching share. If that takes your balance negative it nets against your next sales — no invoice, nothing to pay back. Full terms in the Promotion Agreement.
Testing and submitting
These checks are real HTTP requests from our servers to yours, so your app has to be deployed at a public https address before they can pass — localhost is unreachable to us. If you haven’t built it yet, deploy the starter and come back with its URLs.
Your dashboard has a Test integration button that runs three real checks against your server. All three must pass before you can submit a listing — not as a formality, but because a listing that goes live broken costs a customer money and costs you a refund.
| Check | How to make it pass |
|---|---|
| Launch endpoint | We mint a genuine ticket and POST it to your launch URL. Return 2xx or a redirect. |
| Webhook endpoint | We send a signed test.ping. Return 2xx within 8 seconds. |
| Entitlement API | Your credentials must have authenticated at least one real call. Redeeming the test ticket counts — so this usually passes on the second run. |
Once they’re green, fill in the listing and submit. A human reviews it, usually within 2–3 business days. Your live version keeps serving while an update is in review.
Reference
Endpoints
POST https://yakuraapp.com/api/v1/launch/redeem Basic auth · { token }
POST https://yakuraapp.com/api/v1/entitlement Basic auth · { subject }
GET https://yakuraapp.com/.well-known/yakura-jwks.json public (Ed25519 signing keys)Things that will trip you up
- Your endpoints must be public. We POST from our servers, not the browser — no VPN, preview password, or IP allowlist.
- HTTPS only, both endpoints. Plain HTTP is rejected at review.
- Tokens expire in 60 seconds and are single-use. A second redeem returns
409 token_already_used— that’s replay protection working, not a bug. - Use the raw body for webhook signatures.
express.json()re-serialises and the HMAC won’t match. This is the single most common mistake. - Don’t assume the subject is an email. It’s an opaque string, and the same person has a different one in every other developer’s app.
What if Yakura is down?
Existing sessions in your app keep working — you own those. New launches fail, and entitlement checks return nothing, which is why handler 2 distinguishes “not entitled” from “couldn’t reach us”. Don’t log everyone out over a blip.
Not supported yet
A paid app that ALSO sells a Pro tier on top — today Pro is available on free apps. Trials, seat-based pricing and usage billing aren’t built either. Tell us what you need and it’ll help us prioritise.
Stuck?
Run the integration test first — it names the exact thing that’s failing far faster than we can. If it’s still unclear, reply to any Yakura email.
Open your dashboard