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:
Code
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 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.
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
Code
Example response — small file
Code
| 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:
Code
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
Code
Example response
Code
GET /v1/apps/{app_id}/imports/{import_id}
The polling endpoint. Returns a trimmed record suited to a progress loop.
Example request
Code
Example response
Code
Job statuses
Code
| 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_totalis0ornulluntil the job starts reading, so compute progress only once it is positive.rows_doneadvances 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
pendingfor 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.
summaryis{}until the job reachesdone.
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
Code
File format
Line 1 — the manifest.
Code
| 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.
Code
Last line — the terminal record.
Code
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:
Code
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_idis 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
Code
Example response
Code
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:
Code
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
versionthis build can read. - A
consistencyvalue 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_completemust betrue. Anything else is the writer saying it did not finish.consistencymust 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
countsmust 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
Code
Example response
Code
| 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
GETis the export. - Import ids come back on the 202 only. A small file processed inline still records an import row, but the
200response is the summary, not an id. ReadGET …/importsif 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
- Migrating from OneSignal
- Security and limits
- Users and subscriptions API
- API overview
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.