# Import and export


Getting data into and out of an app. Four things live here:

| Direction | Format | Route | Use it for |
|---|---|---|---|
| In | Subscription CSV | `POST /v1/apps/{app_id}/import` | Bulk-loading devices, migrating an audience from another platform. |
| In | OpenPush NDJSON archive | `POST /v1/apps/{app_id}/import.ndjson` | Restoring a backup into a **new, empty** app. |
| Out | NDJSON archive | `GET /v1/apps/{app_id}/export.ndjson` | Full backup, or moving an app to another OpenPush server. |
| Out | Per-entity CSV | `GET /v1/apps/{app_id}/export/{entity}.csv` | Loading one table into a spreadsheet or a warehouse. |

**Exporting *from OpenPush* is unconditional.** It is a `GET` and a `POST`, not a support request, and it is available at any time. That is a statement about OpenPush's own routes only — what any other provider lets you export from *their* system is governed by your agreement with them, not by anything on this page.

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

---

## Authentication

Every route on this page takes your app's REST API key:

```
X-OP-API-Key: <REST API key>
```

The export routes carry **full push tokens** — the only place in the API where they are not truncated — so treat an export file as a credential store, not as a report.

Errors use the standard `{"detail": "<message>"}` body.

---

> ## ⚠️ What exports do not contain
>
> The NDJSON archive and the per-entity CSVs cover the eleven entities listed below. **Three things you may expect are absent from both formats:**
>
> - **Journey history** — journey definitions, runs, node events, daily rollups.
> - **Custom events** — the entire event stream your SDKs and servers posted.
> - **Daily usage rollups.**
>
> The archive's terminal record still reports `stream_complete: true`, because the stream did complete over the entities it covers — it is not a signal that these three are included. If you need them retained, copy them out of your database directly before you migrate or delete an app.
>
> Message and delivery history **is** exported, including messages that a journey sent.

---

# Importing

## `POST /v1/apps/{app_id}/import`

Imports a subscription CSV. Accepts a OneSignal player/subscription export, and also re-reads OpenPush's own `subscriptions` CSV export (see [round-trip mode](#round-trip-mode) below).

> **Before you upload a file exported from another provider.** Obtaining that file is
> your responsibility, not OpenPush's — this route reads whatever you hand it. Confirm
> that your agreement with the source provider (including any negotiated enterprise
> terms) permits exporting the data this way, and use credentials issued to your own
> organisation for your own data. If you want OpenPush to pull the export for you
> rather than uploading it yourself, the console path and exactly which of the
> provider's documented endpoints it calls are described in
> [Migrating from OneSignal](../guides/migrate-from-onesignal.md).

**Request** — `multipart/form-data` with one field named `file`.

Rows are upserted on **(app, push token)**, so re-running the same file is safe: it updates rather than duplicating.

### Sync or async

The response shape depends on the file size:

| File size | Status | Response |
|---|---|---|
| ≤ 5 MB | `200` | The finished summary object, inline. |
| > 5 MB | `202` | `{"import_id": "imp_…", "status": "pending"}` — poll it. |

The 5 MB threshold is a platform setting, held by the OpenPush team and not exposed per app, so it can change without your client changing. **Write your client to handle both shapes** rather than branching on the file size yourself.

### Encoding

The file is decoded as UTF-8 (a byte-order mark is stripped), falling back to Latin-1 if that fails. Headers are matched case-insensitively.

### Round-trip mode

If the header set contains all of `app`, `token`, `platform`, `status`, `status_detail`, `last_seen` and `device_name`, the file is recognised as an OpenPush subscriptions export and read in native mode: the CSV-injection guard character is stripped back off, `status_detail` is taken verbatim, no source-platform tag is added, and lifecycle columns (`location_at`, `unsubscribed_at`, `retired_at`, `device_status`) are written directly.

That makes `GET …/export/subscriptions.csv` → `POST …/import` a supported round trip.

### Privacy gating

