# Import and export


OpenPush moves subscriber data in and out over two file formats. **CSV** is the import format for subscriber lists and the per-entity export format for spreadsheet work. **NDJSON** is the whole-app archive format — one JSON object per line, streamed, with a manifest at the top and a terminal record at the bottom — and it is also the restore format.

Export is a plain `GET`. It is available at any time, with no notice period and no ticket. What it does *not* contain is documented honestly below, and you should read that section before you rely on an archive as a backup.

All examples use `https://app.openpush.ai`, the OpenPush API base URL.

## When to use

- **CSV import** — bring a subscriber list in from another platform or from your own store. See [Migrating from OneSignal](migrate-from-onesignal.md) for the OneSignal-specific path.
- **CSV export** — pull one entity into a spreadsheet or a warehouse loader.
- **NDJSON export** — take a full, point-in-time archive of an app.
- **NDJSON restore** — rebuild an app from an archive into a fresh, empty app.

## CSV import

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2b/import \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -F "file=@subscribers.csv"
```

The upload is a `multipart/form-data` body with a single field named `file`.

> **If the file came from another provider.** Getting it out of that system is your
> side of the job. Confirm your agreement with the source provider permits exporting
> the data this way, and use credentials issued to your own organisation.

### Column mapping

Headers are matched case-insensitively after trimming, and each target accepts several aliases — the first non-empty one wins. `""`, `null`, and `None` all count as empty.

| Target | Accepted headers |
|---|---|
| **Push token** (the upsert key) | `identifier`, `push_token`, `token`, `device_token`, `registration_id` |
| External ID | `external_user_id`, `external_id`, `externaluserid` |
| Source id (kept as a tag) | `id`, `player_id`, `onesignal_id`, `subscription_id` |
| Language | `language`, `language_code`, `lang` |
| Country | `country`, `country_code` |
| Timezone | `timezone`, `timezone_id`, `tz` |
| Last session | `last_active`, `last_session`, `last_active_at`, `last_seen` |
| First session | `first_session`, `created_at`, `first_active` |
| Platform | `device_type`, `device_platform`, `platform`, `channel` |
| Device model | `device_model`, `device`, `model` |
| Device OS | `device_os`, `os_version`, `device_os_version` |
| App version | `app_version`, `game_version`, `sdk_app_version` |
| Session count | `session_count`, `sessions`, `amount_spent_sessions` |
| IP | `ip`, `last_ip` |
| Device name | `device_name` |
| Sandbox | `sandbox` |
| Subscription status | `notification_types`, `notification_type`, `status` |
| Tags | `tags` (a JSON object, or `k=v;k=v`) **and** any `tag_<key>` column |
| Advertising id *(gated)* | `ad_id`, `advertising_id`, `idfa`, `gaid`, `google_advertising_id` |
| Latitude *(gated)* | `lat`, `latitude` |
| Longitude *(gated)* | `long`, `lng`, `longitude` |
| Email *(gated)* | `email`, `email_address` |

**Rows are upserted on (app, push token).** A row without a token is skipped and counted, never fatal.

Platform values map from numbers or strings — `0` and `9` are iOS, `1` is Android, `5`/`7`/`8`/`12`/`17` are web; textually, `ios`/`apple`/`apns` → iOS, `android`/`google`/`fcm` → Android, `web` → web. Rows on channels OpenPush does not deliver to (Amazon, Windows, Windows Phone, Chrome extension, Alexa, email, SMS, Huawei) are recognised, skipped, and reported separately rather than silently converted.

The three **gated** columns — advertising id, location, and email — pass through the same per-app collection switches the SDK obeys. If a switch is off, values in those columns are dropped rather than rejected, and the count appears in the summary under `not_collected`. Turn the switch on in your app settings before importing if you want that data.

**Encoding**: files are read as `utf-8-sig`, falling back to `latin-1`.

**Round trip**: a CSV whose header set matches an OpenPush subscriptions export is recognised as such and re-read verbatim, including lifecycle timestamps, so exporting and re-importing your own file does not degrade it.

### Job lifecycle and polling

The route behaves differently depending on file size:

| File size | Behaviour |
|---|---|
| ≤ **5 MB** | Processed **inline**. You get `200` and the complete summary object in the response. |
| > 5 MB | Queued. You get `202` and `{"import_id": "imp_01hq8n4t2v", "status": "pending"}`. |

Queued jobs move through `pulling` → `pending` → `running` → `done` / `failed` / `cancelled`. Poll one:

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2b/imports/imp_01hq8n4t2v \
  -H "X-OP-API-Key: $OP_REST_KEY"
```

