In your Apple Developer Account, open Certificates → Keys and add a key. Enable Apple Push Notifications service (APNs), register it, and download the .p8 file.
2
Enter your credentials
Open the and fill in four fields:
Key Id, the identifier of the APNs key.
Key, the full contents of the downloaded .p8 file.
Team Id, your Apple Developer team identifier.
Topic (App Bundle Id), the bundle ID of the app you are sending to.
Then set the two toggles:
Send to Production, off by default. Off targets Apple’s sandbox, which a local development build needs. On targets production, for your App Store app. Getting this wrong is the usual cause of BadEnvironmentKeyInToken.
Attach Mutable Content, on by default. It adds "mutable-content": 1, which is what lets Courier’s run before the alert appears. The extension is what reports a delivery while your app is closed. Turn this off and delivery only registers in the foreground. See .
The APNs provider configuration with the Send to Production and Attach Mutable Content toggles.
Device tokens live on the user profile rather than on the send, so one profile can hold tokens for several devices and every push provider reads them from the same place. covers the model.APNs addresses the device by token, so the profile you send to needs an apn object. Store the token once with , which merges into the profile and creates it if it does not exist:
With Courier MCP, save the APNs tokens APNS_TOKEN_ONE and APNS_TOKEN_TWO on user_123.
A Courier mobile SDK writes that object for you once you extend CourierDelegate, so you send to a user_id and never handle a token. APNs is Apple-only, so that means the , , and SDKs. The Android SDK uses instead.For a one-off with no stored profile, pass it inline instead: "to": { "apn": { "token": "YOUR_APNS_TOKEN" } }.
Set up push notifications
Wire up the SDK, sync tokens, and send your first push.
Send by user id
The call in every language, and the rest of the profile object.
Without a user_id, Courier logs the message under a synthetic anon_<hash> recipient and you lose:
Stored preferences and profile.
Managed token lookup, expiry, sign-out cleanup, and bundleId filtering.
Delivery and click events on that person’s history.
Put both a user_id and a token in to to supply your own and keep the user.
To pick among several tokens on one user, see .
const { requestId } = await courier.send.message({ message: { // A provider recipient is a profile field, so it sits outside the typed union. to: { apn: { token: "YOUR_APNS_TOKEN", }, } as any, template: "nt_01kx4h2jdafq8bk9aftxak4b40", },});
// A provider recipient is a profile field, so it sits outside the typed union.to := param.Override[shared.UserRecipientParam]( json.RawMessage(`{"apn": {"token": "YOUR_APNS_TOKEN"}}`),)response, err := client.Send.Message(context.TODO(), courier.SendMessageParams{ Message: courier.SendMessageParamsMessage{ To: courier.SendMessageParamsMessageToUnion{OfUserRecipient: &to}, Template: courier.String("nt_01kx4h2jdafq8bk9aftxak4b40"), },})
// A provider recipient is a profile field, so it sits outside the typed union.UserRecipient to = UserRecipient.builder() .putAdditionalProperty("apn", JsonValue.from(Map.of( "token", "YOUR_APNS_TOKEN"))) .build();client.send().message(SendMessageParams.builder() .message(SendMessageParams.Message.builder() .to(to) .template("nt_01kx4h2jdafq8bk9aftxak4b40") .build()) .build());
// A provider recipient is a profile field, so it sits outside the typed union.UserRecipient to = UserRecipient.FromRawUnchecked( JsonSerializer.Deserialize<Dictionary<string, JsonElement>>( """{"apn": {"token": "YOUR_APNS_TOKEN"}}""" ));var response = await client.Send.Message(new() { Message = new() { To = to, Template = "nt_01kx4h2jdafq8bk9aftxak4b40" } });
covers the two levels and which one wins. lists the fields every push provider takes.An APNs override sits at providers.apn.override. body changes the notification, and config swaps the credentials the send authenticates with.
Each of these replaces the integration’s own credentials and settings, for one send.
Field
Replaces
isProduction
Which APNs environment the send targets. Defaults to true.
mutableContent
Whether the push is marked mutable, for a notification service extension.
teamId
The Apple team the send authenticates as.
key and keyId
The .p8 key and its id, on p8 auth.
pfx and passphrase
The .p12 certificate and its password, on p12 auth. passphrase replaces the integration’s pfxPassword.
override.body.aps replaces the aps dictionary, it does not merge. The rendered alert, sound, badge, category, thread-id, and content-available are all discarded unless you restate them. Use the individual fields above to change one and keep the rest.
An aps override leaves the rest of the request alone. expiry, topic, apns-priority, apns-collapse-id, and your own payload keys are headers or sit outside aps, and mutable content is applied afterward.
With Courier MCP, send my template to user_123 on push, with an APNs sound and badge.
Custom data, deep links on tap, and silent pushes are the same call with a different payload. covers each, including where APNs and FCM differ.Custom data not reaching the device? covers the one template setting that stops it.
APNs returns BadDeviceToken when the token does not match any registered device for the environment you target (production vs. sandbox). Common causes:
Wrong environment: a token registered against the sandbox environment fails in production, and vice versa. Check the Send to Production toggle in the .
Stale token: the user uninstalled and reinstalled the app, generating a new token. Update the stored token via the when your app receives a new registration token.
Token never registered: the token was never added to a Courier user profile. Your app must call the registration step on every launch.
The key that signed the APNs JWT belongs to a different team or bundle ID than the token. Check that the Team ID and Key ID in your Courier APNs configuration match the app whose tokens you send.
Courier records rejected tokens so later sends skip them. This applies only to managed tokens, the ones a Courier Mobile SDK syncs. Tokens you pass in apn.token or apn.tokens are used as sent.
BadDeviceToken, Unregistered, DeviceTokenNotForTopic, and MissingDeviceToken mark that token failed until the device registers a fresh one.
The status is scoped to one token and user. Other users, tokens, and channels are unaffected.
Managed tokens not refreshed in 60 days go stale and are skipped. The SDKs refresh on app launch.
If a user stops receiving push notifications, confirm their device has registered a current token. Reinstalling the app generates a new one. Confirm your app calls the Courier SDK’s token registration on every launch.