Advertising id, latitude/longitude and email columns pass through the same per-app collection switches your SDKs obey. A column your app has switched off is counted and reported in `summary.not_collected`, not stored.

### Example request

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

### Example response — small file

```json
{
  "file": "onesignal-players.csv",
  "rows": 4812,
  "imported": 4735,
  "created": 4610,
  "updated": 125,
  "skipped": 77,
  "by_status": {"1": 4102, "-2": 512, "-18": 121},
  "invalid_rows": 12,
  "errors": [
    "Row 341 — no push token",
    "Row 902 — no push token"
  ],
  "not_collected": {"email": 4812},
  "skipped_channels": {"email": 210, "sms": 33}
}
```

| Field | Meaning |
|---|---|
| `rows` | Data rows read. |
| `imported` | Rows that became a subscription. |
| `created` / `updated` | Split of `imported` between new and existing tokens. |
| `skipped` | Rows with no token, or whose write failed. Never fatal. |
| `by_status` | Count per derived subscription status. |
| `invalid_rows` | Rows that could not be parsed at all. |
| `errors` | **The first five error strings only**, each carrying its row number. |
| `not_collected` | Present only when a privacy switch dropped a column. |
| `skipped_channels` | Present only when the file contained channels OpenPush does not carry (email, SMS, Amazon, Windows, Huawei, Alexa, Chrome extension). Those rows never become subscriptions. |

### Example response — large file

`202 Accepted`:

```json
{"import_id": "imp_01hq8f", "status": "pending"}
```

### Errors

| Status | Body | Cause |
|---|---|---|
| 401 | `{"detail": "bad X-OP-API-Key"}` | Bad key. |
| 404 | `{"detail": "unknown app"}` | No such app. |
| 413 | `{"detail": "CSV file exceeds the 150 MB limit"}` | Over the upload cap. The number in the message is the limit actually in force — read it rather than hard-coding 150. |

---

## `GET /v1/apps/{app_id}/imports`

Lists the **25 most recent** imports for the app, newest first. There is no pagination and no way to reach older ones over the API.

### Example request

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

### Example response

```json
{
  "imports": [
    {
      "id": "imp_01hq8f",
      "app": "app_3f9c",
      "filename": "onesignal-players.csv",
      "created_at": 1756598400.0,
      "rows_total": 1840221,
      "rows_ok": 1839004,
      "rows_bad": 1217,
      "summary": {"rows": 1840221, "imported": 1839004, "created": 1839004,
                  "updated": 0, "skipped": 1217, "by_status": {"1": 1702118},
                  "invalid_rows": 0, "errors": []},
      "status": "done",
      "rows_done": 1840221,
      "bytes_total": 214005112,
      "bytes_done": 214005112,
      "error": null,
      "started_at": 1756598406.0,
      "finished_at": 1756599940.0,
      "pull_started_at": null,
      "started_by": "API key"
    }
  ]
}
```

---

## `GET /v1/apps/{app_id}/imports/{import_id}`

The polling endpoint. Returns a trimmed record suited to a progress loop.

### Example request

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

### Example response

```json
{
  "id": "imp_01hq8f",
  "status": "running",
  "rows_done": 412000,
  "rows_total": 1840221,
  "error": null,
  "summary": {}
}
```

### Job statuses

```
pulling → pending → running → done
                          ↘ failed
                          ↘ cancelled
```

| Status | Meaning |
|---|---|
| `pulling` | Fetching the file from a remote source. Only reachable via the console's OneSignal pull; an uploaded file never starts here. |
| `pending` | Queued, waiting for a worker. |
| `running` | Being read. `rows_done` advances. |
| `done` | Finished. `summary` is populated. |
| `failed` | Aborted. `error` explains why. |
| `cancelled` | Stopped from the console. |

### How to poll

