Push

Sending and fallback

One call sends to every device of a user, and can send a text when no device accepts the push.

Send a push

POST /v1/push takes app_id, to (user_id for every active device of a user, or device_id for one), and title, body, data, badge, sound, thread_id, ttl (seconds, up to 28 days), priority (high by default, or normal) and collapse_key (1 to 64 visible ASCII characters: a newer push with the same key replaces an older one not yet shown). It needs a title, a body or data. data values are strings, with at most 64 keys and 4096 bytes as JSON. Data-only pushes (no title and no body, to a device that registered no encryption key) are delivered silently as background pushes: nothing shows on the lock screen and your app handles the data. A background push carries no badge or sound, even if you send them.

Send an Idempotency-Key header. The same key returns the original push, and the same key with a different body is a 409 IDEMPOTENCY_KEY_REUSED. Test and live mode never replay each other.

const { data: push, error } = await honkio.push.send({
  appId: 'APP_ID',
  to: { userId: 'user_1042' },
  title: 'Your code',
  body: '123456',
}, { idempotencyKey: 'login-code-1042-2026-11-03' })
if (error) throw new Error(error.message)
console.log(push.id, push.status, push.device_count) // queued 2

The call answers 202 with the push queued and its device_count. GET /v1/push/:id returns it with each device's delivery result, and GET /v1/push lists pushes.

The final provider payload, after encryption for a device with a key, is limited to 4096 bytes. A bigger one is refused with 422 PUSH_PAYLOAD_TOO_LARGE and details.bytes_over, and nothing is charged.

With no active device and no fallback, the push is accepted and finishes as failed with NO_DEVICES, and nothing is charged.

const { data: push, error } = await honkio.push.get('PUSH_ID')
if (error) throw new Error(error.message)
console.log(push.status) // scheduled, queued, sending, sent, failed, fell_back or cancelled
for (const delivery of push.deliveries ?? []) console.log(delivery.device_id, delivery.status, delivery.error_code)

Scheduling a push

Add scheduled_at to send later, such as a reminder an hour before an appointment: an ISO 8601 date-time with an offset, 1 minute to 30 days ahead. The call answers 202 with status scheduled, is charged now for the devices targeted now, and counts against the daily push limit of the day it is due. Every push you read back carries scheduled_at, null on one sent at once, and GET /v1/push takes status=scheduled or status=cancelled.

const { data: push, error } = await honkio.push.send({
  appId: 'APP_ID',
  to: { userId: 'user_1042' },
  title: 'Appointment in one hour',
  body: 'Acme Dental, 2 pm today.',
  scheduledAt: '2026-10-01T13:00:00-04:00',
  expiresAt: '2026-10-01T13:45:00-04:00', // optional: not after 1:45 pm (default: 24 hours after scheduledAt)
}, { idempotencyKey: 'appt-5521-push-reminder' })
if (error) throw new Error(error.message)
console.log(push.id, push.status, push.scheduled_at, push.expires_at) // scheduled

While it is still scheduled, PATCH /v1/push/:id with a new scheduled_at moves it (the key needs push:m; 429 PUSH_DAILY_CAP when the new day is full) and DELETE /v1/push/:id cancels it, refunds the charge and returns its free deliveries to the month (push:d). A cancel cannot be undone. Either one on a push that is no longer scheduled is 422 PUSH_NOT_SCHEDULED. They fire push.rescheduled and push.cancelled.

// Move it, or cancel it (refunded), while it is still scheduled:
const moved = await honkio.push.update('PUSH_ID', { scheduledAt: '2026-10-02T09:00:00-04:00' })
if (moved.error) throw new Error(moved.error.message)

const cancelled = await honkio.push.cancel('PUSH_ID')
if (cancelled.error) throw new Error(cancelled.error.message)

A scheduled push must go out within 24 hours of its scheduled_at. For a tighter deadline, add expires_at: an ISO 8601 date-time with an offset, 1 minute to 24 hours after scheduled_at, and accepted only together with it (test keys too; anything else is 422 VALIDATION_ERROR). A push that cannot go out by its deadline fails with failure_code SCHEDULED_SEND_EXPIRED: nothing is sent, it is refunded as a cancel is, and push.failed fires. PATCH /v1/push/:id takes expires_at too, checked against the new scheduled_at, or null for the 24 hour default; left out, a stored one is kept while it still fits the new time and is otherwise dropped, back to the 24 hour default. Every push you read back carries expires_at, null for the 24 hour default.

When it is due it is sent like any other push, judged at that moment: if the account was suspended or push was turned off for it since (PUSH_DISABLED), the push fails and is refunded; a device deactivated since is a rejected, refunded delivery; and a fallback text runs every rule of a text sent at that moment, so a recipient who opted out is not texted. A test push goes through the simulated delivery like any test push.

Fall back to a text

Add fallback.sms with to, from and body, and honkio sends that text when no device accepts the push. from is required and must be a number you own, as on POST /v1/messages. The key needs permission to send messages as well as push (403 FORBIDDEN otherwise), and still needs it when the text is sent: if it was revoked or lost that permission meanwhile, the push fails with FORBIDDEN and no text goes out.

const { data: push, error } = await honkio.push.send({
  appId: 'APP_ID',
  to: { userId: 'user_1042' },
  title: 'Your code',
  body: '123456',
  fallback: {
    sms: { to: '+14165550123', from: '+14165550100', body: 'Your Acme code is 123456' },
  },
}, { idempotencyKey: 'login-code-1042-2026-11-03' })
if (error) throw new Error(error.message)

The text is sent when every device delivery failed: no active device, a rejected token, a rejected or missing credential, or Apple or Google still failing after the retry window. It is not sent when any device was accepted.

