# 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 player/subscription export from your previous provider, 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
> the [migration guide](../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=@players-export.csv"
```

### Example response — small file

```json
{
  "file": "players-export.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": "players-export.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 pull from your previous provider; 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)
- [Migration guide](../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)