- `rows_total` is `0` or `null` until the job starts reading, so compute progress only once it is positive.
- **`rows_done` advances only after a batch of 1000 rows commits.** A restarted worker safely replays a committed prefix, because rows upsert on the push token.
- The scheduler starts **at most one pending import per tick**, so a queued job behind another one can sit at `pending` for a while. That is not a stall.
- Poll every few seconds. There is no rate limit on this route, but there is nothing to gain from polling faster than the batch commits.
- `summary` is `{}` until the job reaches `done`.

### Errors

| Status | Body | Cause |
|---|---|---|
| 404 | `{"detail": "No such import"}` | No import with that id in this app. |

### Notes

- **There is no cancel route on the API.** Cancelling a running import is a console action. If you need to stop one, use the dashboard.
- **API-initiated imports never send email.** Console-initiated imports can notify an address on completion; imports started with an API key do not.

---

# Exporting

## `GET /v1/apps/{app_id}/export.ndjson`

Streams the app as newline-delimited JSON: a manifest line, then one object per row across all eleven entities, then a terminal line.

Response headers: `Content-Disposition: attachment; filename="<app>-export.ndjson"`, `Cache-Control: no-store`. The body is chunked with no `Content-Length` — the length is not knowable without walking every table twice, and a wrong one is worse than none.

**Snapshot consistency.** By default the whole stream runs inside one read transaction, so every entity in the file is the same instant. OpenPush can also be run with a per-page walk instead — a platform setting, not an app one — and the file then says so about itself in both the manifest and the terminal record. Read `consistency` from the file before you rely on a restore being point-in-time, rather than assuming the default.

**Full push tokens are present.** List endpoints truncate tokens to their first 20 characters; this file does not.

### Example request

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

### File format

**Line 1 — the manifest.**

```json
{"type":"openpush.export","version":2,"app":{"id":"app_3f9c","name":"Acme"},
 "exported_at":1756598400.0,"consistency":"snapshot",
 "entities":["settings","segments","templates","dynamic_content","users","user_hours","subscriptions","messages","test_subscriptions","imports","deliveries"],
 "field_names":"same as the OpenPush REST API",
 "withheld":{"apps.org_id":"…","imports.claim_token":"…"},
 "not_exported":{"api_keys":"…","platform_credentials":"…"},
 "reimport":"POST /v1/apps/{app}/import.ndjson (multipart file=) into an EMPTY app"}
```

| Manifest field | Meaning |
|---|---|
| `type` | Always `openpush.export`. |
| `version` | Archive format version. |
| `app` | The app row, with internal columns removed. |
| `exported_at` | Unix seconds. |
| `consistency` | `snapshot` or `per-page`. |
| `entities` | The entity names in the order they appear below, and the same set the CSV route accepts. |
| `field_names` | States that keys match REST field names. |
| `withheld` | `table.column` → why that column was removed. Internal bookkeeping and ownership columns. |
| `not_exported` | Table → why it was skipped entirely. Includes `api_keys`, `platform_credentials`, `device_credentials`, `media`, `live_activities`, `push_to_start_tokens`, org and membership tables, and the audit log. |
| `reimport` | The route that reads this file back. |

**Body lines — one per row.**

```json
{"type":"subscriptions","data":{"id":"sub_01hq8g","app":"app_3f9c","token":"9f8c…","platform":"ios","status":7,"…":"…"}}
```

**Last line — the terminal record.**

```json
{"type":"openpush.export.end","app":"app_3f9c",
 "counts":{"settings":1,"segments":14,"templates":6,"dynamic_content":2,"users":118402,
           "user_hours":91233,"subscriptions":140881,"messages":903,
           "test_subscriptions":4,"imports":11,"deliveries":8410772},
 "consistency":"snapshot","stream_complete":true,
 "note":"Every row in this archive was read inside ONE database transaction …"}
```

**Always check for the terminal record.** A stream cut off mid-download parses perfectly and is silently short. `stream_complete: true` on the last line is the writer's own statement that it reached the end of its output, and per-entity `counts` let you verify what you received. The restore route enforces both; your own tooling should too.

### Errors

| Status | Body | Cause |
|---|---|---|
| 401 | `{"detail": "bad X-OP-API-Key"}` | Bad key. |
| 404 | `{"detail": "unknown app"}` | No such app. |

