← Back to blog

Your data, as a download — not an email to support

2026-10-12

engineeringprivacy

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/organization returns everything the organization configured. Only owners and admins can call it, and a plain member gets a 403. 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/me returns 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.