ESC
Type to search...
S
Soli Docs

Apple Push (APNs)

Reach a macOS or iOS app that is closed.

Notifications stop at the edge of “app is open” — a closed app is not executing, so something else has to be listening. On Apple platforms that is APNs: the OS receives the push and displays it whether or not your app is running.

Sending

Apns.send(device_token, {
  "title": "New ping",
  "body":  "Ana replied to your comment",
  "url":   "/pings/3"
}, {
  "key":     File.read("AuthKey_ABC123.p8"),
  "key_id":  "ABC123DEFG",
  "team_id": "1A2B3C4D5E",
  "topic":   "net.example.myapp"
})
# => { "status": 200, "reason": "" }

Handling the result

It returns rather than raises, because a dead device token is an ordinary outcome:

class ApplePush
  static def deliver(device, payload)
    result = Apns.send(device["token"], payload, ApplePush.credentials())

    # The device is gone: stop sending to it, or you will do this forever.
    if result["status"] == 410 || result["reason"] == "Unregistered"
      Device.find(device["_key"]).destroy()
      return "pruned"
    end

    return "sent" if result["status"] == 200
    "failed"
  end
end
StatusReasonMeaning
200Accepted by Apple.
400BadDeviceTokenAlmost always the wrong gateway — a development build's token sent to production. Add "sandbox": true.
403InvalidProviderTokenThe .p8, key_id or team_id do not agree.
410UnregisteredApp removed. Delete the token.
429TooManyProviderTokenUpdatesMinting too often — see below.

Options

OptionMeaning
keyContents of the .p8 file, BEGIN/END lines included. Required.
key_idThe key's 10-character id. Required.
team_idYour Apple team id. Required.
topicThe app's bundle id. Required.
sandboxtrue for development builds.
priority10 immediate (default) or 5 power-considerate.
push_type"alert" (default), "background", "voip"
collapse_idNotifications sharing one replace each other.
expirationUnix time after which Apple stops trying.

title, body, badge and sound are wrapped into the aps envelope for you. Anything else rides along as custom data the app reads on tap:

Apns.send(token, {
  "title":     "Deploy finished",
  "body":      "v1.24.0 is live",
  "badge":     1,
  "deploy_id": "d_8123",          # custom — your app reads this
  "url":       "/deploys/d_8123"
}, options)

Token-based auth

One .p8 key works for every app under a team and never expires, where certificates are per-app and expire annually. Get one from Apple Developer → Keys, enable APNs on it, and note the key id.

Provider tokens are cached for 45 minutes, and that is not an optimization: Apple rate-limits minting, answering TooManyProviderTokenUpdates if you reissue more often than every 20 minutes. Apns.token(key, key_id, team_id) exposes one for a caller driving the HTTP itself.

What it costs

Receiving requires the aps-environment entitlement, which comes from a provisioning profile, which requires a paid Apple Developer account. An ad-hoc signed app cannot receive a push however correct the sender is.

Your app then registers and reports its token:

NSApplication.shared.registerForRemoteNotifications()

func application(_ app: NSApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    let hex = token.map { String(format: "%02x", $0) }.joined()
    // POST it to your app and store it against the signed-in user.
}

Combining the two

reached = Native.notify("user:#{str(user_id)}", payload)
Apns.send(device_token, payload, apns_options) if reached == 0

The bridge for anyone looking, APNs for anyone who is not — and no push service involved in the common case.