Authorization and the 404 both happen before streaming begins, so a `200` means the walk started.

---

## `GET /v1/apps/{app_id}/export/{entity}.csv`

Streams one entity as CSV. Same records as the NDJSON, rendered flat.

Response headers: `Content-Disposition: attachment; filename="<app>-<entity>.csv"`, `Cache-Control: no-store`, `Content-Type: text/csv; charset=utf-8`.

### The eleven entities

`{entity}` must be exactly one of:

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

Anything else is a `404` naming the full list. There is no `apps` entity — the app row is the NDJSON manifest's `app` object. There is no journeys, events or usage entity.

### Columns

The header row of each file is the authoritative column list; the values follow in the same order. For reference:

| Entity | Columns |
|---|---|
| `settings` | `app, quiet_start, quiet_end, quiet_enabled, freq_cap, freq_window_h, collect_ad_id, collect_location, collect_email, identity_verification, android_channels, default_tz` |
| `segments` | `id, app, name, status, rules, is_default, is_target_default, created_at, updated_at` |
| `templates` | `id, app, name, title, body, image_url, deep_link, data, created_at, sends_count, src_id` |
| `dynamic_content` | `id, app, name, data, size_bytes, created_at, updated_at` |
| `users` | `id, app, external_id, tags, country, language, timezone, first_session, last_session, session_count, last_ip, email, aliases` |
| `user_hours` | `app, user_id, hour, n, updated_at` |
| `subscriptions` | `id, app, token, external_id, platform, status, status_detail, app_version, device, ip, ad_id, lat, lng, location_at, user_id, created_at, last_seen, sandbox, unsubscribed_at, retired_at, device_status, device_name` |
| `messages` | `id, app, name, template, template_id, title, body, image_url, deep_link, data, languages, default_language, include_segments, exclude_segments, target, schedule_at, audience_est_at, delivery, status, created_by, created_at, sent_at, is_test, audience_n, capped_n, error, ttl_s, priority, collapse_key, overrides, delayed_option, delivery_time_of_day, src_id, stats, kind, activity_id, activity_type, live_activity_event, clicked_n, journey_id, journey_node_id, journey_run_id, custom_data, render_errors, dyn_refs, dyn_snapshot, variants, ab` |
| `test_subscriptions` | `app, name, sub_id, created_at` |
| `imports` | `id, app, filename, created_at, rows_total, rows_ok, rows_bad, summary, status, rows_done, bytes_total, bytes_done, error, started_at, finished_at, pull_started_at` |
| `deliveries` | `message_id, sub_id, sent_at, accepted_at, received_at, confirmed_at, clicked_at, error, capped, capped_at, capped_reason, held_until, queued_at, attempts, next_attempt_at, dead_at, timed_basis, variant, wave` |

Columns withheld from every format: import bookkeeping (`claim_token`, `spool_path`, `notify_email`, `started_by`, `pull_url`), app ownership (`org_id`, `created_by`, `sample_shared`), message internals (`lease_until`, `sender_org_id`, `sender_user_id`), and the owner columns on users and subscriptions.

> `messages.journey_run_id` is a real column and is exported, but nothing writes it — expect it to be empty.

### CSV injection guard

**Every cell, including the header row, is escaped.** A value that begins with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with a single quote, so a spreadsheet treats it as text rather than a formula.

That means a token or tag value starting with one of those characters comes back with a leading `'`. The CSV import route strips it back off in round-trip mode; if you parse these files yourself, strip it too.

### Example request

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

### Example response

```csv
id,app,token,external_id,platform,status,status_detail,app_version,device,ip,ad_id,lat,lng,location_at,user_id,created_at,last_seen,sandbox,unsubscribed_at,retired_at,device_status,device_name
sub_01hq8g,app_3f9c,9f8c1d0a4b7e2f36…,u-91422,ios,7,subscribed,4.2.1,iPhone15,3,,,,,,usr_01hq8h,1756512000.0,1756598400.0,0,,,,Amara's iPhone
```