Accepted is not seen. Once Apple or Google accepts a push, honkio's work is done. It cannot know whether the phone was off, had no connection or had notifications disabled, so an accepted push never falls back.

The text goes through every rule POST /v1/messages applies: consent, opt-outs, sending limits, a verified mobile number on the account and balance. Its static checks (the number is yours and can send, the destination, the content and its length) run when you call POST /v1/push, so a broken fallback fails at once. The recipient checks run only when the text is sent, so a push that never needs a text is never refused for a text reason.

The push becomes fell_back, and fallback_message_id points at the message, which bills at the normal SMS price and fires the usual message events. If the text is refused, the push is failed and fallback_reason carries the SMS error code. A text the carrier does not answer in time still counts as fell_back: the message's own status and webhooks carry the outcome.

With no active device and a fallback set, honkio skips the push and sends the text straight away. The push itself is not charged.

Retries

A retryable failure from Apple or Google, such as a busy or unavailable service, is retried with backoff. With a fallback, honkio retries for up to about 60 seconds and then falls back. Without one, it makes at most three attempts within about 60 seconds and then the delivery fails.

A permanent failure (an invalid token, a rejected credential or no credential) is never retried. A fallback text refused for a retryable reason, such as a rate limit or an unavailable carrier, is retried for up to about two minutes before the push fails.

Price and the free allotment

$5.00 per 1,000 after 2,500 free each month.

You pay per device that Apple or Google accepts. A device that rejects the push is not charged. The first 2,500 accepted live deliveries each calendar month (Eastern time) are free, with no rollover. Test pushes are free and do not count. GET /v1/push/usage shows free_used and free_monthly. A fallback text bills as an SMS on its own message.

While rent is owed on a released phone number, live pushes are refused, including ones the free allotment would cover. A daily push limit applies to each account, and test mode is limited to 1,000 a day: beyond it the call is 429 PUSH_DAILY_CAP.

Errors

Errors specific to push, besides the usual validation and authentication errors:

CodeStatusMeaning
PUSH_PAYLOAD_TOO_LARGE422The final payload is over 4096 bytes. details.bytes_over says by how much. Nothing is charged.
PUSH_NO_CREDENTIAL422The app has no credential for the platform of any targeted device, and there is no fallback.
PUSH_NOT_SCHEDULED422Only a scheduled push can be moved or cancelled.
PUSH_DAILY_CAP429The account reached its daily push limit.
PUSH_DISABLED403Push is turned off for this account. Nothing was sent or charged; contact support.
INSUFFICIENT_BALANCE402The balance cannot cover the push. Top up and send again.
RENT_OWED402Rent is owed on a released phone number, so live pushes are refused, even ones the free allotment would cover.
IDEMPOTENCY_KEY_REUSED409The Idempotency-Key was already used with a different body.
PUSH_IDEMPOTENCY_IN_PROGRESS409The same Idempotency-Key was replayed while the first call was still being accepted. Retry shortly with the same key.
PUSH_PROVIDER_UNAVAILABLE503Returned when you upload or replace a credential and Apple or Google could not be reached to check it. Retry shortly.
PUSH_DEVICE_LIMIT409Registering a device: the app already has its maximum of active devices in this mode (1,000,000 live, 1,000 test). Unregister devices you no longer send to.
DPA_PUSH_ACCEPTANCE_REQUIRED409Creating a push app: the account accepted HonkIO’s data processing agreement before it covered push notifications. Accept the current version in Settings, Data processing agreement. An account that never accepted the agreement is not asked.

Webhooks

Eight events cover pushes, devices and credentials. Payloads carry IDs, status and error codes, never content, and livemode. The message events for a fallback text fire as usual.

EventWhen it firesFields in data
push.queuedThe push was accepted and queued.id, app_id, status, user_id, device_count, accepted_count, fallback_message_id, failure_code, livemode
push.sentAt least one device was accepted.id, app_id, status, user_id, device_count, accepted_count, fallback_message_id, failure_code, livemode
push.failedNo device accepted it and there was no usable fallback, or a scheduled push could not go out by its deadline (failure_code SCHEDULED_SEND_EXPIRED).id, app_id, status, user_id, device_count, accepted_count, fallback_message_id, failure_code, livemode
push.fell_backNo device accepted it and the fallback text was submitted.id, app_id, status, user_id, device_count, accepted_count, fallback_message_id, failure_code, livemode
push.cancelledA scheduled push was cancelled before it went out, and what it reserved was given back.id, app_id, status, user_id, device_count, accepted_count, fallback_message_id, failure_code, livemode, details.cancelled_at
push.rescheduledA scheduled push was moved to a new time.id, app_id, status, user_id, device_count, accepted_count, fallback_message_id, failure_code, livemode, details.previous_scheduled_at, details.scheduled_at
push_device.deactivatedA device was retired by Apple, Google or a browser push service (UNREGISTERED or INVALID_TOKEN, also for a browser subscription honkio can no longer read), as a token for another app on a credential that works (APP_MISMATCH), by the per-user device limit (DEVICE_LIMIT), as a browser whose push service kept refusing the app's key (VAPID_KEY_MISMATCH), or as a second copy of a browser subscription (DUPLICATE). Not fired when you delete a device yourself, nor for each browser a key replacement retires (<code>push_app.vapid_key_replaced</code> covers those).id, app_id, user_id, reason, livemode
push_app.credentials_invalidA provider rejected the credential. Once per change to invalid.id (the app), provider, environment, reason, livemode
push_app.vapid_key_replacedThe app's web push key pair was replaced. Every browser on the app was retired with VAPID_KEY_REPLACED, in one step: one event, with the count per mode.id (the app), vapid_public_key (the new one), devices_retired.live, devices_retired.test, livemode