Your data, as a download — not an email to support
2026-10-12
Our privacy policy says you can get a portable export of your data "at any time from your account settings". This post explains what you actually get when you do: two JSON documents, who can download each one, what is in them, and what we leave out on purpose.
Two exports, because "your data" means two things
An organization admin and an individual member are asking different questions.
The admin wants to know what the team set up. The member wants to know what we
hold about them. So there are two endpoints, both GET and both read-only:
/v1/export/organizationreturns everything the organization configured. Only owners and admins can call it, and a plain member gets a403. The document lists every member's email address and role, so it is admin-only for the same reason the team page is./v1/export/mereturns what we hold about the person calling it. Every signed-in user can call it, members included, because the right to portability belongs to the person and not to a role. The document contains nothing about anyone else. It has no checks, no alert channels, no API keys, and no colleagues' addresses. Your memberships appear as organization id, name, your role and the date you joined, and nothing more about those organizations.
How to get them: open Account → Your data in the dashboard. Everyone
sees Export my data. Owners and admins also see Export organization
data. The response carries
Content-Disposition: attachment, so the browser saves a file named
okokumo-export-organization-2026-10-12.json or
okokumo-export-user-2026-10-12.json (UTC date) instead of displaying a
megabyte of JSON in a tab.
Both endpoints require a dashboard session, and API keys are refused. API
keys live in CI config and on laptops, and a leaked key should not be able to
pull the whole organization in a single request. For the same reason, exports
have their own rate limit, much tighter than the regular API: 5 per hour per
organization, shared between the two endpoints. Going over it returns 429
with a Retry-After header. Each export also writes an audit row recording
who exported, which scope, and when. That write is best-effort: if it fails, we log the failure and
you get your file anyway. Refusing to hand over someone's data because our own
record-keeping failed would put the paperwork ahead of the right it exists for.
What the file looks like
Here is an abbreviated self export from an account whose login history went over the cap (more on the cap below):
{
"format_version": 1,
"generated_at": "2026-10-12T08:14:03Z",
"scope": { "type": "user", "user_id": "5b0e…" },
"account": {
"id": "5b0e…", "email": "you@example.com", "email_verified": true,
"created_at": "2026-06-02T09:11:40Z", "updated_at": "2026-09-30T17:02:11Z"
},
"memberships": [
{ "organization_id": "a41c…", "organization_name": "Acme Ops",
"role": "member", "member_since": "2026-06-02T09:11:40Z" }
],
"login_history": [
{ "occurred_at": "2026-10-12T07:58:21Z", "success": true,
"ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0 …" }
],
"password_resets": [
{ "occurred_at": "2026-09-30T16:58:02Z", "event": "reset-requested",
"reset_email_sent": true, "ip_address": "203.0.113.7" }
],
"excluded": [
{ "data": "check_results", "reason": "Monitoring measurements we generated …",
"alternative": "Aggregates are available from GET /v1/checks/{id}/stats …" },
{ "data": "alert channel credentials", "reason": "…", "alternative": "…" },
{ "data": "API key material", "reason": "…", "alternative": "…" },
{ "data": "login_history",
"reason": "Older login attempts. This document carries the most recent 500 only, newest first, and there were more. …",
"alternative": "Ask support for the full history of your address; we hold it and will extract it." }
]
}
format_version is the first field so an importer can check it before parsing
anything else. The organization document uses the same envelope with
scope.type: "organization", followed by organization (name, plan, signup
attribution), members, checks, alert_channels, check_alert_channels,
status_pages (with components, incidents and incident updates nested inside),
api_keys, pending invitations, state_transitions, and the same excluded
list.
Every field is copied by hand
No section of either document is a serialised database model. Each section is an export-specific struct, and every field is copied into it one at a time.
The reason is what happens when someone adds a column. If the export serialised models, every new column would show up in the file automatically: a token, an internal counter, a Stripe customer id. Nobody would notice in review, because the diff that adds the column doesn't touch the export code. Then the value ends up in a file that customers email to colleagues, attach to tickets and leave in Downloads folders. With explicit copying, a new column stays out of the export until someone decides to put it there.
Concretely, that means the following never appear in either file:
- Password hashes and Stripe customer or subscription ids.
- API key hashes. API keys appear as name, prefix (the same fragment the dashboard shows), last use and creation date. The key itself cannot be recovered, because we only store its hash.
- Invitation tokens, plus each check's ping token and embed token. Each of those is a live credential, and you don't need any of them to recreate your setup somewhere else.
- Alert channel secrets, including webhook URLs and the
webhook signing secret. Every config
key is still listed by name, but its value is
"[redacted]"unless the key is on an allowlist. Today the allowlist has one entry:to, the destination address of an email channel. We chose an allowlist instead of a denylist so that a new channel type with a new secret key is redacted by default.
Tests check the serialised output for these values. One test generates an organization export and fails if any API key hash, invitation token, ping or embed token, channel secret or webhook URL from the fixture appears anywhere in the raw bytes. Another fails if the self export of a member contains the owner's email address.
The reverse case gets a test too. Your organization's signup attribution is deliberately hidden from every API response, since it is analytics. It is still data we hold about you that you can't see anywhere else, so it is included in the export, and a test fails if it is ever dropped.
The 500-attempt cap, and why the file says when it applies
login_history is the one section that grows without you doing anything. We
record a sign-in attempt against your address even when no user id can be
attached to it, such as a wrong password typed for your email. The self export
includes those rows by matching on your user id or on your address
(case-insensitively). A bot trying passwords against your address keeps adding
rows. The dashboard has no login-history view, so this export is the only place
you can see them.
We cap the section at the 500 most recent attempts, newest first. That is enough to answer "who has been getting into my account". The risk is that on an account under attack, the bot's attempts fill all 500 slots and your own successful sign-ins fall past the cap. Cutting the list is fine. Cutting it without saying so would not be, in a document whose whole purpose is to state what we hold.
So when the cap actually cuts something, the document adds a login_history
entry to excluded, as in the sample above. The entry is conditional on
purpose: if it appeared on every export, you couldn't tell a complete history
from a truncated one. The code fetches 501 rows to make that distinction, since
exactly 500 rows could mean "complete" or "cut". Password reset events get
their own password_resets section with a separate 500 cap and a matching
disclosure. That way, someone requesting reset links for your address can't
push your real sign-ins out of login_history.
What isn't in the file
The excluded list covers it, and it is the same in both documents: check
results (large, generated by our probes, kept 90 days, see
Security & data; aggregates and transitions are available from the
API), channel credentials and API key material, plus the truncation entries
when they apply. Each entry has the same three fields: what was left out, why,
and how to get it another way.
A field missing with no explanation looks the same as a bug. An export is only useful if you can trust that what it leaves out was left out on purpose.
okokumo is infrastructure monitoring hosted in France: HTTP, heartbeat, TLS and domain checks. How long we keep each kind of data, and why: Why we delete your job output after 30 days.