### Errors

| Status | Body | Cause |
|---|---|---|
| 404 | `{"detail": "unknown entity 'journeys' — one of settings, segments, templates, dynamic_content, users, user_hours, subscriptions, messages, test_subscriptions, imports, deliveries"}` | Unrecognised entity. |
| 404 | `{"detail": "unknown app"}` | No such app. |

---

## `POST /v1/apps/{app_id}/import.ndjson`

Reads an OpenPush NDJSON archive back in. This is a **restore into an empty app**, not a merge.

**Request** — `multipart/form-data` with one field named `file`. The file is read line by line, so a multi-million-row archive is never buffered whole.

### The empty-app precondition

The target app must have **zero** subscriptions, users and messages. If it has any, the call fails with **`409`**:

```json
{"detail": "app_3f9c already has 140881 subscriptions. The NDJSON import restores into an EMPTY app — create a new one rather than merging two audiences."}
```

`409` rather than `400` is deliberate: the file is fine, the target is wrong. Create a fresh app and restore into that.

Row ids are **re-derived**, not reused: each old id is hashed with a per-restore salt into a new one, and every reference is rewritten consistently. Segments are the exception — they are matched by name, because a freshly created app already owns its managed segments.

### Envelope grammar

The reader holds the file to the claims it makes about itself, and every refusal names what was wrong.

Enforced for every archive:

- The first record must parse as JSON and must be the manifest. A line of anything else in front of it fails, so junk cannot be hidden ahead of the header.
- Exactly one manifest, and it is first.
- A `version` this build can read.
- A `consistency` value from the known set, in the manifest.
- Exactly one terminal record, and it is **last**. Records after it mean two archives were concatenated.
- A terminal record must be present at all. Its absence means the file was truncated in transit.

Additionally enforced for current-version archives:

- `stream_complete` must be `true`. Anything else is the writer saying it did not finish.
- `consistency` must appear in the terminal record too, from the known set, and must **match** the manifest. Two answers about one walk means the file was assembled.
- Per-entity `counts` must be present and must **match** the rows actually read, entity by entity.

Older archives predate the consistency declaration and the completeness flag, so a count mismatch in one is **reported** in `mismatch` rather than refused.

### There is no rollback

Rows commit in batches as they are read. A refusal partway through leaves the rows already written in place, and the error says so, including how many. There is deliberately no cleanup sweep — a restore that can delete live subscriptions is a worse tool than one that leaves a mess it names. The empty-app precondition is what makes the remedy trivial: **delete the app, make a new one, re-run the export.**

### Example request

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

### Example response

```json
{
  "app": "app_restore",
  "salt": "imp_01hq8j",
  "imported": {
    "settings": 1, "segments": 14, "templates": 6, "dynamic_content": 2,
    "users": 118402, "user_hours": 91233, "subscriptions": 140881,
    "messages": 903, "test_subscriptions": 4, "imports": 11, "deliveries": 8410772
  },
  "skipped": 0,
  "errors": [],
  "declared": {"users": 118402, "subscriptions": 140881, "deliveries": 8410772},
  "mismatch": {},
  "not_collected": {},
  "source_consistency": "snapshot",
  "source_format_version": 2,
  "source_app": "app_3f9c"
}
```

| Field | Meaning |
|---|---|
| `imported` | Rows written, per entity. |
| `skipped` | Rows the reader could not use. |
| `errors` | The first five error strings. |
| `declared` | The counts the archive claimed. |
| `mismatch` | Entity → `[declared, read]` for any that disagree. Empty on a clean restore; on an older archive this is reported rather than refused. |
| `not_collected` | Columns dropped by the target app's privacy switches. |
| `source_consistency` | Whether the file you loaded is a point-in-time view or a walk over a moving app. |
| `source_app` | The app id the archive came from. |

### Errors

