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 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
Code
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:
Code
Code
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
/v1cancel 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
Code
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:
Code
Body — one line per row, in entity order:
Code
Last line — the terminal record:
Code
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
Code
{entity} must be one of these eleven, which is also the order entities appear in the NDJSON stream:
Code
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
Code
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
versionis unreadable. - The
consistencyvalue 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.
- Custom events — the entire event stream. Everything under Events.
- 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.
- 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.
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.