Zevron for apps: setup guide
Last updated
Connect web billing, send your app’s events from your server, and check that web chargebacks, App Store refund requests and failed renewals are covered. About an afternoon, most of it on your backend. Code for Swift, Kotlin, React Native and the web.
1.How the pieces fit
Four things talk to Zevron. Your web billing (Stripe) tells us about chargebacks and failed renewals. Your server tells us what customers did in the app. Apple tells us when a customer asks for a refund on an In-App Purchase. And you tell us, once, what your customers agreed to.
- Create a workspace at zevron.ai/start and connect Stripe (we read disputes and payments, and submit the evidence you approve).
- Create a usage key. Keep it on your server.
- Add one route to your backend that forwards events (the relay below).
- Call it from the app and the website:
session_start,feature_usedandpurchasefirst. - Optional: connect the App Store and choose how consent works.
- Open the Apps page in Zevron and watch the checklist turn green.
This is packaging of things Zevron already does for any subscription business: the usage API, Compelling Evidence 3.0, App Store refund answers and failed-payment recovery. Nothing here needs a special SDK.
2.Connect web billing
In Zevron, open Settings, Connections, and connect Stripe with one click or with a restricted key. We read disputes, charges, customers and invoices, and upload evidence and submit the responses you approve; we never move money or change a price.
Paddle and RevenueCat Web Billing are in early access. Tell us when you sign up and we work your web chargebacks from the dispute emails you forward until they open. If you use RevenueCat to manage subscriptions, you can also connect it for subscriber history next to Stripe.
3.Create a key and keep it on your server
In Settings, Connections, Your product (usage API), choose Create key. It starts zv_live_ and is shown once. Store it as a server secret, ZEVRON_KEY.
Never ship the key inside the app. A binary can be unpacked in minutes, and anyone with the key can write events into your account, including events that would help a fraudulent dispute. The app sends events to your own backend, which authenticates the user, adds the customer and IP, and forwards them. That is the relay below.
The full reference for keys, batches, retries and limits is the usage API page. Up to 500 events per request, and an event with an id is stored once however many times you send it.
4.What to send
Send a line for what a customer does, not everything they do. A bank and Apple both ask the same questions: did they agree, did they use it, was it them. These events answer them. The first three are enough to start.
| Event | Send it when | Fields | Why |
|---|---|---|---|
session_startStart here | The app or the web product opens, once per session. | email, user_id, ip, device_id | Shows the customer came back after the charge. Logins and sessions are the proof most disputes turn on. |
login | Someone signs in. | email, user_id, ip, device_id | Same as a session, and the cleanest sign that the account owner is the cardholder. |
feature_usedStart here | A lesson, a workout, a match, a prompt: the thing they pay for. | email, user_id, device_id, properties.feature | Banks and Apple both ask whether the product was used. This answers with dates and counts. |
purchaseStart here | A subscription starts or renews on the web, or an In-App Purchase completes. | email, user_id, ip, device_id, properties.payment_intent (web) or properties.transaction_id (App Store), properties.app_account_token | Ties the buyer's IP and device to the exact charge, which is what Compelling Evidence 3.0 matches against. |
terms_accepted | The customer agrees to the price and renewal at your paywall or checkout. | email, user_id, ip, device_id, properties.terms_version, properties.disclosure_text, properties.checkbox | The first argument in every renewal response: they agreed, and saw the terms. |
consumption_consent | The customer accepts your terms for sharing usage data with Apple. | user_id or properties.app_account_token | Apple's refund process requires the customer's consent. Without a record, we send nothing. |
consumption_consent_withdrawn | They withdraw that consent. | same as consumption_consent | A withdrawal always wins. We stop sharing at once. |
Every event can carry properties.platform (ios, android or web) so you can tell where it came from in your own reports.
5.Match events to customers
A web chargeback names a card and an email. An Apple refund request names a transaction and, if you set one, an appAccountToken. Your events need to carry whichever the dispute will name.
| For | We match on | Which is |
|---|---|---|
| A web dispute | email on the event | The email on the Stripe payment. Use the address the customer gave at web checkout. |
| A web dispute (fallback) | properties.payment_intent | The Stripe payment itself. Send it in your purchase event to tie the buyer's IP and device to that charge. |
| An App Store refund request | properties.app_account_token, or user_id set to it | The appAccountToken you passed to StoreKit when the purchase was made. Apple sends it back with the request. |
| Both, for one customer | email and the same user_id on every event | Use one stable ID per account as the appAccountToken (a UUID you keep per user). Then a customer who bought on the web and on iPhone is one person. |
The simplest pattern: give every account a UUID when it is created, use it as the appAccountToken on every StoreKit purchase, and send it as user_id (and in properties.app_account_token) on every event, together with the account’s email. The relay below does exactly that from your session.
On iOS the token is set at purchase time: product.purchase(options: [.appAccountToken(accountId)]). Apple sends it back on every transaction and refund request for that purchase. A purchase made without one cannot be matched by token, only by transaction ID, so set it before you ship.
6.The relay and the client code
Start with the server relay, then add the client for each place the customer uses your product. Tabs marked as running in the app post to your backend. Only the server tabs mention the key.
Runs on your serverThe one place the key lives. It checks the event name, takes the customer from your session, and adds the IP.
// Your backend (Express). The ONLY place the Zevron key lives: set ZEVRON_KEY as a server secret.
import express from "express";
import net from "node:net";
import rateLimit from "express-rate-limit";
const app = express();
app.use(express.json({ limit: "20kb" }));
// Only these names go through, so a client cannot make up its own.
const ALLOWED = new Set(["session_start", "login", "feature_used", "purchase", "terms_accepted", "consumption_consent", "consumption_consent_withdrawn"]);
// Only these properties go through, and only as short strings. A client must not be able to name a Stripe charge
// (payment_intent, charge_id...) or set the account token: those come from your webhook and your session.
const PROPS = new Set(["feature", "terms_version", "disclosure_text", "checkbox", "consented", "transaction_id", "original_transaction_id"]);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Per signed-in customer, so one account cannot flood your Zevron events.
const limit = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, keyGenerator: (req) => String(req.user?.id ?? req.ip) });
app.post("/analytics/zevron", requireSession, limit, async (req, res) => {
const { id, event, at, device_id, properties } = req.body ?? {};
if (typeof event !== "string" || !ALLOWED.has(event)) return res.status(400).json({ error: "unknown event" });
if (typeof id !== "string" || id.length > 100) return res.status(400).json({ error: "id must be a string" });
if (typeof device_id !== "string" || device_id.length > 100) return res.status(400).json({ error: "device_id must be a string" });
// A time in the future, or one that is not a time, is dropped: the server's clock is used instead.
const when = Date.parse(at);
const time = Number.isFinite(when) && when <= Date.now() + 60_000 ? new Date(when).toISOString() : new Date().toISOString();
const props = {};
for (const [k, v] of Object.entries(properties ?? {})) if (PROPS.has(k) && typeof v === "string") props[k] = v.slice(0, 2000);
// The customer comes from YOUR session, never from the request body.
// The IP is the phone's or browser's address as your server saw it. Trust X-Forwarded-For only when your own proxy sets it
// (in Express: app.set("trust proxy", 1)), then req.ip is the right value.
const ip = req.ip && net.isIP(req.ip) ? req.ip : undefined;
const r = await fetch("https://zevron.ai/api/v1/events", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ZEVRON_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
id: `${req.user.id}:${id}`, // retries are ignored, not duplicated; namespaced so one customer cannot collide with another
event,
at: time,
email: req.user.email, // matches a web dispute
user_id: req.user.appAccountToken, // the UUID you pass to StoreKit; matches an App Store request
ip, // an empty or invalid address would reject the event, so it is left out
device_id,
properties: { ...props, app_account_token: req.user.appAccountToken, platform: req.get("x-app-platform") === "ios" || req.get("x-app-platform") === "android" ? req.get("x-app-platform") : "web" },
}),
}).catch(() => null);
res.status(r?.ok ? 204 : 502).end(); // never let Zevron being down break your app
});Why the relay? Because your server is the only place that can say who the customer is (from your session) and which IP the request came from. Neither should be taken from the body of a request the client wrote.
7.App Store refund requests
When a customer asks Apple for a refund on an In-App Purchase, Apple sends a refund-request notification and waits 12 hours for consumption information. Zevron answers from your events, if the customer agreed.
- In App Store Connect, create an App Store Server API key (Users and Access, Integrations, In-App Purchase). Note the Issuer ID, the Key ID and your bundle ID, and download the
.p8file once. - In Zevron, Settings, Connections, App Store: paste them. Copy the notification URL shown there into App Store Connect as your Production Server URL, so Apple tells us when a request opens.
- Choose how consent works in Approvals and automation.
There are three settings for consent. Each customer’s own record is the default and the safe one:
- Each customer’s own record. You send
consumption_consentwhen the customer accepts your data terms. We answer only for those customers. - Your terms cover everyone. You confirm that your terms obtain every customer’s consent. A customer who has withdrawn is still left out.
- Send nothing. We never share usage with Apple.
Record a customer’s agreement from the app, through your relay:
// When the customer accepts your data terms (a screen or a toggle in the app), record it. Apple's refund
// process needs this before any usage can be shared. Withdraw with the same call and event "consumption_consent_withdrawn".
track("consumption_consent", { terms_version: "2026-09", consented: "true" });We send Apple only what its form takes: whether consent exists, how long the account was active, how much was used, and a refund preference taken from the usage. Apple takes no files and no written argument, and it decides. We cannot guarantee a refund is declined.
8.Check it works
Open the Apps page in Zevron. It has three things for this:
- Watch events arrive. The newest events from your app, updating while the page is open, with the IP and device of each. If your relay works you will see your own session within seconds.
- Send a test event. One clearly marked event written by the page itself, so you can see how an event looks. It does not count toward the checklist, and no dispute reads it.
- Setup health. A checklist read from the last 30 days of real traffic: web billing connected, a key created, events arriving, IP and device on at least 80% of them, events matching customers, the App Store key valid, consent set, and recovery on. Each line that is not green has the fix beside it.
From a terminal you can send one with curl. This is for your own testing; it uses your key, so do not paste it anywhere shared:
curl -X POST https://zevron.ai/api/v1/events \
-H "Authorization: Bearer $ZEVRON_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "test_1",
"email": "[email protected]",
"user_id": "6f1c2a9e-3b0d-4e55-9a7f-2d8c1b4e7a10",
"event": "session_start",
"ip": "203.0.113.7",
"device_id": "d_91",
"properties": { "app_account_token": "6f1c2a9e-3b0d-4e55-9a7f-2d8c1b4e7a10", "platform": "ios" }
}'Your app is live when web billing, events, IP and device, customer matching and recovery are green, and real events with an IP and a device have arrived on seven different days of the last 30. The App Store lines are optional and only apply if you have iOS subscribers.
9.If something looks wrong
| What you see | What to check |
|---|---|
| Events return 401 | The key was stopped, mistyped or is for another workspace. Make a new one in Settings, Connections, Your product, and update the secret on your server. |
| Events arrive without an IP | Your relay is reading its own address. Take the first entry of X-Forwarded-For (or your platform's equivalent) from the request the app made to you, and leave ip out when it is empty. |
| The IP and device lines stay amber | Under 80% of the last 30 days' events carry both. The usual cause is a client that calls Zevron directly, or a background job that has no request to take them from. |
| A web dispute shows no in-app logs | Events are matched by the email on the payment. Check the events carry the same email the customer used at web checkout, and that the purchase event names the payment_intent. |
| An App Store request went unanswered | Either there was no record that the customer agreed to share usage, or the key failed Apple's check. The setup page shows which; Apple's 12 hours cannot be reopened. |
| The checklist is green but the app is not counted as live | Zevron counts an app as live once web billing is connected and real events with both an IP and a device have arrived on seven different days of the last 30. The events line on your setup page shows how many days you are at. |
| Test events do not turn anything green | By design. They prove the page and the path work. The checklist reads events from your server. |
10.What we keep and what we do not
We read an event only when a dispute or a refund request opens for that customer, and only the ones from the 60 days before it. Nothing is sold or shared, and a customer’s events are removed when they ask us to erase their data. We never see a card number.
On the Apple links: As of April 2026, apps in the United States can link customers to an outside payment page instead of selling only through Apple's in-app purchase. Apple charges a fee on those linked purchases, and a court has found that fee too high. Which fee is allowed is still being fought: Apple has said it plans to ask the Supreme Court to review the case. TechCrunch, April 6, 2026: Apple and Epic Games, the Supreme Court appeal and the App Store commission. Reviewed October 2026. This is a summary, not legal advice. The rules differ outside the United States and are changing. Check Apple's current guidelines and talk to your lawyer before you change how you sell.