```json
{"id": "imp_01hq8n4t2v",
 "status": "running",
 "rows_done": 42000,
 "rows_total": 118400,
 "error": null,
 "summary": {"rows": 42000, "imported": 41880, "created": 39104,
             "updated": 2776, "skipped": 120,
             "by_status": {"1": 38220, "-2": 3660},
             "invalid_rows": 120,
             "not_collected": ["email"],
             "skipped_channels": {"sms": 44}}}
```

`GET /v1/apps/{app_id}/imports` lists the **last 25** jobs, newest first.

Practical notes:

- Progress advances only after a batch of 1000 rows commits, so a restarted server safely replays from the last committed batch — the upsert key makes re-processing idempotent.
- The scheduler starts at most one pending import per tick, so multiple queued imports run one after another, not in parallel.
- The summary keeps only the **first 5 error strings**, with row numbers. Counters (`rows`, `imported`, `created`, `updated`, `skipped`, `invalid_rows`, `by_status`) are complete.
- **Cancelling an import is a console action.** There is no `/v1` cancel route.
- **API-initiated imports never send an email notification.** Console-initiated ones can.

### Import limits

| Limit | Value |
|---|---|
| CSV upload file size | **150 MB** (configurable) |
| Request envelope for the CSV import path | Upload cap + 1 MB |
| **NDJSON restore envelope** | **8 MB** (the ordinary body limit — see the warning below) |
| Sync/async threshold | 5 MB |
| Commit batch | 1000 rows |
| Error strings retained | 5 |
| Encodings | `utf-8-sig`, falling back to `latin-1` |

> **Warning.** The 150 MB allowance applies to the **CSV** import path only. The NDJSON restore route gets the ordinary 8 MB body limit, so a large archive will be refused by the size middleware before it is parsed. Plan restores of large apps around that: split the archive, or restore the CSV path instead. The body limit is a platform setting and not something an app can raise.

## NDJSON export

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2b/export.ndjson \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -o app_3f9c2b-export.ndjson
```

The response streams chunked, with `Content-Disposition: attachment` and `Cache-Control: no-store`. There is no `Content-Length` — a wrong one would be worse than none.

### File structure

**Line 1 — the manifest:**

```json
{"type": "openpush.export",
 "version": 2,
 "app": {"id": "app_3f9c2b", "name": "Bakery on Main", "...": "..."},
 "exported_at": 1756598662.0,
 "consistency": "snapshot",
 "entities": ["settings", "segments", "templates", "..."],
 "field_names": "same as the OpenPush REST API",
 "withheld": {"subscriptions.owner_org_id": "internal ownership"},
 "not_exported": {"api_keys": "credentials are never exported"},
 "reimport": "POST /v1/apps/{app}/import.ndjson (multipart file=) into an EMPTY app"}
```

**Body — one line per row**, in entity order:

```json
{"type": "subscriptions", "data": {"id": "sub_01hq8n4t2v", "token": "…", "...": "..."}}
```

**Last line — the terminal record:**

```json
{"type": "openpush.export.end",
 "app": "app_3f9c2b",
 "counts": {"users": 88214, "subscriptions": 91002},
 "consistency": "snapshot",
 "stream_complete": true,
 "note": "…"}
```

If the terminal record is missing, the file is truncated — the restore route will tell you so, and you should not treat the file as a backup.

### Consistency modes

| Mode | Behaviour |
|---|---|
| `snapshot` (default) | A single read transaction is held open for the entire walk, so every entity in the file is the same instant. |
| `per-page` | Pages are read independently. Cheaper on the database, but entities can be seconds apart. |

The mode is a platform setting held by the OpenPush team, and **the file records which mode produced it** — both in the manifest and in the terminal record. If you are restoring, check it rather than assuming the default.

### What the archive holds

Push tokens are present in full in the NDJSON export, and nowhere else in the API. Treat the file as a credential.

## Per-entity CSV exports

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2b/export/subscriptions.csv \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -o subscriptions.csv
```