| Status | Body | Cause |
|---|---|---|
| 409 | `{"detail": "… already has N subscriptions. The NDJSON import restores into an EMPTY app …"}` | Target not empty. |
| 409 | `{"detail": "the file has no openpush.export.end record — it was truncated in transit, or it is not an OpenPush export. N row(s) were already written …"}` | Truncated download. |
| 409 | `{"detail": "this file declares format version 2 and does not meet it. the declared counts do not match what was read: users declared 118402, read 91004 …"}` | Envelope violation. |
| 409 | `{"detail": "unknown app 'app_restore'"}` | No such app. |
| 413 | — | Request over the body limit. **See the limits table — this route gets the ordinary 8 MB envelope, not the 150 MB import envelope.** |

---

## Limits

| Limit | Default | Applies to |
|---|---|---|
| CSV upload size | **150 MB** | `POST …/import`. Over → `413`. |
| Request envelope for `…/import` | CSV limit + 1 MB | `POST …/import` |
| **Request envelope for `…/import.ndjson`** | **8 MB** | `POST …/import.ndjson` — the enlarged envelope applies only to the CSV import route, so an NDJSON restore over 8 MB is rejected by the body-size middleware before the route sees it. Split a larger archive, or restore through the CSV path. |
| Sync/async threshold | 5 MB | `POST …/import` |
| Batch commit size | 1000 rows | Async CSV imports |
| Import history returned | 25 records | `GET …/imports` |
| Error strings kept | 5 | Every import summary |
| Encodings accepted | UTF-8 (BOM stripped), then Latin-1 | CSV import |
| Rate limits | **none** | Every route on this page |

Every value above except the batch size, the history depth and the error cap is a platform setting held by the OpenPush team, so the numbers are the ones you will be served rather than ones you configure. Check `GET /healthz` if a limit does not match.

---

## Notes

- **Exports are streamed, not queued.** There is no export job to poll and no artefact to download later. The `GET` is the export.
- **Import ids come back on the 202 only.** A small file processed inline still records an import row, but the `200` response is the summary, not an id. Read `GET …/imports` if you need the id of an inline import.
- **A skipped row is never fatal.** A CSV row with no push token, or one whose write fails, is counted and the import continues.
- **Restoring does not copy keys or credentials.** API keys, platform credentials, device credentials, uploaded media, and live-activity registrations are excluded from the archive by design. After a restore, re-upload your APNs key and FCM service account, and re-issue SDK keys.
- **A restored app has new ids.** Anything holding an OpenPush subscription id or message id from the source app will not resolve against the restored one. External ids and push tokens are preserved.

---

## FAQ

**Can I import into an app that already has users?**
With CSV, yes — it upserts on push token. With NDJSON, no: it is a restore into an empty app and returns `409` otherwise.

**How do I export journeys?**
You cannot, through this API. Journey definitions are readable one at a time from the journeys API; journey run history has no export at all.

**Why does my exported CSV have a leading apostrophe on some values?**
That is the spreadsheet-formula guard on any cell beginning with `=`, `+`, `-`, `@`, tab or carriage return. Strip it when parsing.

**My import has been `pending` for minutes. Is it stuck?**
Probably not. The scheduler starts at most one queued import per tick, so a job behind another one waits. Check whether an earlier import is `running`.

**Can I cancel a running import over the API?**
No. Cancellation is a console action.

**How do I verify an archive downloaded completely?**
Read the last line. It must be `openpush.export.end` with `stream_complete: true`, and its per-entity `counts` should match the lines you read.

---

## Related

- [Import and export guide](../guides/import-export.md)
- [Migrating from OneSignal](../guides/migrate-from-onesignal.md)
- [Security and limits](../guides/security-and-limits.md)
- [Users and subscriptions API](03-subscriptions-users.md)
- [API overview](00-overview.md)

---

*OneSignal is a trademark of OneSignal, Inc. OpenPush is an independent project and is not affiliated with, endorsed by, or sponsored by OneSignal, Inc. References to OneSignal are for identification and interoperability purposes only.*
