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
| Header | Value | Required |
|---|---|---|
Authorization | Bearer <your-api-key> | Yes |
Content-Type | application/json | Yes |
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
devices | array | Yes | 1–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"
}
]
}
external_idRows 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.
sdk_version for imported devicesThe 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."
}
]
}
| Field | Type | Description |
|---|---|---|
job_id | string | Poll the status route with this |
accepted | number | Rows queued for processing |
rejected | number | Rows rejected on shape and never queued |
errors | array | The 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"
}
| Status | Meaning |
|---|---|
queued | Accepted, not started. Keep polling. |
processing | Running. Keep polling. |
completed | Every accepted row was attempted. Check failed and read errors. |
failed | The 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:
| Code | Meaning | What to do |
|---|---|---|
CONTACT_NOT_FOUND | No contact matches this row's identifiers | Import the contact first — a device import never creates one |
CONTACT_CONFLICT | The identifiers match contacts that cannot be safely merged | Usually 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:
- 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.
- Import contacts first — this endpoint never creates one. Key them on
external_id, then key your device rows on the same value. - Import devices here, 1,000 at a time, omitting
sdk_version. Poll each job and reconcile its errors before moving on. - 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.
- 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.
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.
- 422 Envelope
- 401 Unauthorized
- 429 Rate Limited
Fewer than 1 or more than 1,000 items:
{
"status_code": 422,
"name": "validation_error",
"message": "devices must contain no more than 1000 elements"
}
{
"status_code": 401,
"name": "unauthorized",
"message": "Invalid API key"
}
{
"status_code": 429,
"name": "too_many_requests",
"message": "ThrottlerException: Too Many Requests"
}