Skip to main content

Bulk Register Devices

Import up to 1,000 devices in one call. This is the endpoint to use when migrating an existing push audience onto Arsel.

Each item is the shape Register Device accepts, plus an optional external_id, with the same (organization, installation_id) upsert and the same durable opt-out rules. Unlike the single-device route it is asynchronous: the request validates and queues, and you poll for the outcome.

Endpoint

POST /push/devices/bulk

Returns: 202 Accepted

How it behaves

Shape errors come back immediately; everything else is queued. A row that fails validation is reported in errors on the response and is never queued. The rest are accepted and processed in the background, so a large import is never bounded by a request timeout.

Failures found during processing are not in this response. "No contact matches this reference" — the most common migration error — can only be known once the row is processed. Poll the status route; it returns the per-row errors alongside the counts.

Every error carries an index — the position of the row in the devices array you submitted. That is how a failure traces back to the line in your export.

The whole batch is safe to retry. Registration is an idempotent upsert on installation_id, so re-sending a batch after a partial failure re-registers the successes harmlessly instead of duplicating them.

Headers

HeaderValueRequired
AuthorizationBearer <your-api-key>Yes
Content-Typeapplication/jsonYes

Body Parameters

ParameterTypeRequiredDescription
devicesarrayYes1–1,000 device objects, each the Register Device body plus an optional external_id.
{
"devices": [
{
"email": "john.doe@example.com",
"installation_id": "install-abc123",
"platform": "android",
"device_token": "fzXn2…J3x9",
"device_timezone": "Asia/Riyadh",
"device_locale": "ar-SA"
},
{
"external_id": "user-4471",
"installation_id": "install-def456",
"platform": "ios",
"apns_environment": "production",
"device_token": "9a1c…40be"
}
]
}
Key your import on external_id

Rows are resolved through the same identifier ladder the rest of the platform uses — external_id, then email, then phone_number — so an export keyed on your own user id binds directly, with no contact_id lookup pass in between. It is also the identifier that survives a customer changing their email or phone.

Where several identifiers on a row point at duplicate contacts, those contacts are consolidated rather than left forked. Where they point at contacts that cannot be reconciled, the row is reported as a 409 rather than guessed at — merging two real customers because an export carried a stale address is not recoverable.

external_id is accepted only here. The single-device route does an exact lookup and would silently ignore it.

Do not send sdk_version for imported devices

The same rule as the single-device route, and it matters more here because an import applies it 1,000 rows at a time. sdk_version marks a device as instrumented; sending it for a device with no embedded Arsel SDK tells Arsel to expect delivery and engagement reports that will never arrive.


Response

{
"job_id": "01957e3a-4b5c-7d8e-9f0a-1b2c3d4e5f6a",
"accepted": 998,
"rejected": 2,
"errors": [
{
"index": 3,
"status_code": 400,
"message": "device_token is required for android and ios"
},
{
"index": 51,
"status_code": 400,
"message": "Provide at least one of contact_id, external_id, email, or phone_number."
}
]
}
FieldTypeDescription
job_idstringPoll the status route with this
acceptednumberRows queued for processing
rejectednumberRows rejected on shape and never queued
errorsarrayThe rejected rows, each with the index it held in your devices array

Checking progress

GET /push/imports/{job_id}
{
"job_id": "01957e3a-4b5c-7d8e-9f0a-1b2c3d4e5f6a",
"status": "completed",
"total": 998,
"completed": 995,
"failed": 3,
"errors": [
{
"index": 7,
"status_code": 404,
"code": "CONTACT_NOT_FOUND",
"message": "No contact in this organization matches the supplied identifiers."
},
{
"index": 12,
"status_code": 409,
"code": "CONTACT_CONFLICT",
"message": "The identifiers on this row match more than one contact and cannot be reconciled automatically. Correct the row and re-import it."
}
],
"error_message": null,
"created_at": "2026-06-01T12:00:00.000Z",
"completed_at": "2026-06-01T12:04:11.000Z"
}
StatusMeaning
queuedAccepted, not started. Keep polling.
processingRunning. Keep polling.
completedEvery accepted row was attempted. Check failed and read errors.
failedThe job died, which is not the same as its rows failing. error_message says why.

errors carries one entry per failed row, each with the index it held in the devices array you submitted — that is how a failure traces back to the line in your export. It is bounded by the batch cap, so it is never truncated and never paginated.

completed does not mean "all succeeded"

It means the job finished attempting every row. A job can complete with failed: 3. Reconcile on the counts, not the status.

The common row failures:

CodeMeaningWhat to do
CONTACT_NOT_FOUNDNo contact matches this row's identifiersImport the contact first — a device import never creates one
CONTACT_CONFLICTThe identifiers match contacts that cannot be safely mergedUsually a stale address in the export. Fix the row and re-submit it

Migrating an existing audience

The full order of operations, including the credential decisions that determine whether your tokens survive at all, is in the push guide. In short:

  1. Keep your existing Firebase project and APNs key. Tokens are bound to your credentials, not to your previous vendor. A new Firebase project kills every Android token you hold, and no import recovers them.
  2. Import contacts first — this endpoint never creates one. Key them on external_id, then key your device rows on the same value.
  3. Import devices here, 1,000 at a time, omitting sdk_version. Poll each job and reconcile its errors before moving on.
  4. Apply opt-outs last, with Unregister Device. That revocation is durable and re-registration will not undo it, so it must come after the import rather than before.
  5. Then ship the app build carrying the Arsel SDK. Imported devices are already reachable, so campaigns work immediately — they do not wait for users to update.
Web push does not migrate as tokens

Browser subscriptions are encrypted to the sender's VAPID key pair, so a subscription minted under another provider's keys cannot be sent to under different ones. What you carry across is the key pair itself — import it when you provision Web Push. See Migrating an existing web audience.


Error Responses

Row-level failures are reported in errors (shape) or on the errors route (processing), never as a request error. The request itself fails only when the envelope is wrong.

Fewer than 1 or more than 1,000 items:

{
"status_code": 422,
"name": "validation_error",
"message": "devices must contain no more than 1000 elements"
}