`{entity}` must be one of these eleven, which is also the order entities appear in the NDJSON stream:

```
settings   segments   templates   dynamic_content   users   user_hours
subscriptions   messages   test_subscriptions   imports   deliveries
```

Anything else is a `404` naming the valid list. The header row and the value order are exactly the entity's column register.

**CSV injection guard**: every cell, header row included, is prefixed with an apostrophe if it begins with `=`, `+`, `-`, `@`, tab, or carriage return. This is why re-importing an OpenPush CSV goes through the round-trip path, which strips the guard back off.

A few columns are withheld from every export by design: internal ownership columns, lease and sender bookkeeping on messages, import spool paths and notification addresses, and the OneSignal migration marker on subscriptions.

## NDJSON restore

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_7d21ef/import.ndjson \
  -H "X-OP-API-Key: $OP_REST_KEY" \
  -F "file=@app_3f9c2b-export.ndjson"
```

**This is a restore into an empty app, not a merge.**

If the target app already holds any subscriptions, users, or messages, the request is refused with **`409`** — not a `400`, because the file is fine and the target is wrong. Create a fresh app and restore into that.

New ids are derived deterministically from the old ones rather than held in memory, so the file streams line by line and a multi-million-row archive is never buffered whole.

The envelope grammar is strict, and each of these is a refusal rather than a partial import:

- The first record is not JSON.
- The manifest is missing, or appears anywhere other than first.
- The `version` is unreadable.
- The `consistency` value is not a known mode.
- Any record appears after the terminal record.
- For version 2 files, the declared counts do not match the actual ones.

As with CSV, the first 5 errors are reported, along with a `skipped` count.

## What exports do NOT contain

> **⚠️ Read this before you treat an archive as a backup.**
>
> The NDJSON archive's terminal record reports `stream_complete: true`. That flag means the stream finished, **not** that the app was fully captured. The following are in **neither** the NDJSON archive **nor** any CSV export, and nothing in the file signals their absence:
>
> - **Journey history** — journey definitions, runs, node events, and daily rollups. Everything under [Journeys](journeys.md).
> - **Custom events** — the entire event stream. Everything under [Events](events.md).
> - **Daily usage history** — the rolled-up dashboard series.
>
> Three further categories are excluded **deliberately**, for good reasons, and you should plan around them rather than expect them:
>
> - **Live Activities** — activity registrations and push-to-start tokens. These are short-lived by nature (a 12-hour total window) and meaningless once restored. See [Live Activities](live-activities.md).
> - **Media bytes** — uploaded images are referenced by URL in templates and messages, but the bytes are not in the archive. Exporting metadata without bytes would describe an archive that cannot restore. Keep your own copy of uploaded media, or use externally hosted HTTPS image URLs.
> - **API keys and platform credentials** — REST keys, SDK keys, APNs keys, and FCM service accounts are never exported. Re-provision them on the restored app. See [Security and limits](security-and-limits.md).
>
> If journey history or custom events matter to you as records, write them to your own store as they happen. An OpenPush export will not give them back.

## Limits summary

| Limit | Value |
|---|---|
| CSV upload file | 150 MB (configurable) |
| NDJSON restore body | 8 MB (ordinary body limit) |
| Sync/async import threshold | 5 MB |
| Import commit batch | 1000 rows |
| Import list depth | Last 25 jobs |
| Error strings retained per job | 5 |
| Export entities | 11 |
| Rate limit on import or export routes | None |

## FAQ

**How do I know whether an import finished?**
A file at or under 5 MB returns the summary directly. A larger one returns an import id — poll it until `status` is `done` or `failed`.

**Can I import users without push tokens?**
No. The token is the upsert key; a row without one is skipped and counted as invalid.

**Why did my email or location columns not import?**
Those columns are gated by the app's collection switches. Check `not_collected` in the summary and enable the switch before re-importing.

**Can I restore an archive over a live app to "sync" it?**
No. Restore refuses any app that already has users, subscriptions, or messages, with a `409`. Restore into a new app.

**Does exporting cost anything or need approval?**
No. Export is a `GET` with your REST key, available at any time.

## Related

- [Migrating from OneSignal](migrate-from-onesignal.md)
- [Users and subscriptions](users-and-subscriptions.md)
- [Events](events.md)
- [Journeys](journeys.md)
- [Security and limits](security-and-limits.md)
